commit 240e7f36774e019b432f4ae183065e6cb7205251 Author: Alexander Rosenberg Date: Fri Sep 23 03:33:44 2022 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6aaeeb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.idea +*.log +tmp/ +bin/ +.metadata +.classpath +.gradle/ +.project +.settings/ +build/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb1e48d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + 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. + + Curator + Copyright (C) 2021 Alexander Rosenberg + + 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) 2021 Alexander Rosenberg + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f06e0bd --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Curator + +A program to manage the collections for [Art Museum](https://gitlab.com/Zander671/art-museum). diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..960dfc4 --- /dev/null +++ b/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'application' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'commons-net:commons-net:3.8.0' + implementation 'com.googlecode.json-simple:json-simple:1.1.1' + implementation 'org.slf4j:slf4j-api:1.7.30' + implementation 'org.slf4j:slf4j-simple:1.7.30' + implementation 'com.drewnoakes:metadata-extractor:2.15.0' + implementation 'org.apache.xmlgraphics:batik-all:1.14' + implementation 'com.twelvemonkeys.imageio:imageio-bmp:3.7.0' + implementation 'com.twelvemonkeys.imageio:imageio-batik:3.7.0' + implementation 'com.twelvemonkeys.imageio:imageio-webp:3.7.0' + implementation 'com.twelvemonkeys.imageio:imageio-tiff:3.7.0' + implementation 'com.twelvemonkeys.imageio:imageio-icns:3.7.0' + implementation 'com.twelvemonkeys.imageio:imageio-jpeg:3.7.0' + implementation 'com.github.lgooddatepicker:LGoodDatePicker:11.2.1' + implementation 'org.jxmapviewer:jxmapviewer2:2.6' + implementation 'org.apache.tika:tika-core:2.1.0' + // Needed to fix Batik interfering by having the javax.xml package + configurations { + all*.exclude group: 'xml-apis', module: 'xml-apis' + } +} + +application { + mainClass = 'zander.Start' +} + +jar { + manifest { + attributes 'Main-Class': 'zander.Start' + } +} diff --git a/scripts/PKGBUILD b/scripts/PKGBUILD new file mode 100644 index 0000000..20ea005 --- /dev/null +++ b/scripts/PKGBUILD @@ -0,0 +1,37 @@ +# Maintainer: Alexander Rosenberg + +pkgname=curator +pkgver=1 +pkgrel=1 +pkgdesc="A program for uploading images to Art Museum" +url="https://gitlab.com/zander671/curator" +arch=('i686' 'x86_64') +license=('GPL3') +depends=('ffmpeg') +makedepends=('gradle' 'git') +source=("git+https://gitlab.com/zander671/curator.git") +sha256sums=('SKIP') + +pkgver(){ + cd $pkgname + git rev-list --count HEAD +} + +build() { + cd "$srcdir/$pkgname" + gradle installDist +} + +package() { + cd "$srcdir/$pkgname" + mkdir -p "$pkgdir/usr/share" + cp -r build/install/curator "$pkgdir/usr/share/" + mkdir -p "$pkgdir/usr/bin" + ln -fs "/usr/share/curator/bin/curator" "$pkgdir/usr/bin/curator" + install -m644 -D LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -m644 -D README.md "$pkgdir/usr/share/doc/$pkgname/README" + install -m644 -D scripts/linux/curator.xpm "$pkgdir/usr/share/pixmaps/curator.xpm" + install -m644 -D scripts/linux/curator.png "$pkgdir/usr/share/icons/hicolor/48x48/apps/curator.png" + install -m644 -D scripts/linux/curator.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/curator.svg" + install -m644 -D scripts/linux/curator.desktop "$pkgdir/usr/share/applications/curator.desktop" +} diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 0000000..3d1966b --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +echo 'Generating macos app bundle...' + +realpath() { + path="$(ls "$PWD/$1")" + echo "$path" +} + +# Change our working directory to the parent directory of this script +real_path="$(realpath "$0")" +parent_dir="$(dirname "$real_path")" +cd "$parent_dir" + +if [ ! -e ../build/install/curator ]; then + echo "You must run 'gradle installDist' before this script!" 1>&2 + exit 1 +fi + +# Delete old app +rm -rf ../build/macos/Curator.app + +# Copy empty application +mkdir -p ../build/macos/Curator.app/Contents/MacOS/ ../build/macos/Curator.app/Contents/Resources/ + +# Copy Info.plist and icon +cp macos/Info.plist ../build/macos/Curator.app/Contents/Info.plist +cp macos/icon.icns ../build/macos/Curator.app/Contents/Resources/icon.icns + +# Copy compiled app +cp -r ../build/install/curator ../build/macos/Curator.app/Contents/MacOS/ +rm ../build/macos/Curator.app/Contents/MacOS/curator/bin/curator.bat + +echo "Compiling start script..." +# Compile start script (required to make JFileChooser work) +shc -r -f ../build/macos/Curator.app/Contents/MacOS/curator/bin/curator -o ../build/macos/Curator.app/Contents/MacOS/curator/bin/curator.x +rm ../build/macos/Curator.app/Contents/MacOS/curator/bin/curator.x.c +mv ../build/macos/Curator.app/Contents/MacOS/curator/bin/curator.x ../build/macos/Curator.app/Contents/MacOS/curator/bin/curator + +# Build install image (if we are on a mac) +echo 'Building intsall image...' +if which hdiutil >/dev/null 2>/dev/null; then + # Set up temp dir + rm -rf ../build/tmp/macos + mkdir -p ../build/tmp/macos + cp -r ../build/macos/Curator.app ../build/tmp/macos/ + ln -s /Applications ../build/tmp/macos/Applications + + # Create image + hdiutil create -ov -volname "Curator" -srcfolder ../build/tmp/macos ../build/macos/Curator.dmg >/dev/null +else + echo "hdiutil(1) not found! Can't build DMG image." +fi + +echo "App bundle generated in 'build/macos'!" diff --git a/scripts/linux/curator.desktop b/scripts/linux/curator.desktop new file mode 100644 index 0000000..f776d19 --- /dev/null +++ b/scripts/linux/curator.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=Curator +GenericName=Media Manager +Comment=Manage photos uploaded to Art Museums instances +Exec=curator +Icon=curator +Type=Application +Terminal=false +Categories=Graphics; +StartupWMClass=Curator +Keywords=Curator;Art Museum;Media; diff --git a/scripts/linux/curator.png b/scripts/linux/curator.png new file mode 100644 index 0000000..3df2c51 Binary files /dev/null and b/scripts/linux/curator.png differ diff --git a/scripts/linux/curator.svg b/scripts/linux/curator.svg new file mode 100644 index 0000000..ea618f9 --- /dev/null +++ b/scripts/linux/curator.svg @@ -0,0 +1,76 @@ + + + + + + + + image/svg+xml + + + + + + + + C + + diff --git a/scripts/linux/curator.xpm b/scripts/linux/curator.xpm new file mode 100644 index 0000000..f1a3ea2 --- /dev/null +++ b/scripts/linux/curator.xpm @@ -0,0 +1,104 @@ +/* XPM */ +static char *logo[] = { +/* columns rows colors chars-per-pixel */ +"32 32 66 1 ", +" c None", +". c #000000", +"X c #020202", +"o c #030303", +"O c #060606", +"+ c #090909", +"@ c #0A0A0A", +"# c #0D0D0D", +"$ c #101010", +"% c #131313", +"& c #161616", +"* c #191919", +"= c #1B1B1B", +"- c #222222", +"; c #232323", +": c #242424", +"> c #272727", +", c #2D2D2D", +"< c #323232", +"1 c #333333", +"2 c #373737", +"3 c #383838", +"4 c #434343", +"5 c #4E4E4E", +"6 c #4F4F4F", +"7 c #505050", +"8 c #5E5E5E", +"9 c #6A6A6A", +"0 c #747474", +"q c #757575", +"w c #787878", +"e c #797979", +"r c #C18F00", +"t c #878787", +"y c #8A8A8A", +"u c #909090", +"i c #9F9F9F", +"p c #A0A0A0", +"a c #A2A2A2", +"s c #A3A3A3", +"d c #B6B6B6", +"f c #B7B7B7", +"g c #BABABA", +"h c #BBBBBB", +"j c #C0C0C0", +"k c #C3C3C3", +"l c #C4C4C4", +"z c #CDCDCD", +"x c #D0D0D0", +"c c #D1D1D1", +"v c #D2D2D2", +"b c #D3D3D3", +"n c #D5D5D5", +"m c #DBDBDB", +"M c #F5EDD5", +"N c #FAF5E8", +"B c #FAF6EA", +"V c #F0F0F0", +"C c #F1F1F1", +"Z c #F4F4F4", +"A c #F5F5F5", +"S c #FBFBFB", +"D c #FCFCFC", +"F c #FDFDFD", +"G c #FEFEFE", +"H c #FFFFFF", +/* pixels */ +" ", +" ", +" rrrrrrrrrrrrrrrrrrrr ", +" rrrrrrrrrrrrrrrrrrrr ", +" rrMNNNNNNNNNNNNNNMrr ", +" rrNHHHHHHHHHHHHHHNrr ", +" rrNHHHHHHHHHHHHHHNrr ", +" rrNHHHHHHHHZHHHHHNrr ", +" rrNHHHZj8-..@1tzHNrr ", +" rrNHHHe*.$473#.>vNrr ", +" rrNHZq.*aZHHHZu,hNrr ", +" rrNHh&$iHHHHHHHvmNrr ", +" rrNH5.9SHHHHHHHHHNrr ", +" rrNS$.fHHHHHHHHHHNrr ", +" rrNn..CHHHHHHHHHHNrr ", +" rrNj.OHHHHHHHHHHHNrr ", +" rrNl.OHHHHHHHHHHHNrr ", +" rrNn..CHHHHHHHHHHNrr ", +" rrNS$.fHHHHHHHHHHNrr ", +" rrNH5.9SHHHHHHHHHNrr ", +" rrNHh&$aHHHHHHHvmNrr ", +" rrNHZq.*aZHHHZu,hNrr ", +" rrNHHHe*.$462#.>vNrr ", +" rrNHHHZj8-..@1yvHNrr ", +" rrNHHHHHHHHZHHHHHNrr ", +" rrNHHHHHHHHHHHHHHNrr ", +" rrNHHHHHHHHHHHHHHNrr ", +" rrMNNNNNNNNNNNNNNMrr ", +" rrrrrrrrrrrrrrrrrrrr ", +" rrrrrrrrrrrrrrrrrrrr ", +" ", +" " +}; diff --git a/scripts/macos/Info.plist b/scripts/macos/Info.plist new file mode 100644 index 0000000..9deb6b0 --- /dev/null +++ b/scripts/macos/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + English + + CFBundleExecutable + curator/bin/curator + + CFBundleIconFile + icon.icns + + CFBundleIdentifier + zander.curator + + CFBundleDisplayName + Curator + + CFBundleInfoDictionaryVersion + 6.0 + + CFBundleName + Curator + + CFBundlePackageType + APPL + + CFBundleVersion + 1.0 + + CFBundleShortVersionString + 1.0 + + CFBundleSignature + ???? + + CFBundleVersion + 1.0 + + NSHumanReadableCopyright + (c) 2021 Alexander Rosenberg + + + diff --git a/scripts/macos/icon.icns b/scripts/macos/icon.icns new file mode 100644 index 0000000..25715f6 Binary files /dev/null and b/scripts/macos/icon.icns differ diff --git a/src/main/java/zander/Start.java b/src/main/java/zander/Start.java new file mode 100644 index 0000000..a0be718 --- /dev/null +++ b/src/main/java/zander/Start.java @@ -0,0 +1,27 @@ +package zander; + +import javax.swing.SwingUtilities; + +import zander.library.secrets.PassSecretsFactory; +import zander.library.secrets.PreferencesSecretsFactory; +import zander.library.secrets.SecretsFactory; +import zander.ui.library.LibrarySelectFrame; + +public class Start { + + public static final String CURATOR_VERSION = "1.0"; + + public static void main(String[] args) { + // TODO debug + System.setProperty("sun.java2d.uiScale", "2"); + + registerSecretsFactories(); + SwingUtilities.invokeLater(() -> new LibrarySelectFrame(args)); + } + + private static void registerSecretsFactories() { + SecretsFactory.registerFactory(new PassSecretsFactory()); + SecretsFactory.registerFactory(new PreferencesSecretsFactory()); + } + +} diff --git a/src/main/java/zander/library/FileUtils.java b/src/main/java/zander/library/FileUtils.java new file mode 100644 index 0000000..05a9398 --- /dev/null +++ b/src/main/java/zander/library/FileUtils.java @@ -0,0 +1,35 @@ +package zander.library; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +import org.apache.tika.utils.SystemUtils; + +public class FileUtils { + + public static File createTempFile(boolean deleteOnExit) throws IOException { + return createTempFile(null, deleteOnExit); + } + + public static File createTempFile(byte[] data, boolean deleteOnExit) throws IOException { + File file; + if (SystemUtils.IS_OS_MAC) { + final File dir = new File(System.getProperty("user.home") + "/.cache/curator"); + dir.mkdirs(); + file = File.createTempFile("curator-temp-file-", null, dir); + } else { + file = File.createTempFile("curator-temp-file-", null); + } + if (deleteOnExit) { + file.deleteOnExit(); + } + if (data != null) { + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(data); + } + } + return file; + } + +} diff --git a/src/main/java/zander/library/Library.java b/src/main/java/zander/library/Library.java new file mode 100644 index 0000000..a4ad2af --- /dev/null +++ b/src/main/java/zander/library/Library.java @@ -0,0 +1,241 @@ +package zander.library; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.function.Consumer; + +import javax.swing.SwingUtilities; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.Manifest.ManifestDifference; +import zander.library.Manifest.ManifestParseException; +import zander.library.secrets.Secrets; + +public class Library { + private static final Logger LOGGER = LoggerFactory.getLogger(Library.class); + + public static final class SyncStatus { + private int total = -1; + private int current; + private boolean started; + private Throwable error; + private boolean canceled; + private String note; + private boolean done; + + private Consumer updateAction; + + public SyncStatus(Consumer updateAction) { + this.updateAction = updateAction; + } + + private synchronized void finish() { + done = true; + runUpdateAction(); + } + + public synchronized boolean isDone() { + return done; + } + + public synchronized String getNote() { + return note; + } + + private synchronized void setNote(String note) { + LOGGER.info(note); + this.note = note; + runUpdateAction(); + } + + private synchronized void setTotal(int total) { + this.total = total; + runUpdateAction(); + } + + public synchronized boolean isStarted() { + return started; + } + + public synchronized Consumer getUpdateAction() { + return updateAction; + } + + public synchronized Throwable getError() { + return error; + } + + public synchronized boolean isFailed() { + return error != null; + } + + public synchronized void setUpdateAction(Consumer updateAction) { + this.updateAction = updateAction; + } + + public synchronized int getTotal() { + return total; + } + + public synchronized int getCurrent() { + return current; + } + + public synchronized float getPrecent() { + return (float) current / total; + } + + public synchronized void cancel() { + canceled = true; + } + + public synchronized boolean isCanceled() { + return canceled; + } + + private synchronized void incrementCurrent() { + ++this.current; + runUpdateAction(); + } + + private synchronized void runUpdateAction() { + if (updateAction != null) { + SwingUtilities.invokeLater(() -> updateAction.accept(this)); + } + } + + } + + public static final String CONFIG_FILE_PATH = "config.json"; + public static final String ALBUMS_FILE_PATH = "albums"; + public static final String THUMBNAILS_FILE_PATH = "thumbnails"; + public static final String ALBUM_CONFIG_FILE_NAME = "data.json"; + + private LocalCache localCache; + private RemoteStore remoteStore; + + public Library(File local, URI remote, Secrets secrets) throws ManifestParseException, IOException { + remoteStore = new RemoteStore(remote, secrets); + localCache = new LocalCache(local); + } + + private void syncLocalToRemote(SyncStatus status) throws IOException, ManifestParseException { + if (status != null && !status.isStarted()) { + Thread t = new Thread(() -> { + status.started = true; + status.setNote("Fetching manifest..."); + try { + syncLocalToRemote(status); + } catch (Throwable e) { + LOGGER.error("Failed to sync local to remote!", e); + status.error = e; + status.runUpdateAction(); + return; + } + }); + t.start(); + return; + } + LOGGER.info("Syncing local cahce to remote store..."); + remoteStore.doFtpActions(() -> { + status.setNote("Retreving remote manifest"); + remoteStore.fetchManifest(); + Manifest rm = remoteStore.getManifest(); + status.setNote("Reading remote manifest"); + Manifest lm = localCache.getManifest(); + status.setNote("Calculating differences..."); + ManifestDifference diff = lm.compareTo(rm); + status.setTotal(diff.newFiles.size() + diff.removedFiles.size()); + if (status != null) { + if (status.isCanceled()) { + LOGGER.info("Sync canceled!"); + return; + } + } + status.setNote("Deleting old files..."); + for (String f : diff.removedFiles) { + localCache.deleteFile(f); + status.incrementCurrent(); + status.setNote("Deleted: " + f); + if (status != null) { + if (status.isCanceled()) { + LOGGER.info("Sync canceled!"); + return; + } + } + } + status.setNote("Retrieving new and updated files..."); + for (String f : diff.newFiles) { + status.setNote("Retrieving '" + f + "'.."); + byte[] data = remoteStore.retrieveFile(f); + localCache.addFile(f, data); + status.setNote("Retrived: '" + f + "' (" + data.length + " B)"); + status.incrementCurrent(); + if (status != null) { + if (status.isCanceled()) { + LOGGER.info("Sync canceled!"); + return; + } + } + } + }); + if (!status.isCanceled()) { + status.setNote("Done!"); + status.finish(); + } + } + + public void syncLocalToRemote() throws IOException, ManifestParseException { + syncLocalToRemote(null); + } + + public SyncStatus syncLocalToRemoteAsync(Consumer updateAction) { + SyncStatus s = new SyncStatus(updateAction); + try { + syncLocalToRemote(s); + } catch (ManifestParseException | IOException e) { + // ignore + } + return s; + } + + public void clearLocalCache() throws IOException { + localCache.clear(); + } + + public LocalCache getLocalCache() { + return localCache; + } + + public RemoteStore getRemoteStore() { + return remoteStore; + } + + public void addFile(String name, byte[] data) throws IOException { + localCache.addFile(name, data); + remoteStore.addFile(name, data); + } + + public void addBinaryFile(String name, byte[] data) throws IOException { + localCache.addFile(name, data); + remoteStore.addBinaryFile(name, data); + } + + public void addAsciiFile(String name, String data) throws IOException { + localCache.addFile(name, data); + remoteStore.addAsciiFile(name, data); + } + + public void deleteFile(String name) throws IOException { + localCache.deleteFile(name); + remoteStore.deleteFile(name); + } + + public void moveFile(String name, String target) throws IOException { + localCache.moveFile(name, target); + remoteStore.moveFile(name, target); + } +} diff --git a/src/main/java/zander/library/LibraryConfig.java b/src/main/java/zander/library/LibraryConfig.java new file mode 100644 index 0000000..83b72b4 --- /dev/null +++ b/src/main/java/zander/library/LibraryConfig.java @@ -0,0 +1,95 @@ +package zander.library; + +import java.util.ArrayList; +import java.util.List; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +public class LibraryConfig { + + private String name; + private String license; + private List albums; + + public LibraryConfig() { + this("", "", new ArrayList()); + } + + public LibraryConfig(String name, String license, List albums) { + this.name = name; + this.license = license; + this.albums = albums; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLicense() { + return license; + } + + public void setLicense(String license) { + this.license = license; + } + + public List getAlbums() { + return albums; + } + + public void setAlbums(List albums) { + this.albums = albums; + } + + public static LibraryConfig parseJson(String json) throws ParseException { + JSONParser p = new JSONParser(); + Object o = p.parse(json); + if (!(o instanceof JSONObject)) { + throw new ClassCastException("Json root was not an object"); + } + return parseJson((JSONObject) o); + } + + public static LibraryConfig parseJson(JSONObject json) { + Object nameObj = json.get("library_name"); + Object licenseObj = json.get("license"); + Object albumsObj = json.get("albums"); + if (!(nameObj instanceof String) || !(licenseObj instanceof String)) { + throw new ClassCastException("Name and license must be strings"); + } else if (!(albumsObj instanceof JSONArray)) { + throw new ClassCastException("Albums must be an array"); + } + JSONArray albumsArr = (JSONArray) albumsObj; + final ArrayList albums = new ArrayList(albumsArr.size()); + for (Object o : albumsArr) { + if (!(o instanceof String)) { + throw new ClassCastException("All members of albums must be a string"); + } + albums.add((String) o); + } + return new LibraryConfig((String) nameObj, (String) licenseObj, albums); + } + + @SuppressWarnings("unchecked") + public static JSONObject toJson(LibraryConfig lc) { + JSONObject root = new JSONObject(); + root.put("library_name", lc.getName()); + root.put("license", lc.getLicense()); + JSONArray albums = new JSONArray(); + albums.addAll(lc.getAlbums()); + root.put("albums", albums); + return root; + } + + public static String toJsonString(LibraryConfig lc) { + return toJson(lc).toJSONString(); + } + +} diff --git a/src/main/java/zander/library/LibraryMediaCache.java b/src/main/java/zander/library/LibraryMediaCache.java new file mode 100644 index 0000000..ce4167f --- /dev/null +++ b/src/main/java/zander/library/LibraryMediaCache.java @@ -0,0 +1,237 @@ +package zander.library; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import zander.library.media.MediaData; +import zander.library.media.MediaLoader; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.ui.ProgressTracker.ProgressManager; + +public class LibraryMediaCache { + + public static class AlbumMediaCache { + + private final HashMap cache = new HashMap(); + private final HashMap swapFiles = new HashMap(); + private final LibraryState library; + private final AlbumState album; + + public AlbumMediaCache(LibraryState library, String name) { + this.library = library; + album = library.getAlbumForName(name); + } + + public Map getAllMedia() { + return cache; + } + + public MediaData clearMedia(String name) { + return removeMedia(name); + } + + public void clear() { + cache.clear(); + swapFiles.clear(); + } + + public void swapOutMedia(String name) { + MediaData data = cache.remove(name); + if (data != null) { + swapFiles.put(name, data.getSourceFile()); + } + } + + public void swapOutAll(boolean doThumbnail) { + for (var i = cache.entrySet().iterator(); i.hasNext();) { + Entry entry = i.next(); + if (doThumbnail || !entry.getKey().equals(album.getThumbnailName())) { + i.remove(); + swapFiles.put(entry.getKey(), entry.getValue().getSourceFile()); + } + } + } + + public MediaData getMedia(String name, ProgressManager t) { + if (cache.containsKey(name)) { + return cache.get(name); + } + try { + if (swapFiles.containsKey(name)) { + File file = swapFiles.get(name); + MediaData data = MediaLoader.loadBytes(file, readFileBytes(file), false, t); + cache.put(name, data); + swapFiles.remove(name); + return data; + } + LocalCache lc = library.getLibrary().getLocalCache(); + String path = getPathForImage(name); + if (!lc.fileExists(path)) { + return null; + } + MediaData data = MediaLoader.loadBytes(lc.forPath(path), lc.retrieveFile(path), false, t); + cache.put(name, data); + return data; + } catch (IOException e) { + return null; + } + } + + public MediaData getCheckedMedia(String name, ProgressManager t) { + if (album.getMediaForName(name) != null) { + return getMedia(name, t); + } + return null; + } + + public void moveMedia(String oldName, String newName) { + if (cache.containsKey(oldName)) { + MediaData d = cache.remove(oldName); + cache.put(newName, d); + swapFiles.remove(oldName); + swapFiles.put(newName, d.getSourceFile()); + } + } + + public MediaData removeMedia(String name) { + swapFiles.remove(name); + return cache.remove(name); + } + + public void putMedia(String name, MediaData data) { + cache.put(name, data); + } + + private String getPathForImage(String name) { + return Library.ALBUMS_FILE_PATH + "/" + album.getNameOnDisk() + "/" + name; + } + + private byte[] readFileBytes(File file) throws IOException { + try (FileInputStream fin = new FileInputStream(file)) { + return fin.readAllBytes(); + } + } + } + + private final HashMap cache = new HashMap(); + private final LibraryState library; + + public LibraryMediaCache(LibraryState library) { + this.library = library; + } + + public LibraryState getLibrary() { + return library; + } + + public Map getAlbums() { + return cache; + } + + public AlbumMediaCache getAlbum(String name) { + if (cache.containsKey(name)) { + return cache.get(name); + } else if (!albumExists(name)) { + return null; + } + AlbumMediaCache album = new AlbumMediaCache(library, name); + cache.put(name, album); + return album; + } + + public void moveAlbum(String oldName, String newName) { + if (cache.containsKey(oldName)) { + AlbumMediaCache ac = cache.remove(oldName); + cache.put(newName, ac); + } + } + + public AlbumMediaCache removeAlbum(String album) { + return cache.remove(album); + } + + public void moveMedia(String album, String oldMedia, String newMedia) { + AlbumMediaCache a = getAlbum(album); + if (a != null) { + a.moveMedia(oldMedia, newMedia); + } + } + + public MediaData removeMedia(String album, String media) { + AlbumMediaCache a = getAlbum(album); + if (a != null) { + return a.removeMedia(media); + } + return null; + } + + public MediaData getMedia(String album, String media, ProgressManager t) { + AlbumMediaCache a = getAlbum(album); + if (a != null) { + return a.getMedia(media, t); + } + return null; + } + + public MediaData getCheckedMedia(String album, String media, ProgressManager t) { + AlbumMediaCache a = getAlbum(album); + if (a != null) { + return a.getCheckedMedia(media, t); + } + return null; + } + + public void putMedia(String album, String media, MediaData data) { + AlbumMediaCache a = getAlbum(album); + if (a != null) { + a.putMedia(media, data); + } + } + + public MediaData clearMedia(String album, String media) { + return removeMedia(album, media); + } + + public void swapOutAlbum(String album, boolean doThumbnail) { + AlbumMediaCache a = getAlbum(album); + if (a != null) { + a.swapOutAll(doThumbnail); + } + } + + public void swapOutAll(boolean doThumbnails) { + swapOutAllBut(null, doThumbnails); + } + + public void swapOutAllBut(String album, boolean doThumbnails) { + for (Entry entry : cache.entrySet()) { + AlbumMediaCache a = entry.getValue(); + if (!a.album.getName().equals(album)) { + a.swapOutAll(doThumbnails); + } + } + } + + public void clearAlbum(String name) { + cache.remove(name); + } + + public void clear() { + cache.clear(); + } + + private boolean albumExists(String name) { + for (AlbumState album : library.getAlbums()) { + if (name.equals(album.getName())) { + return true; + } + } + return false; + } + +} diff --git a/src/main/java/zander/library/LocalCache.java b/src/main/java/zander/library/LocalCache.java new file mode 100644 index 0000000..82afc15 --- /dev/null +++ b/src/main/java/zander/library/LocalCache.java @@ -0,0 +1,196 @@ +package zander.library; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.Manifest.ManifestParseException; + +public class LocalCache { + private static final Logger LOGGER = LoggerFactory.getLogger(LocalCache.class); + + public static final String MANIFEST_PATH = ".manifest"; + + /** Will always be canonical */ + private File root; + private Manifest manifest; + + /** @param uid the uid of the remote manifest */ + public LocalCache(File root) throws IOException, ManifestParseException { + this.root = root.getCanonicalFile(); + this.root.mkdirs(); + reloadManifest(); + } + + public File getRoot() { + return root; + } + + public Manifest getManifest() { + return manifest; + } + + private void reloadManifest() throws IOException, ManifestParseException { + File mf = getAbsoluteFile(MANIFEST_PATH); + if (!mf.exists()) { + manifest = new Manifest(); + writeManifest(); + } else { + manifest = new Manifest(mf); + } + } + + public void addFile(String name, String data) throws IOException { + addFile(name, data.getBytes()); + } + + public void addFile(String name, byte[] data) throws IOException { + createParentDirectories(name); + try (FileOutputStream out = new FileOutputStream(getAbsoluteFile(name))) { + out.write(data); + } + manifest.add(name, data); + LOGGER.info("Wrote file: {} ({} B)", name, data.length); + writeManifest(); + } + + public void deleteFile(String name) throws IOException { + File file = getAbsoluteFile(name); + recursiveDelete(file); + deleteEmptyParents(file, root); + manifest.delete(name); + LOGGER.info("Deleted file: {}", name); + writeManifest(); + } + + public void moveFile(String source, String target) throws IOException { + File sf = getAbsoluteFile(source); + File tf = getAbsoluteFile(target); + Path sp = sf.toPath(); + Path tp = tf.toPath(); + createParentDirectories(target); + Files.move(sp, tp, StandardCopyOption.REPLACE_EXISTING); + deleteEmptyParents(sf, tf); + manifest.move(source, target); + LOGGER.info("Moved file '{}' to '{}'", source, target); + writeManifest(); + } + + public byte[] retrieveFile(String path) throws IOException { + FileInputStream in = new FileInputStream(getAbsoluteFile(path)); + byte[] d = in.readAllBytes(); + in.close(); + return d; + } + + public boolean fileExists(String path) throws IOException { + File f = getAbsoluteFile(path); + return f.exists(); + } + + public boolean isFileTracked(String path) { + return manifest.contains(path); + } + + public void clear() throws IOException { + recursiveDelete(root); + manifest.clear(); + writeManifest(); + LOGGER.info("Cleared local cache"); + } + + public File forPath(String path) throws IOException { + return getAbsoluteFile(path); + } + + private String getRootPath() { + return root.getPath() + File.separator; + } + + private File getAbsoluteFile(String path) throws IOException { + return new File(getRootPath() + cleanPath(path)).getCanonicalFile(); + } + + private void writeManifest() throws IOException { + if (!root.exists()) { + root.mkdirs(); + } + try (FileOutputStream out = new FileOutputStream(getAbsoluteFile(MANIFEST_PATH))) { + byte[] data = manifest.getData().getBytes(); + out.write(data); + LOGGER.info("Wrote local manifest ({} B)", data.length); + } + } + + private void createParentDirectories(String path) throws IOException { + String pp = getParentPath(path); + if (pp == null) { + return; + } + File parent = getAbsoluteFile(pp); + if (parent.exists()) { + if (parent.isDirectory()) { + return; + } else { + throw new IOException("Not a directory: " + pp); + } + } + try { + if (!getAbsoluteFile(pp).mkdirs()) { + throw new IOException("Could not create directoy: '" + pp + "'"); + } + LOGGER.info("Created directory: {}", path); + } catch (SecurityException e) { + throw new IOException("No perms for directory: '" + pp + "'", e); + } + } + + private static void deleteEmptyParents(File file, File top) throws IOException { + if (!file.equals(top)) { + if (file.isDirectory() && file.list().length == 0) { + File parent = file.getParentFile(); + file.delete(); + deleteEmptyParents(parent, top); + } + } + } + + private static void recursiveDelete(File file) throws IOException { + if (file.isDirectory()) { + for (File child : file.listFiles()) { + recursiveDelete(child); + } + } + try { + file.delete(); + } catch (SecurityException e) { + throw new IOException("No perms for file: " + file.getPath(), e); + } + } + + private static String cleanPath(String p) { + if (!p.isEmpty() && p.charAt(0) == '/') { + p = p.substring(1); + } + if (p.isEmpty() && p.charAt(p.length() - 1) == '/') { + p = p.substring(0, p.length() - 1); + } + return p; + } + + private static String getParentPath(String path) { + String cp = cleanPath(path); + int i = cp.lastIndexOf("/"); + if (i == -1) { + return null; + } + return cp.substring(0, i); + } +} diff --git a/src/main/java/zander/library/Manifest.java b/src/main/java/zander/library/Manifest.java new file mode 100644 index 0000000..6d88b2b --- /dev/null +++ b/src/main/java/zander/library/Manifest.java @@ -0,0 +1,229 @@ +package zander.library; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +public class Manifest implements Cloneable { + private static MessageDigest HASHER; + static { + try { + HASHER = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new Error("Could not create SHA-256 MessageDigest!", e); + } + } + + public static class ManifestParseException extends Exception { + public ManifestParseException(String what, String line) { + this(what + ": HERE -> '" + line + "'"); + } + + public ManifestParseException(String message) { + super(message); + } + + public ManifestParseException(String message, Throwable cause) { + super(message, cause); + } + } + + private final Map entries; + + public Manifest() { + entries = new HashMap(); + } + + public Manifest(String source) throws ManifestParseException { + this(); + addEntriesFromString(source); + } + + public Manifest(File source) throws IOException, ManifestParseException { + this(); + addEntriesFromFile(source); + } + + public Map getEntries() { + return entries; + } + + public void add(String path, String data) { + add(path, data.getBytes()); + } + + public void add(String path, byte[] data) { + byte[] rh = HASHER.digest(data); + String b64eh = Base64.getEncoder().encodeToString(rh); + entries.put(cleanPath(path), b64eh); + } + + public boolean contains(String path) { + return entries.containsKey(cleanPath(path)); + } + + public boolean delete(String path) { + if (path.isEmpty()) { + return false; + } + String cp = cleanPath(path); + String hash = entries.remove(cleanPath(path)); + if (hash != null) { + return true; + } + // path is a directory + cp += "/"; + boolean deleted = false; + for (var i = entries.entrySet().iterator(); i.hasNext();) { + Entry e = i.next(); + if (e.getKey().startsWith(cp)) { + i.remove(); + deleted = true; + } + } + return deleted; + } + + public void move(String source, String target) { + String cs = cleanPath(source); + String ct = cleanPath(target); + String hash = entries.remove(cs); + if (hash != null) { + entries.put(ct, hash); + return; + } + final HashMap moved = new HashMap(); + // source & target are directory + cs += "/"; + ct += "/"; + for (var i = entries.entrySet().iterator(); i.hasNext();) { + Entry e = i.next(); + if (e.getKey().startsWith(cs)) { + i.remove(); + String np = replaceDirectoryPart(e.getKey(), cs, ct); + moved.put(np, e.getValue()); + } + } + entries.putAll(moved); + } + + /** path must be clean, od and nd can be clean or end with "/" (but not start with) */ + private String replaceDirectoryPart(String path, String od, String nd) { + String np = path.substring(od.length()); + np = nd + np; + return np; + } + + public String get(String file) { + return entries.get(file); + } + + public void clear() { + entries.clear(); + } + + public Set getFiles() { + return new HashSet(entries.values()); + } + + public String getData() { + final StringBuilder sb = new StringBuilder(); + for (Entry e : entries.entrySet()) { + sb.append(e.getKey()); + sb.append(" "); + sb.append(e.getValue()); + sb.append("\n"); + } + return sb.toString(); + } + + public static class ManifestDifference { + public final Set newFiles; + public final Set removedFiles; + + public ManifestDifference(Set nf, Set rf) { + newFiles = Collections.unmodifiableSet(new HashSet(nf)); + removedFiles = Collections.unmodifiableSet(new HashSet(rf)); + } + } + + public ManifestDifference compareTo(Manifest om) { + final HashSet nf = new HashSet(); + final HashSet rf = new HashSet(); + for (Entry e : om.entries.entrySet()) { + String oh = e.getValue(); + String th = entries.get(e.getKey()); + if (th == null || !th.equals(oh)) { + nf.add(e.getKey()); + } + } + for (Entry e : entries.entrySet()) { + String oh = om.entries.get(e.getKey()); + if (oh == null) { + rf.add(e.getKey()); + } + } + return new ManifestDifference(nf, rf); + } + + @Override + public Manifest clone() { + Manifest n = new Manifest(); + n.entries.putAll(entries); + return n; + } + + @Override + public String toString() { + return getData(); + } + + private void addEntriesFromString(String source) throws ManifestParseException { + String[] lines = source.split("\\r?\\n"); + for (String line : lines) { + addLineEntry(line); + } + } + + private void addLineEntry(String line) throws ManifestParseException { + if (line.isEmpty()) { + return; + } + int di = line.lastIndexOf(" "); + if (di == -1) { + throw new ManifestParseException("No divider", line); + } else if (di == line.length() - 1) { + throw new ManifestParseException("No hash", line); + } else if (di == 0) { + throw new ManifestParseException("No path", line); + } + String path = line.substring(0, di); + String hash = line.substring(di + 1); + entries.put(path, hash); + } + + private void addEntriesFromFile(File file) throws IOException, ManifestParseException { + try (FileInputStream fin = new FileInputStream(file)) { + addEntriesFromString(new String(fin.readAllBytes())); + } + } + + private static String cleanPath(String p) { + if (!p.isEmpty() && p.charAt(0) == '/') { + p = p.substring(1); + } + if (!p.isEmpty() && p.charAt(p.length() - 1) == '/') { + p = p.substring(0, p.length() - 1); + } + return p; + } +} diff --git a/src/main/java/zander/library/RemoteStore.java b/src/main/java/zander/library/RemoteStore.java new file mode 100644 index 0000000..508ffb1 --- /dev/null +++ b/src/main/java/zander/library/RemoteStore.java @@ -0,0 +1,370 @@ +package zander.library; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.ProtocolException; +import java.net.URI; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.net.ftp.FTP; +import org.apache.commons.net.ftp.FTPClient; +import org.apache.commons.net.ftp.FTPFile; +import org.apache.commons.net.ftp.FTPReply; +import org.apache.commons.net.ftp.FTPSClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.Manifest.ManifestParseException; +import zander.library.secrets.Secrets; + +public class RemoteStore { + private static final Logger LOGGER = LoggerFactory.getLogger(RemoteStore.class); + private static final String MANIFEST_PATH = ".manifest"; + + public static interface FTPAction { + public void run() throws IOException; + } + + private URI url; + private Secrets secrets; + private Manifest manifestCahce; + + /** may be connected or disconnected */ + private final FTPClient ftp; + private int connectionLevel = 0; + private boolean ignoreManifest = false; + + public RemoteStoreFileTypeHandler fileTypeHandler = new RemoteStoreFileTypeHandler(); + + public RemoteStore(URI url, Secrets secrets) throws IOException, ManifestParseException { + this.url = url; + if (!url.isAbsolute() || url.getScheme().equals("ftp")) { + ftp = new FTPClient(); + } else if (url.getScheme().equals("ftps")) { + ftp = new FTPSClient(false); + } else { + throw new ProtocolException("unknown protocol: '" + url.getScheme() + "'"); + } + this.secrets = secrets; + } + + public URI getURL() { + return url; + } + + public boolean isIgnoringManifest() { + return ignoreManifest; + } + + public void setIgnoreManifest(boolean ignoreManifest) { + this.ignoreManifest = ignoreManifest; + } + + public void fetchManifest() throws IOException { + doFtpActions(() -> { + FTPFile info = fileInformation(MANIFEST_PATH); + if (info == null) { + manifestCahce = new Manifest(); + uploadManifest(); + } else { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftp.setFileType(FTP.ASCII_FILE_TYPE); + ftp.retrieveFile(resolvePath(MANIFEST_PATH), out); + LOGGER.info("Retrieved manifest ({} B)", out.size()); + try { + manifestCahce = new Manifest(new String(out.toByteArray())); + } catch (ManifestParseException e) { + throw new IOException("Could not parse remote manifest"); + } + } + }); + } + + public Manifest getManifest() throws IOException { + if (manifestCahce == null) { + fetchManifest(); + } + return manifestCahce; + } + + public void addBinaryFile(String path, byte[] data) throws IOException { + doFtpActions(() -> { + Manifest mf = getManifest(); + createParentDirectories(path); + ftp.setFileType(FTP.BINARY_FILE_TYPE); + final ByteArrayInputStream in = new ByteArrayInputStream(data); + ftp.storeFile(resolvePath(path), in); + checkFtpResponse("Could not upload file: " + path); + LOGGER.info("Uploaded ascii file: {} ({} B)", path, data.length); + if (!ignoreManifest) { + mf.add(cleanPath(path), data); + uploadManifest(); + } + }); + } + + public void addAsciiFile(String path, String data) throws IOException { + doFtpActions(() -> { + final Manifest mf = getManifest(); + createParentDirectories(path); + ftp.setFileType(FTP.ASCII_FILE_TYPE); + final byte[] bytes = data.getBytes(); + final ByteArrayInputStream in = new ByteArrayInputStream(bytes); + ftp.storeFile(resolvePath(path), in); + checkFtpResponse("Could not upload file: " + path); + LOGGER.info("Uploaded file: {} ({} B)", path, bytes.length); + if (!ignoreManifest) { + mf.add(cleanPath(path), data); + uploadManifest(); + } + }); + } + + public void addFile(String path, byte[] data) throws IOException { + if (fileTypeHandler.isBinaryFile(path)) { + addBinaryFile(path, data); + } else { + addAsciiFile(path, new String(data)); + } + } + + public void deleteFile(String path) throws IOException { + doFtpActions(() -> { + Manifest mf = getManifest(); + String rp = resolvePath(path); + recursiveDelete(rp); + LOGGER.info("Deleted file: {}", path); + deleteEmptyUpward(rp); + if (!ignoreManifest) { + mf.delete(path); + uploadManifest(); + } + }); + } + + public void moveFile(String source, String target) throws IOException { + doFtpActions(() -> { + Manifest mf = getManifest(); + String rs = resolvePath(source); + String rt = resolvePath(target); + FTPFile info = fileInformation(target); + if (info != null) { + recursiveDelete(rt); + } + ftp.rename(rs, rt); + LOGGER.info("Moved remote file '{}' to '{}'", source, target); + if (!ignoreManifest) { + mf.move(cleanPath(source), cleanPath(target)); + uploadManifest(); + } + }); + } + + public byte[] retrieveBinaryFile(String path) throws IOException { + AtomicReference data = new AtomicReference(); + doFtpActions(() -> { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftp.setFileType(FTP.BINARY_FILE_TYPE); + ftp.retrieveFile(resolvePath(path), out); + checkFtpResponse("Could not retrieve binary file: " + path); + LOGGER.info("Retrieved binary file: {} ({} B)", path, out.size()); + data.set(out.toByteArray()); + }); + return data.get(); + } + + public String retrieveAsciiFile(String path) throws IOException { + AtomicReference data = new AtomicReference(); + doFtpActions(() -> { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftp.setFileType(FTP.ASCII_FILE_TYPE); + ftp.retrieveFile(resolvePath(path), out); + checkFtpResponse("Could not retrieve ascii file: " + path); + LOGGER.info("Retrieved ascii file: {} ({} B)", path, out.size()); + data.set(new String(out.toByteArray())); + }); + return data.get(); + } + + public byte[] retrieveFile(String path) throws IOException { + if (fileTypeHandler.isBinaryFile(path)) { + return retrieveBinaryFile(path); + } else { + return retrieveAsciiFile(path).getBytes(); + } + } + + public boolean fileExists(String path) throws IOException { + return fileInformation(path) != null; + } + + public FTPFile fileInformation(String path) throws IOException { + AtomicReference file = new AtomicReference(); + doFtpActions(() -> { + file.set(ftp.mlistFile(resolvePath(path))); + }); + return file.get(); + } + + public void openConnection() throws IOException { + if (connectionLevel++ == 0) { + int port = url.getPort(); + String host = url.getHost(); + ftp.connect(host == null ? "" : host, port == -1 ? FTPClient.DEFAULT_PORT : port); + if (!ftp.login(secrets.getUsername(), secrets.getPassword())) { + throw new IOException("Invalid ftp secrets! Username: '" + secrets.getUsername() + "'"); + } + LOGGER.info("Logged info ftp as user '{}'", secrets.getUsername()); + } + } + + public void closeConnection() throws IOException { + if (connectionLevel == 0) { + return; + } + if (--connectionLevel == 0) { + try { + ftp.logout(); + ftp.disconnect(); + } catch (IOException e) { + ftp.disconnect(); + throw e; + } finally { + LOGGER.info("Disconnected from ftp"); + } + } + } + + public void closeNow() throws IOException { + connectionLevel = 1; + closeConnection(); + } + + public void doFtpActions(FTPAction action) throws IOException { + try { + openConnection(); + action.run(); + } finally { + closeConnection(); + } + } + + /** path must be resolved */ + private void deleteEmptyUpward(String path) throws IOException { + doFtpActions(() -> { + if ((path).equals(cleanPath(getBasePath()))) { + return; + } + FTPFile info = fileInformation(path); + if (info == null) { + return; + } else if (!info.isDirectory()) { + throw new IOException("Not a directory: " + path); + } + FTPFile[] children = ftp.mlistDir(path); + checkFtpResponse("Could not list directory: " + path); + if (children.length == 0) { + ftp.deleteFile(path); + checkFtpResponse("Could not delete directory: " + path); + LOGGER.info("Deleted empty directory: {}", path); + deleteEmptyUpward(getParentPath(path)); + } + }); + } + + /** path must be resolved */ + private void recursiveDelete(String path) throws IOException { + doFtpActions(() -> { + FTPFile info = fileInformation(path); + if (info == null) { + return; + } else if (info.isDirectory()) { + FTPFile[] children = ftp.mlistDir(path); + checkFtpResponse("Could not list directory: " + path); + for (FTPFile child : children) { + recursiveDelete(path + "/" + child.getName()); + } + } + ftp.deleteFile(path); + checkFtpResponse("Could not delete file: " + path); + LOGGER.info("Deleted remote file: {}", path); + }); + } + + private void createParentDirectories(String path) throws IOException { + doFtpActions(() -> { + String pp = getParentPath(path); + if (pp == null) { + return; + } + FTPFile info = fileInformation(pp); + if (info == null) { + final StringBuilder pb = new StringBuilder(getBasePath()); + String[] parts = pp.split("/"); + for (String part : parts) { + pb.append(part); + pb.append("/"); + ftp.makeDirectory(pb.toString()); + } + checkFtpResponse("Could not create directory: " + pp); + LOGGER.info("Created directory: {}", pp); + } else if (!info.isDirectory()) { + throw new IOException("Not a directory: " + pp); + } + }); + } + + private void checkFtpResponse(String error) throws IOException { + if (ftp.isConnected()) { + int r = ftp.getReplyCode(); + if (!FTPReply.isPositiveCompletion(r)) { + LOGGER.info(error); + throw new IOException(error); + } + } + } + + private void uploadManifest() throws IOException { + doFtpActions(() -> { + Manifest mf = getManifest(); + byte[] bytes = mf.getData().getBytes(); + ByteArrayInputStream in = new ByteArrayInputStream(bytes); + ftp.setFileType(FTP.ASCII_FILE_TYPE); + ftp.storeFile(resolvePath(MANIFEST_PATH), in); + checkFtpResponse("Could not upload manifest"); + LOGGER.info("Uploaded manifest ({} B)", bytes.length); + }); + } + + private String getBasePath() { + String base = url.getPath(); + if (!base.endsWith("/")) { + base += "/"; + } + return base; + } + + private String resolvePath(String p) { + return getBasePath() + cleanPath(p); + } + + private static String cleanPath(String p) { + if (!p.isEmpty() && p.charAt(0) == '/') { + p = p.substring(1); + } + if (p.isEmpty() && p.charAt(p.length() - 1) == '/') { + p = p.substring(0, p.length() - 1); + } + return p; + } + + private static String getParentPath(String path) { + String cp = cleanPath(path); + int i = cp.lastIndexOf("/"); + if (i == -1) { + return null; + } + return cp.substring(0, i); + } +} diff --git a/src/main/java/zander/library/RemoteStoreFileTypeHandler.java b/src/main/java/zander/library/RemoteStoreFileTypeHandler.java new file mode 100644 index 0000000..6f79209 --- /dev/null +++ b/src/main/java/zander/library/RemoteStoreFileTypeHandler.java @@ -0,0 +1,71 @@ +package zander.library; + +import java.util.HashSet; +import java.util.Set; + +public class RemoteStoreFileTypeHandler { + + public static enum HandlerMode { + BINARY, + ASCII + }; + + public static final String[] INITIAL_FILTER = { "png", "jpg", "jpeg", "mp4", "mp3", "wav", "ogg", "mov", "bmp" }; + public static final HandlerMode INITIAL_MODE = HandlerMode.BINARY; + public static final boolean INITIAL_IS_EMPTY_BINARY = true; + + /** must be lower case */ + public Set filters = arrayToSet(INITIAL_FILTER); + public HandlerMode mode = INITIAL_MODE; + public boolean isEmptyBinary = INITIAL_IS_EMPTY_BINARY; + + public boolean isBinaryFile(String path) { + String e = getExtension(path).toLowerCase(); + if (e.length() == 0) { + return isEmptyBinary; + } + return filters.contains(e) && mode == HandlerMode.BINARY; + } + + public boolean isAsciiFile(String path) { + return !isBinaryFile(path); + } + + /** returns "" if file has no extension */ + private String getExtension(String path) { + String name = getName(path); + int i = name.lastIndexOf("."); + if (i == -1) { + return ""; + } + return name.substring(i + 1); + } + + private String getName(String path) { + String cp = cleanPath(path); + int i = cp.lastIndexOf("/"); + if (i == -1) { + return cp; + } + return cp.substring(i + 1); + } + + private String cleanPath(String path) { + if (path.startsWith("/")) { + path = path.substring(1); + } + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + return path; + } + + private static Set arrayToSet(T[] arr) { + final Set set = new HashSet(); + for (T e : arr) { + set.add(e); + } + return set; + } + +} diff --git a/src/main/java/zander/library/media/AudioMediaData.java b/src/main/java/zander/library/media/AudioMediaData.java new file mode 100644 index 0000000..b543675 --- /dev/null +++ b/src/main/java/zander/library/media/AudioMediaData.java @@ -0,0 +1,90 @@ +package zander.library.media; + +import java.awt.Component; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import org.apache.tika.Tika; + +import zander.library.FileUtils; +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; + +public class AudioMediaData implements MediaData { + private static final SerializableImageContainer MUSIC_ICON_IMAGE = getMusicIcon(); + private static final Tika TIKA = new Tika(); + + private final AudioMediaViewer viewer; + private final String type; + + public AudioMediaData(File file) throws IOException { + this(file, readFileBytes(file)); + } + + public AudioMediaData(byte[] data) throws IOException { + this(FileUtils.createTempFile(data, true), data); + } + + public AudioMediaData(File file, byte[] data) { + this.viewer = new AudioMediaViewer(file); + type = TIKA.detect(data); + } + + @Override + public byte[] loadRawBytes() throws IOException { + return readFileBytes(getSourceFile()); + } + + @Override + public String getType() { + return type; + } + + @Override + public Component createThumbnailComponent() { + ImageMediaComponent thumbnail = new ImageMediaComponent(MUSIC_ICON_IMAGE); + thumbnail.setPreferredSize(UIUtils.THUMBNAIL_SIZE); + return thumbnail; + } + + @Override + public Component getViewerComponent() { + return viewer; + } + + @Override + public File getSourceFile() { + return viewer.getSourceFile(); + } + + @Override + public void setSourceFile(File file) { + viewer.setSourceFile(file); + } + + @Override + public boolean hasSourceFile() { + return viewer.getSourceFile() != null; + } + + private static byte[] readFileBytes(File file) throws IOException { + FileInputStream in = new FileInputStream(file); + byte[] buffer = in.readAllBytes(); + in.close(); + return buffer; + } + + private static SerializableImageContainer getMusicIcon() { + try { + BufferedImage img = UIUtils.loadBufferedImage(ClassLoader.getSystemResource("music-icon.svg"), 1000, 1000); + return new SerializableImageContainer(img); + } catch (IOException e) { + ErrorDialog ed = new ErrorDialog("Internal Error", "A fatal error has occured!", e); + ed.setVisible(true); + System.exit(1); + return null; + } + } +} diff --git a/src/main/java/zander/library/media/AudioMediaViewer.java b/src/main/java/zander/library/media/AudioMediaViewer.java new file mode 100644 index 0000000..b02ed20 --- /dev/null +++ b/src/main/java/zander/library/media/AudioMediaViewer.java @@ -0,0 +1,26 @@ +package zander.library.media; + +import java.awt.image.BufferedImage; +import java.io.File; + +import zander.ui.UIUtils; + +public class AudioMediaViewer extends VideoMediaViewer { + private static final long serialVersionUID = -9143966700980397199L; + private static final SerializableImageContainer COVER_IMAGE; + static { + BufferedImage cover = null; + try { + cover = UIUtils.loadBufferedImage(ClassLoader.getSystemResource("music-icon.svg"), 1000, 1000); + } catch (Throwable e) { + System.err.println("Could not load internal resource."); + System.exit(0); + } + COVER_IMAGE = new SerializableImageContainer(cover); + } + + public AudioMediaViewer(File sourceFile) { + super(sourceFile, COVER_IMAGE); + } + +} diff --git a/src/main/java/zander/library/media/FFmpeg.java b/src/main/java/zander/library/media/FFmpeg.java new file mode 100644 index 0000000..46e9995 --- /dev/null +++ b/src/main/java/zander/library/media/FFmpeg.java @@ -0,0 +1,123 @@ +package zander.library.media; + +import java.awt.image.BufferedImage; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.concurrent.TimeUnit; + +import javax.imageio.ImageIO; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.FileUtils; +import zander.ui.ProgressTracker.ProgressManager; + +public class FFmpeg { + private static final Logger LOGGER = LoggerFactory.getLogger(FFmpeg.class); + private static final String FFMPEG_PATH = "ffmpeg"; + + public static BufferedImage createThumbnail(byte[] indata, ProgressManager t) throws IOException { + File tmp = FileUtils.createTempFile(indata, true); + File to = FileUtils.createTempFile(true); + final String[] COMMAND = { FFMPEG_PATH, "-ss", "0:0:0", "-i", tmp.getPath(), "-frames:v", "1", "-y", + "-nostdin", "-f", "image2", to.getPath() }; + final String STRCMD = String.join(" ", COMMAND); + final ProcessBuilder pb = new ProcessBuilder(COMMAND); + LOGGER.info("Starting FFmpeg: '" + STRCMD + "'"); + if (t != null) { + t.setIndeterminate(true); + } + final Process p = pb.start(); + try { + if (t == null) { + p.waitFor(2L, TimeUnit.SECONDS); + } else { + while (!p.waitFor(10L, TimeUnit.MILLISECONDS)) { + if (t.isCanceled()) { + dumpError(p); + p.destroyForcibly(); + return null; + } + } + } + if (p.exitValue() != 0) { + dumpError(p); + return null; + } + } catch (InterruptedException e) { + p.destroyForcibly(); + dumpError(p); + throw new IOException("Could not wait on FFmpeg: '" + STRCMD + "'"); + }catch (IllegalThreadStateException e) { + p.destroyForcibly(); + dumpError(p); + throw new IOException("FFmpeg not exited: '" + STRCMD + "'"); + } + BufferedImage img = ImageIO.read(to); + to.delete(); + tmp.delete(); + // dumpError(p); + LOGGER.info("FFmpeg done!"); + if (t != null) { + t.setIndeterminate(false); + } + return img; + } + + public static byte[] convertMedia(byte[] indata, String outfmt, ProgressManager t) throws IOException { + File tmp = FileUtils.createTempFile(indata, true); + File to = FileUtils.createTempFile(true); + final String[] COMMAND = { FFMPEG_PATH, "-i", tmp.getPath(), "-vcodec", "copy", "-y", + "-nostdin", "-f", outfmt, to.getPath() }; + final String STRCMD = String.join(" ", COMMAND); + final ProcessBuilder pb = new ProcessBuilder(COMMAND); + LOGGER.info("Starting FFmpeg: '" + STRCMD + "'"); + t.setIndeterminate(true); + final Process p = pb.start(); + try { + while (!p.waitFor(10L, TimeUnit.MILLISECONDS)) { + if (t.isCanceled()) { + dumpError(p); + p.destroyForcibly(); + return null; + } + } + if (p.exitValue() != 0) { + dumpError(p); + return null; + } + } catch (InterruptedException e) { + dumpError(p); + p.destroyForcibly(); + throw new IOException("Could not wait on FFmpeg: '" + STRCMD + "'"); + }catch (IllegalThreadStateException e) { + p.destroyForcibly(); + dumpError(p); + throw new IOException("FFmpeg not exited: '" + STRCMD + "'"); + } + final FileInputStream in = new FileInputStream(to); + final byte[] outdata = in.readAllBytes(); + in.close(); + tmp.delete(); + to.delete(); + // dumpError(p); + LOGGER.info("FFmpeg done!"); + t.setIndeterminate(false); + return outdata; + } + + private static void dumpError(Process p) throws IOException { + InputStream s = p.getErrorStream(); + BufferedReader err = new BufferedReader(new InputStreamReader(s)); + String line; + while ((line = err.readLine()) != null) { + System.err.println("[ffmpeg - stderr] " + line); + } + err.close(); + } +} diff --git a/src/main/java/zander/library/media/ImageMediaComponent.java b/src/main/java/zander/library/media/ImageMediaComponent.java new file mode 100644 index 0000000..f874247 --- /dev/null +++ b/src/main/java/zander/library/media/ImageMediaComponent.java @@ -0,0 +1,42 @@ +package zander.library.media; + +import java.awt.Graphics; +import java.awt.image.BufferedImage; + +import javax.swing.JComponent; + +public class ImageMediaComponent extends JComponent { + private static final long serialVersionUID = -471866227150575822L; + + private transient SerializableImageContainer image; + + public ImageMediaComponent(BufferedImage image) { + setOpaque(false); + this.image = new SerializableImageContainer(image); + } + + public ImageMediaComponent(SerializableImageContainer image) { + setOpaque(false); + this.image = image; + } + + public BufferedImage getImage() { + return image.getImage(); + } + + @Override + protected void paintComponent(Graphics g) { + BufferedImage image = this.image.getImage(); + int cw = getWidth(); + int ch = getHeight(); + int iw = image.getWidth(); + int ih = image.getHeight(); + float scale = Math.min((float) cw / iw, (float) ch / ih); + int fw = (int) Math.floor(scale * iw); + int fh = (int) Math.floor(scale * ih); + int fx = (cw / 2) - (fw / 2); + int fy = (ch / 2) - (fh / 2); + g.drawImage(image, fx, fy, fw, fh, null); + } + +} diff --git a/src/main/java/zander/library/media/ImageMediaData.java b/src/main/java/zander/library/media/ImageMediaData.java new file mode 100644 index 0000000..7d98fc8 --- /dev/null +++ b/src/main/java/zander/library/media/ImageMediaData.java @@ -0,0 +1,95 @@ +package zander.library.media; + +import java.awt.Component; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOError; +import java.io.IOException; + +import javax.imageio.ImageIO; + +import org.apache.tika.Tika; + +import zander.library.FileUtils; +import zander.ui.UIUtils; + +public class ImageMediaData implements MediaData { + private static final Tika TIKA = new Tika(); + + private File sourceFile; + + private final SerializableImageContainer data; + private final ImageMediaComponent viewer; + private final String type; + + public ImageMediaData(File file) throws IOException { + this(file, readFileBytes(file)); + } + + public ImageMediaData(byte[] data) throws IOException { + this(FileUtils.createTempFile(data, true), data); + } + + public ImageMediaData(File file, byte[] data) { + try { + ByteArrayInputStream in = new ByteArrayInputStream(data); + BufferedImage img = UIUtils.fitImageToBox(ImageIO.read(in), UIUtils.IMAGE_PREVEW_SIZE); + this.data = new SerializableImageContainer(img); + if (!this.data.hasImage()) { + throw new IllegalArgumentException("Image data was invalid"); + } + viewer = new ImageMediaComponent(this.data.getImage()); + type = TIKA.detect(data); + this.sourceFile = file; + } catch (IOException e) { + // Should not happen + throw new IOError(e); + } + } + + @Override + public byte[] loadRawBytes() throws IOException { + return readFileBytes(sourceFile); + } + + @Override + public String getType() { + return type; + } + + @Override + public Component createThumbnailComponent() { + ImageMediaComponent thumbnail = new ImageMediaComponent(this.data.getImage()); + thumbnail.setPreferredSize(UIUtils.THUMBNAIL_SIZE); + return thumbnail; + } + + @Override + public Component getViewerComponent() { + return viewer; + } + + @Override + public File getSourceFile() { + return sourceFile; + } + + @Override + public void setSourceFile(File file) { + this.sourceFile = file; + } + + @Override + public boolean hasSourceFile() { + return sourceFile != null; + } + + private static byte[] readFileBytes(File file) throws IOException { + FileInputStream in = new FileInputStream(file); + byte[] buffer = in.readAllBytes(); + in.close(); + return buffer; + } +} diff --git a/src/main/java/zander/library/media/MediaData.java b/src/main/java/zander/library/media/MediaData.java new file mode 100644 index 0000000..5168508 --- /dev/null +++ b/src/main/java/zander/library/media/MediaData.java @@ -0,0 +1,23 @@ +package zander.library.media; + +import java.awt.Component; +import java.io.File; +import java.io.IOException; + +public interface MediaData { + + public byte[] loadRawBytes() throws IOException; + + public String getType(); + + public Component createThumbnailComponent(); + + public Component getViewerComponent(); + + public File getSourceFile(); + + public void setSourceFile(File file); + + public boolean hasSourceFile(); + +} diff --git a/src/main/java/zander/library/media/MediaLoader.java b/src/main/java/zander/library/media/MediaLoader.java new file mode 100644 index 0000000..7b917a3 --- /dev/null +++ b/src/main/java/zander/library/media/MediaLoader.java @@ -0,0 +1,71 @@ +package zander.library.media; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import org.apache.tika.Tika; + +import zander.library.FileUtils; +import zander.ui.ProgressTracker.ProgressManager; + +public class MediaLoader { + private static final Tika TIKA = new Tika(); + + private static enum ContentType { + VIDEO, + AUDIO, + IMAGE, + UNKNOWN + } + + public static MediaData loadFile(File file, boolean moveToTemp, ProgressManager t) throws IOException { + FileInputStream in = new FileInputStream(file); + byte[] data = in.readAllBytes(); + in.close(); + return loadBytes(file.getCanonicalFile(), data, moveToTemp, t); + } + + public static MediaData loadBytes(File file, byte[] data, boolean moveToTemp, ProgressManager t) throws IOException { + MediaData md = null; + if (moveToTemp) { + file = FileUtils.createTempFile(data, true); + } + switch (getFileType(data)) { + case IMAGE: + md = new ImageMediaData(file, data); + break; + case VIDEO: + md = new VideoMediaData(file, data, t); + break; + case AUDIO: + md = new AudioMediaData(file, data); + break; + default: + md = new UnknownMediaData(file, data); + break; + } + return md; + } + + private static ContentType getFileType(byte[] data) { + try { + ByteArrayInputStream in = new ByteArrayInputStream(data); + String type = TIKA.detect(in); + if (type == null) { + return ContentType.UNKNOWN; + } else if (type.startsWith("image/")) { + return ContentType.IMAGE; + } else if (type.startsWith("video/")) { + return ContentType.VIDEO; + } else if (type.startsWith("audio/")) { + return ContentType.AUDIO; + } else { + return ContentType.UNKNOWN; + } + } catch (IOException e) { + return ContentType.UNKNOWN; + } + } +} diff --git a/src/main/java/zander/library/media/SerializableImageContainer.java b/src/main/java/zander/library/media/SerializableImageContainer.java new file mode 100644 index 0000000..f13a9c2 --- /dev/null +++ b/src/main/java/zander/library/media/SerializableImageContainer.java @@ -0,0 +1,51 @@ +package zander.library.media; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; + +import javax.imageio.ImageIO; + +public class SerializableImageContainer implements Serializable { + private static final long serialVersionUID = 2606473417847123614L; + + private transient BufferedImage image; + + public SerializableImageContainer(BufferedImage image) { + this.image = image; + } + + public BufferedImage getImage() { + return image; + } + + public void setImage(BufferedImage image) { + this.image = image; + } + + public boolean hasImage() { + return image != null; + } + + private void writeObject(ObjectOutputStream out) throws IOException { + out.defaultWriteObject(); + if (image == null) { + out.writeBoolean(false); + } else { + out.writeBoolean(true); + ImageIO.write(image, "png", out); + } + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + in.defaultReadObject(); + if (in.readBoolean()) { + image = ImageIO.read(in); + } else { + image = null; + } + } + +} diff --git a/src/main/java/zander/library/media/UnknownMediaData.java b/src/main/java/zander/library/media/UnknownMediaData.java new file mode 100644 index 0000000..f56feee --- /dev/null +++ b/src/main/java/zander/library/media/UnknownMediaData.java @@ -0,0 +1,98 @@ +package zander.library.media; + +import java.awt.Component; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import javax.swing.SwingUtilities; + +import org.apache.tika.Tika; + +import zander.library.FileUtils; +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; + +public class UnknownMediaData implements MediaData { + private static final Tika TIKA = new Tika(); + + private final SerializableImageContainer errorImage; + private final ImageMediaComponent viewer; + private final String type; + + private File sourceFile; + + public UnknownMediaData(File file) throws IOException { + this(file, readFileBytes(file)); + } + + public UnknownMediaData(byte[] data) throws IOException { + this(FileUtils.createTempFile(data, true), data); + } + + public UnknownMediaData(File file, byte[] data) { + errorImage = new SerializableImageContainer(getErrorIcon()); + viewer = new ImageMediaComponent(errorImage.getImage()); + type = TIKA.detect(data); + sourceFile = file; + } + + @Override + public byte[] loadRawBytes() throws IOException { + return readFileBytes(sourceFile); + } + + @Override + public String getType() { + return type; + } + + @Override + public Component createThumbnailComponent() { + ImageMediaComponent c = new ImageMediaComponent(errorImage.getImage()); + c.setPreferredSize(UIUtils.THUMBNAIL_SIZE); + return c; + } + + @Override + public Component getViewerComponent() { + return viewer; + } + + @Override + public File getSourceFile() { + return sourceFile; + } + + @Override + public void setSourceFile(File file) { + this.sourceFile = file; + } + + @Override + public boolean hasSourceFile() { + return sourceFile != null; + } + + private static byte[] readFileBytes(File file) throws IOException{ + try (FileInputStream in = new FileInputStream(file)) { + return in.readAllBytes(); + } + } + + private static BufferedImage getErrorIcon() { + try { + BufferedImage i = UIUtils.loadBufferedImage(ClassLoader.getSystemResource("missing-image-icon.svg"), -1, -1); + return i; + } catch (IOException e) { + SwingUtilities.invokeLater(() -> { + ErrorDialog ed = new ErrorDialog("Internal Error", "A fatal internal error has occured!", e); + ed.setVisible(true); + }); + System.err.println("Could not load missing image icon!"); + System.exit(1); + return null; + } + } +} diff --git a/src/main/java/zander/library/media/VideoMediaData.java b/src/main/java/zander/library/media/VideoMediaData.java new file mode 100644 index 0000000..337a37d --- /dev/null +++ b/src/main/java/zander/library/media/VideoMediaData.java @@ -0,0 +1,117 @@ +package zander.library.media; + +import java.awt.Component; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import javax.swing.SwingUtilities; + +import org.apache.tika.Tika; + +import zander.library.FileUtils; +import zander.ui.ErrorDialog; +import zander.ui.ProgressTracker.ProgressManager; +import zander.ui.UIUtils; + +public class VideoMediaData implements MediaData { + private static final Tika TIKA = new Tika(); + private static final SerializableImageContainer ERROR_ICON = getErrorIcon(); + + private SerializableImageContainer thumbnail; + private final VideoMediaViewer viewer; + private final String type; + + public VideoMediaData(File file, ProgressManager t) throws IOException { + this(readAllBytes(file), t); + } + + public VideoMediaData(byte[] data, ProgressManager t) throws IOException { + this(FileUtils.createTempFile(data, true), data, t); + } + + public VideoMediaData(File file, byte[] data, ProgressManager t) { + try { + BufferedImage i = getThumbnailFrame(data, t); + if (i != null) { + this.thumbnail = new SerializableImageContainer(i); + } else { + this.thumbnail = ERROR_ICON; + } + } catch (Throwable e) { + e.printStackTrace(); + this.thumbnail = ERROR_ICON; + } + viewer = new VideoMediaViewer(file, this.thumbnail); + type = TIKA.detect(data); + } + + public SerializableImageContainer getThumbnailFrame() { + return thumbnail; + } + + @Override + public byte[] loadRawBytes() throws IOException { + return readAllBytes(getSourceFile()); + } + + @Override + public String getType() { + return type; + } + + @Override + public Component createThumbnailComponent() { + ImageMediaComponent thumbnail = new ImageMediaComponent(this.thumbnail); + thumbnail.setPreferredSize(UIUtils.THUMBNAIL_SIZE); + return thumbnail; + } + + @Override + public Component getViewerComponent() { + return viewer; + } + + @Override + public File getSourceFile() { + return viewer.getSourceFile(); + } + + @Override + public void setSourceFile(File file) { + viewer.setSourceFile(file); + } + + @Override + public boolean hasSourceFile() { + return viewer.getSourceFile() != null; + } + + private static byte[] readAllBytes(File file) throws IOException { + FileInputStream in = new FileInputStream(file); + byte[] b = in.readAllBytes(); + in.close(); + return b; + } + + private static BufferedImage getThumbnailFrame(byte[] data, ProgressManager t) throws IOException { + BufferedImage img = FFmpeg.createThumbnail(data, t); + return UIUtils.fitImageToBox(img, UIUtils.IMAGE_PREVEW_SIZE); + } + + private static SerializableImageContainer getErrorIcon() { + try { + BufferedImage i = UIUtils.loadBufferedImage(ClassLoader.getSystemResource("video-icon.png"), -1, -1); + return new SerializableImageContainer(i); + } catch (IOException e) { + SwingUtilities.invokeLater(() -> { + ErrorDialog ed = new ErrorDialog("Internal Error", "A fatal internal error has occured!", e); + ed.setVisible(true); + }); + System.err.println("Could not load missing image icon!"); + System.exit(1); + return null; + } + } +} diff --git a/src/main/java/zander/library/media/VideoMediaViewer.java b/src/main/java/zander/library/media/VideoMediaViewer.java new file mode 100644 index 0000000..d25cadc --- /dev/null +++ b/src/main/java/zander/library/media/VideoMediaViewer.java @@ -0,0 +1,92 @@ +package zander.library.media; + +import java.awt.Color; +import java.awt.Desktop; +import java.awt.Graphics; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.io.IOException; + +import javax.swing.SwingUtilities; + +import zander.ui.ErrorDialog; + +public class VideoMediaViewer extends ImageMediaComponent { + private static final long serialVersionUID = 2913980500748591535L; + + private final MouseAdapter action = new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + Thread t = new Thread(() -> open()); + t.start(); + } + }; + private final Desktop desktop; + + private File sourceFile; + + public VideoMediaViewer(File sourceFile, SerializableImageContainer thumb) { + super(thumb); + if (Desktop.isDesktopSupported()) { + desktop = Desktop.getDesktop(); + addMouseListener(action); + } else { + desktop = null; + } + this.sourceFile = sourceFile; + } + + @Override + public void setEnabled(boolean enabled) { + boolean old = isEnabled(); + super.setEnabled(enabled); + if (!old && enabled) { + addMouseListener(action); + } else if (!enabled) { + removeMouseListener(action); + } + } + + public void setSourceFile(File file) { + this.sourceFile = file; + } + + public File getSourceFile() { + return sourceFile; + } + + public void open() { + try { + desktop.open(sourceFile); + } catch (IOException e) { + SwingUtilities.invokeLater(() -> { + ErrorDialog diag = new ErrorDialog("Open Error", "Could not open video!", e); + diag.setVisible(true); + }); + } + } + + @Override + public void paintComponent(Graphics g) { + super.paintComponent(g); + if (isEnabled()) { + int cw = getWidth(); + int ch = getHeight(); + int size = Math.round(Math.max(cw, ch) * 0.1f); + int cx = cw / 2; + int cy = ch / 2; + int x1 = cx - (size / 2); + int y1 = cy - (size / 2); + int x2 = cx - (size / 2); + int y2 = cy + (size / 2); + int x3 = cx + (size / 2); + int y3 = cy; + int[] xv = { x1, x2, x3 }; + int[] yv = { y1, y2, y3 }; + g.setColor(new Color(255, 255, 255, (2 * 255 / 3) /* 75% */)); + g.fillPolygon(xv, yv, 3); + } + } + +} diff --git a/src/main/java/zander/library/metadata/MediaMetadata.java b/src/main/java/zander/library/metadata/MediaMetadata.java new file mode 100644 index 0000000..9f59d62 --- /dev/null +++ b/src/main/java/zander/library/metadata/MediaMetadata.java @@ -0,0 +1,78 @@ +package zander.library.metadata; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalTime; + +public class MediaMetadata implements Serializable { + private static final long serialVersionUID = 7970810201152417800L; + + private String description; + private LocalDate date; + private LocalTime time; + private Double latitude; + private Double longitude; + private long size; + + public MediaMetadata() { + this(null, null, null, null, null, 0); + } + + public MediaMetadata(String description, LocalDate date, LocalTime time, Double latitude, Double longitude, long size) { + this.description = description; + this.date = date; + this.time = time; + this.latitude = latitude; + this.longitude = longitude; + this.size = size; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public LocalTime getTime() { + return time; + } + + public void setTime(LocalTime time) { + this.time = time; + } + + public Double getLatitude() { + return latitude; + } + + public void setLatitude(Double latitude) { + this.latitude = latitude; + } + + public Double getLongitude() { + return longitude; + } + + public void setLongitude(Double longitude) { + this.longitude = longitude; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + +} diff --git a/src/main/java/zander/library/metadata/MetadataLoader.java b/src/main/java/zander/library/metadata/MetadataLoader.java new file mode 100644 index 0000000..1427592 --- /dev/null +++ b/src/main/java/zander/library/metadata/MetadataLoader.java @@ -0,0 +1,127 @@ +package zander.library.metadata; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Collection; +import java.util.Date; + +import com.drew.imaging.FileType; +import com.drew.imaging.FileTypeDetector; +import com.drew.imaging.ImageMetadataReader; +import com.drew.imaging.ImageProcessingException; +import com.drew.lang.GeoLocation; +import com.drew.metadata.Directory; +import com.drew.metadata.Metadata; +import com.drew.metadata.exif.ExifIFD0Directory; +import com.drew.metadata.exif.ExifSubIFDDirectory; +import com.drew.metadata.exif.GpsDirectory; +import com.drew.metadata.iptc.IptcDirectory; +import com.drew.metadata.mov.metadata.QuickTimeMetadataDirectory; + +import zander.library.state.DateTimeUtils; + +public class MetadataLoader { + + public static MediaMetadata fromBytes(byte[] rawData) { + MediaMetadata md = new MediaMetadata(null, null, null, null, null, rawData.length); + FileType ft = null; + Metadata rm = null; + try { + ByteArrayInputStream bin = new ByteArrayInputStream(rawData); + BufferedInputStream bs = new BufferedInputStream(bin); + ft = FileTypeDetector.detectFileType(bs); + bin.reset(); + rm = ImageMetadataReader.readMetadata(bin); + } catch (IOException | ImageProcessingException e) { + return md; + } + switch (ft) { + case Png: + fillPngMetadata(md, rm); + break; + case Jpeg: + fillJpegMetadata(md, rm); + break; + case Tiff: + fillTiffMetadata(md, rm); + break; + case Mp4: + fillMp4Metadata(md, rm); + break; + case QuickTime: + fillQuickTimeMetadata(md, rm); + break; + default: + // Just return the empty object + break; + } + return md; + } + + private static void fillPngMetadata(MediaMetadata md, Metadata rm) { + fillDateTime(md, rm, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME); + fillGeoLocation(md, rm); + } + + private static void fillJpegMetadata(MediaMetadata md, Metadata rm) { + fillDescription(md, rm, IptcDirectory.class, IptcDirectory.TAG_CAPTION); + fillDateTime(md, rm, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME); + fillGeoLocation(md, rm); + } + + private static void fillTiffMetadata(MediaMetadata md, Metadata rm) { + fillDescription(md, rm, IptcDirectory.class, IptcDirectory.TAG_CAPTION); + fillDateTime(md, rm, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); + fillGeoLocation(md, rm); + } + + private static void fillMp4Metadata(MediaMetadata md, Metadata rm) { + fillGeoLocation(md, rm); + } + + private static void fillQuickTimeMetadata(MediaMetadata md, Metadata rm) { + fillDescription(md, rm, QuickTimeMetadataDirectory.class, QuickTimeMetadataDirectory.TAG_DESCRIPTION); + fillDateTime(md, rm, QuickTimeMetadataDirectory.class, QuickTimeMetadataDirectory.TAG_CREATION_DATE); + fillGeoLocation(md, rm); + } + + private static void fillDescription(MediaMetadata md, Metadata rm, Class dc, int tag) { + final Collection dirs = rm.getDirectoriesOfType(dc); + for (Directory d : dirs) { + String desc = d.getString(tag); + if (desc != null) { + md.setDescription(desc); + break; + } + } + } + + private static void fillDateTime(MediaMetadata md, Metadata rm, Class dc, int tag) { + final Collection dirs = rm.getDirectoriesOfType(dc); + for (Directory d : dirs) { + Date date = d.getDate(tag); + if (date != null) { + LocalDate ld = DateTimeUtils.dateToLocalDate(date); + LocalTime lt = DateTimeUtils.dateToLocalTime(date); + md.setDate(ld); + md.setTime(lt); + break; + } + } + } + + private static void fillGeoLocation(MediaMetadata md, Metadata rm) { + final Collection gpsDirs = rm.getDirectoriesOfType(GpsDirectory.class); + for (GpsDirectory d : gpsDirs) { + GeoLocation loc = d.getGeoLocation(); + if (loc != null && !loc.isZero()) { + md.setLatitude(loc.getLatitude()); + md.setLongitude(loc.getLongitude()); + break; + } + } + } +} diff --git a/src/main/java/zander/library/secrets/PassSecrets.java b/src/main/java/zander/library/secrets/PassSecrets.java new file mode 100644 index 0000000..bdc43d4 --- /dev/null +++ b/src/main/java/zander/library/secrets/PassSecrets.java @@ -0,0 +1,237 @@ +package zander.library.secrets; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class PassSecrets implements Secrets { + + private String name; + private HashMap keys; + + public PassSecrets(String name) { + this.name = name; + checkPassInstalledAndConfigured(); + addEntryIfAbsent(); + } + + private Process runPass(String ...args) { + String[] cmd = new String[args.length + 1]; + cmd[0] = "pass"; + System.arraycopy(args, 0, cmd, 1, args.length); + try { + return Runtime.getRuntime().exec(cmd); + } catch (IOException e) { + throw new RuntimeException("Could not execute pass", e); + } + } + + private void checkPassInstalledAndConfigured() { + try { + Process p = runPass(); + if (p.waitFor() != 0) { + String err = new String(p.getErrorStream().readAllBytes()); + throw new RuntimeException("Pass exited with code '" + p.exitValue() + "': " + err); + } + } catch (IOException | InterruptedException e) { + throw new RuntimeException("Could not execute pass", e); + } + } + + private void addEntry() { + try { + Process p = runPass("insert", name); + OutputStream out = p.getOutputStream(); + out.write('\n'); + out.flush(); + out.write('\n'); + out.close(); + p.waitFor(); + try { + if (p.waitFor() != 0) { + throw new RuntimeException("Pass failed!"); + } + } catch (InterruptedException e) { + throw new RuntimeException("Could not wait for pass", e); + } + } catch (IOException | InterruptedException e) { + throw new RuntimeException("Could not execute pass", e); + } + } + + private void addEntryIfAbsent() { + try { + Process p = runPass(name); + if (p.waitFor() != 0) { + addEntry(); + } + } catch (InterruptedException e) { + throw new RuntimeException("Could not execute pass", e); + } + } + + private void writeKeys() { + try { + Process p = runPass("insert", "-fm", name); + OutputStream out = p.getOutputStream(); + for (Map.Entry e : keys.entrySet()) { + if (e.getKey().length() == 0) { + out.write(e.getValue().getBytes()); + } else { + out.write((e.getKey() + ":" + e.getValue()).getBytes()); + } + out.write('\n'); + } + out.close(); + try { + if (p.waitFor() != 0) { + throw new RuntimeException("Pass failed!"); + } + } catch (InterruptedException e) { + throw new RuntimeException("Could not wait for pass", e); + } + } catch (IOException e) { + throw new RuntimeException("Could not execute pass", e); + } + } + + public String getPassEntryName() { + return name; + } + + @Override + public void refresh() { + try { + Process p = runPass(name); + if (p.waitFor() != 0) { + addEntry(); + } + InputStream in = p.getInputStream(); + String data = new String(in.readAllBytes()); + String[] lines = data.split("\n"); + HashMap map = new HashMap(); + if (lines.length == 0) { + map.put("", ""); + } else { + map.put("", lines[0]); + } + for (int i = 1; i < lines.length; ++i) { + Matcher m = Pattern.compile(":").matcher(lines[i]); + if (!m.find()) { + continue; + } + String key = lines[i].substring(0, m.start()); + if (m.start() + 1 == lines[i].length()) { + continue; + } + String value = lines[i].substring(m.start() + 1); + map.put(key, value); + } + keys = map; + } catch (IOException | InterruptedException e) { + throw new RuntimeException("Could not execute pass", e); + } + } + + private void refreshIfNeeded() { + if (keys == null) { + refresh(); + } + } + + @Override + public Map getKeys() { + refreshIfNeeded(); + return keys; + } + + @Override + public void setAll(Map keys) { + for (Map.Entry key : keys.entrySet()) { + set(key.getKey(), key.getValue()); + } + } + + @Override + public void clear() { + Map keys = getKeys(); + for (Map.Entry key : keys.entrySet()) { + remove(key.getKey()); + } + } + + @Override + public String getPassword() { + return get(""); + } + + @Override + public void setPassword(String password) { + set("", password); + } + + @Override + public String getUsername() { + return get("username"); + } + + @Override + public void setUsername(String username) { + set("username", username); + } + + @Override + public String get(String key) { + Map keys = getKeys(); + return keys.get(key); + } + + @Override + public void set(String key, String value) { + refreshIfNeeded(); + keys.put(key, value); + writeKeys(); + } + + @Override + public String remove(String key) { + refreshIfNeeded(); + String value = keys.remove(key); + writeKeys(); + return value; + } + + @Override + public boolean contains(String key) { + refreshIfNeeded(); + return keys.containsKey(key); + } + + @Override + public void delete() { + Process p = runPass("rm", "-f", name); + try { + if (p.waitFor() != 0) { + String err = new String(p.getErrorStream().readAllBytes()); + throw new RuntimeException("Pass exited with code '" + p.exitValue() + "': " + err); + } + } catch (InterruptedException | IOException e) { + throw new RuntimeException("Could not execute pass", e); + } + } + + @Override + public String getBackend() { + return "Pass"; + } + + @Override + public boolean isEncrypted() { + return true; + } + +} diff --git a/src/main/java/zander/library/secrets/PassSecretsFactory.java b/src/main/java/zander/library/secrets/PassSecretsFactory.java new file mode 100644 index 0000000..39180c2 --- /dev/null +++ b/src/main/java/zander/library/secrets/PassSecretsFactory.java @@ -0,0 +1,46 @@ +package zander.library.secrets; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class PassSecretsFactory extends SecretsFactory { + private static final long serialVersionUID = -2401450268365168782L; + + @Override + public boolean isEncrypted() { + return true; + } + + @Override + public boolean isRecommended() { + return true; + } + + @Override + public String getBackendName() { + return "Pass"; + } + + @Override + public Secrets createSecrets(String key) { + return new PassSecrets(key); + } + + @Override + public boolean isSupported() { + return isBackendSupported(); + } + + public static boolean isBackendSupported() { + try { + Process p = Runtime.getRuntime().exec("which pass"); + if (!p.waitFor(1000, TimeUnit.MILLISECONDS)) { + return false; + } + return p.waitFor() == 0; + } catch (IOException | InterruptedException e) { + return false; + } + } + +} diff --git a/src/main/java/zander/library/secrets/PreferencesSecrets.java b/src/main/java/zander/library/secrets/PreferencesSecrets.java new file mode 100644 index 0000000..6da1da0 --- /dev/null +++ b/src/main/java/zander/library/secrets/PreferencesSecrets.java @@ -0,0 +1,135 @@ +package zander.library.secrets; + +import java.util.HashMap; +import java.util.Map; +import java.util.prefs.BackingStoreException; +import java.util.prefs.Preferences; + +import zander.ui.UIUtils; + +public class PreferencesSecrets implements Secrets { + + private String path; + private Preferences prefs; + + public PreferencesSecrets(String path) { + this.path = path; + this.prefs = UIUtils.PREFS_ROOT.node(path); + } + + public String getPath() { + return path; + } + + @Override + public void refresh() { + // Do nothing + } + + @Override + public Map getKeys() { + checkState(); + final Map keys = new HashMap(); + try { + for (String key : prefs.keys()) { + keys.put(key, prefs.get(key, null)); + } + } catch (BackingStoreException e) { + throw new RuntimeException("Could not access preferences", e); + } + return keys; + } + + @Override + public void setAll(Map keys) { + checkState(); + for (Map.Entry key : keys.entrySet()) { + prefs.put(key.getKey(), key.getValue()); + } + } + + @Override + public void clear() { + checkState(); + try { + prefs.clear();; + } catch(BackingStoreException e) { + throw new RuntimeException("Could not access preferences", e); + } + } + + private void checkState() { + try { + if (!prefs.nodeExists("")) { + prefs = Preferences.userRoot().node(path); + } + } catch (BackingStoreException e) { + throw new RuntimeException("Could not acces preferences", e); + } + } + + @Override + public String getPassword() { + return get(""); + } + + @Override + public void setPassword(String password) { + set("", password); + } + + @Override + public String getUsername() { + return get("username"); + } + + @Override + public void setUsername(String username) { + set("username", username); + } + + @Override + public String get(String key) { + checkState(); + return prefs.get(key, null); + } + + @Override + public void set(String key, String value) { + checkState(); + prefs.put(key, value); + } + + @Override + public String remove(String key) { + checkState(); + String val = prefs.get(key, null); + prefs.remove(key); + return val; + } + + @Override + public boolean contains(String key) { + return get(key) != null; + } + + @Override + public void delete() { + try { + prefs.removeNode(); + } catch (BackingStoreException e) { + throw new RuntimeException("Could not access preferences", e); + } + } + + @Override + public String getBackend() { + return "Java Preferences API"; + } + + @Override + public boolean isEncrypted() { + return false; + } + +} diff --git a/src/main/java/zander/library/secrets/PreferencesSecretsFactory.java b/src/main/java/zander/library/secrets/PreferencesSecretsFactory.java new file mode 100644 index 0000000..bf3478f --- /dev/null +++ b/src/main/java/zander/library/secrets/PreferencesSecretsFactory.java @@ -0,0 +1,31 @@ +package zander.library.secrets; + +public class PreferencesSecretsFactory extends SecretsFactory { + private static final long serialVersionUID = 1L; + + @Override + public boolean isEncrypted() { + return false; + } + + @Override + public boolean isRecommended() { + return false; + } + + @Override + public String getBackendName() { + return "Java Preferences API"; + } + + @Override + public Secrets createSecrets(String key) { + return new PreferencesSecrets(key); + } + + @Override + public boolean isSupported() { + return true; + } + +} diff --git a/src/main/java/zander/library/secrets/Secrets.java b/src/main/java/zander/library/secrets/Secrets.java new file mode 100644 index 0000000..0eba28c --- /dev/null +++ b/src/main/java/zander/library/secrets/Secrets.java @@ -0,0 +1,37 @@ +package zander.library.secrets; + +import java.util.Map; + +public interface Secrets { + + public void refresh(); + + public Map getKeys(); + + public void setAll(Map keys); + + public void clear(); + + public String getPassword(); + + public void setPassword(String password); + + public String getUsername(); + + public void setUsername(String username); + + public String get(String key); + + public void set(String key, String value); + + public String remove(String key); + + public boolean contains(String key); + + public void delete(); + + public String getBackend(); + + public boolean isEncrypted(); + +} diff --git a/src/main/java/zander/library/secrets/SecretsFactory.java b/src/main/java/zander/library/secrets/SecretsFactory.java new file mode 100644 index 0000000..36a8651 --- /dev/null +++ b/src/main/java/zander/library/secrets/SecretsFactory.java @@ -0,0 +1,107 @@ +package zander.library.secrets; + +import java.io.Serializable; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public abstract class SecretsFactory implements Serializable { + private static final long serialVersionUID = 3438101849590929517L; + + public abstract boolean isEncrypted(); + + public abstract boolean isRecommended(); + + public abstract String getBackendName(); + + public abstract Secrets createSecrets(String key); + + public abstract boolean isSupported(); + + @Override + public boolean equals(Object o) { + if (o instanceof SecretsFactory) { + return ((SecretsFactory) o).getBackendName().equals(getBackendName()); + } + return false; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(getBackendName()); + if (isRecommended()) { + sb.append(" (Recommended)"); + } else if (!isEncrypted()) { + sb.append(" (Unencrypted, Not Recommended)"); + } + return sb.toString(); + } + + private static final SecretsFactory DEFAULT_FACTORY = new DefaultSecretsFactory(); + private static Set FACTORIES = Collections.unmodifiableSet(new HashSet()); + + public static void registerFactory(SecretsFactory c) { + final HashSet sf = new HashSet(FACTORIES); + sf.add(c); + FACTORIES = Collections.unmodifiableSet(sf); + } + + public static Set getFactories() { + return FACTORIES; + } + + + public static SecretsFactory getDefaultFactory() { + return DEFAULT_FACTORY; + } + + private static final class DefaultSecretsFactory extends SecretsFactory { + private static final long serialVersionUID = 8071227730925085343L; + + private SecretsFactory factory; + + public DefaultSecretsFactory() { + factory = getFactory(); + } + + private SecretsFactory getFactory() { + if (PassSecretsFactory.isBackendSupported()) { + return new PassSecretsFactory(); + } else { + return new PreferencesSecretsFactory(); + } + } + + @Override + public boolean isEncrypted() { + return factory.isEncrypted(); + } + + @Override + public boolean isRecommended() { + return factory.isRecommended(); + } + + @Override + public String getBackendName() { + return "Default"; + } + + @Override + public Secrets createSecrets(String key) { + return factory.createSecrets(key); + } + + @Override + public boolean isSupported() { + return factory != null && factory.isSupported(); + } + + @Override + public String toString() { + return "Auto (" + factory.getBackendName() + ")"; + } + + } + +} diff --git a/src/main/java/zander/library/state/AlbumConfig.java b/src/main/java/zander/library/state/AlbumConfig.java new file mode 100644 index 0000000..47bcf8e --- /dev/null +++ b/src/main/java/zander/library/state/AlbumConfig.java @@ -0,0 +1,232 @@ +package zander.library.state; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +public class AlbumConfig implements Serializable { + private static final long serialVersionUID = -5741698724083700184L; + + public static class AlbumImage implements Serializable { + private static final long serialVersionUID = 2580196202496621048L; + + public String name; + public String description; + public String date; + public String time; + public Number latitude; + public Number longitude; + + @SuppressWarnings("unchecked") + public JSONObject toJsonObject() { + JSONObject r = new JSONObject(); + if (description != null) { + r.put("description", description); + } + if (date != null) { + r.put("date", date); + } + if (time != null) { + r.put("time", time); + } + if (latitude != null) { + r.put("latitude", String.valueOf(latitude)); + } + if (longitude != null) { + r.put("longitude", String.valueOf(longitude)); + } + return r; + } + + public boolean hasDescription() { + return description != null; + } + + public boolean hasDate() { + return date != null; + } + + public boolean hasTime() { + return time != null; + } + + public boolean hasLatitude() { + return latitude != null; + } + + public boolean hasLongitude() { + return longitude != null; + } + + public boolean hasLocation() { + return hasLatitude() && hasLongitude(); + } + + public boolean isEmpty() { + return description == null && date == null && time == null && latitude == null && longitude == null; + } + } + + private String title; + private boolean nameFollowsTitle; + private String thumbnail; + private List images; + + public AlbumConfig() { + this("", false, "", new ArrayList()); + } + + public AlbumConfig(String title, boolean nameFollowsTitle, String thumbnail, List images) { + this.title = title; + this.nameFollowsTitle = nameFollowsTitle; + this.thumbnail = thumbnail; + this.images = images; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public boolean doesNameFollowTitle() { + return nameFollowsTitle; + } + + public void setNameFollowsTitle(boolean nameFollowsTitle) { + this.nameFollowsTitle = nameFollowsTitle; + } + + public String getThumnail() { + return thumbnail; + } + + public void setThumbnail(String thumbnail) { + this.thumbnail = thumbnail; + } + + public List getImages() { + return images; + } + + public void setImages(List images) { + this.images = images; + } + + public static AlbumConfig parseJson(String json) throws ParseException { + JSONParser p = new JSONParser(); + Object o = p.parse(json); + if (!(o instanceof JSONObject)) { + throw new ClassCastException("Json root was not an object"); + } + return parseJson((JSONObject) o); + } + + public static AlbumConfig parseJson(JSONObject json) { + Object titleObj = json.get("title"); + Object nameFollowsObj = json.get("nameFollowsTitle"); + Object thumbnailObj = json.get("thumbnail"); + Object imagesObj = json.get("images"); + if (!(titleObj instanceof String) || !(thumbnailObj instanceof String)) { + throw new ClassCastException("Title and thumbnail must be strings"); + } else if (!(imagesObj instanceof JSONArray)) { + throw new ClassCastException("Images must be an array"); + } else if (!(nameFollowsObj instanceof Boolean)) { + throw new ClassCastException("nameFollowsTitle must be a boolean"); + } + JSONArray imagesArr = (JSONArray) imagesObj; + final ArrayList images = new ArrayList(imagesArr.size()); + for (Object o : imagesArr) { + if (!(o instanceof String)) { + throw new ClassCastException("All members of images must be a string"); + } + images.add(getImage(json, (String) o)); + } + return new AlbumConfig((String) titleObj, (Boolean) nameFollowsObj, (String) thumbnailObj, images); + } + + private static AlbumImage getImage(JSONObject root, String name) { + AlbumImage i = new AlbumImage(); + i.name = name; + Object mtdaObj = root.get("metadata"); + if (mtdaObj instanceof JSONObject) { + JSONObject mtda = (JSONObject) mtdaObj; + Object imgObj = mtda.get(name); + if (imgObj instanceof JSONObject) { + JSONObject img = (JSONObject) imgObj; + Object descObj = img.get("description"); + if (descObj instanceof String) { + i.description = (String) descObj; + } else { + i.description = null; + } + Object dateObj = img.get("date"); + if (dateObj instanceof String) { + i.date = (String) dateObj; + } else { + i.date = null; + } + Object timeObj = img.get("time"); + if (timeObj instanceof String) { + i.time = (String) timeObj; + } else { + i.time = null; + } + Object latObj = img.get("latitude"); + if (latObj instanceof String) { + i.latitude = parseNumber((String) latObj); + } else { + i.latitude = null; + } + Object lonObj = img.get("longitude"); + if (lonObj instanceof String) { + i.longitude = parseNumber((String) lonObj); + } else { + i.longitude = null; + } + } + } + return i; + } + + private static Number parseNumber(String str) { + try { + return Double.parseDouble(str); + } catch (NumberFormatException | NullPointerException e) { + return null; + } + } + + @SuppressWarnings("unchecked") + public static JSONObject toJson(AlbumConfig lc) { + JSONObject root = new JSONObject(); + root.put("title", lc.getTitle()); + root.put("nameFollowsTitle", lc.doesNameFollowTitle()); + root.put("thumbnail", lc.getThumnail()); + JSONArray images = new JSONArray(); + JSONObject metadata = new JSONObject(); + for (AlbumImage i : lc.images) { + images.add(i.name); + if (!i.isEmpty()) { + metadata.put(i.name, i.toJsonObject()); + } + } + root.put("images", images); + if (!metadata.isEmpty()) { + root.put("metadata", metadata); + } + return root; + } + + public static String toJsonString(AlbumConfig lc) { + return toJson(lc).toJSONString(); + } + +} diff --git a/src/main/java/zander/library/state/AlbumState.java b/src/main/java/zander/library/state/AlbumState.java new file mode 100644 index 0000000..99048eb --- /dev/null +++ b/src/main/java/zander/library/state/AlbumState.java @@ -0,0 +1,134 @@ +package zander.library.state; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import org.json.simple.JSONObject; + +import zander.library.state.AlbumConfig.AlbumImage; + +public class AlbumState implements Serializable { + private static final long serialVersionUID = -5291546798543244207L; + + private String nameOnDisk; + private String name; + private String title; + private boolean nameFollowsTitle; + private String thumbnail; + private List media; + + private final String originalName; + private final String originalTitle; + private final String originalThumbnail; + private final List originalMedia; + + public AlbumState(String name, String title, boolean nameFollowsTitle, String thumbnail, List media) { + this.nameOnDisk = name; + this.name = name; + this.title = title; + this.nameFollowsTitle = nameFollowsTitle; + this.thumbnail = thumbnail; + this.media = new ArrayList(media); + + originalName = name; + originalTitle = title; + originalThumbnail = thumbnail; + originalMedia = new ArrayList(); + for (MediaState s : media) { + if (s.getNameOnDisk() != null) { + originalMedia.add(s.getNameOnDisk()); + } + } + } + + public List getMedia() { + return media; + } + + public void setMedia(List media) { + this.media = media; + } + + public String getThumbnailName() { + return thumbnail; + } + + public void setThumbnailName(String thumbnail) { + this.thumbnail = thumbnail; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public boolean doesNameFollowTitle() { + return nameFollowsTitle; + } + + public void setNameFollowsTitle(boolean nameFollowsTitle) { + this.nameFollowsTitle = nameFollowsTitle; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNameOnDisk() { + return nameOnDisk; + } + + public void setNameOnDisk(String nameOnDisk) { + this.nameOnDisk = nameOnDisk; + } + + public MediaState getMediaForName(String name) { + for (MediaState m : media) { + if (m.getName().equals(name)) { + return m; + } + } + return null; + } + + public String getOriginalName() { + return originalName; + } + + public String getOriginalTitle() { + return originalTitle; + } + + public String getOriginalThumbnail() { + return originalThumbnail; + } + + public List getOriginalMediaNames() { + return originalMedia; + } + + public boolean isThumbnail(MediaState state) { + return thumbnail.equals(state.getName()); + } + + public static JSONObject toJson(AlbumState state) { + final ArrayList images = new ArrayList(); + for (MediaState ms : state.media) { + images.add(MediaState.toAlbumImage(ms)); + } + AlbumConfig ac = new AlbumConfig(state.title, state.nameFollowsTitle, state.thumbnail, images); + return AlbumConfig.toJson(ac); + } + + public static String toJsonString(AlbumState state) { + return toJson(state).toJSONString(); + } +} diff --git a/src/main/java/zander/library/state/DateTimeUtils.java b/src/main/java/zander/library/state/DateTimeUtils.java new file mode 100644 index 0000000..67827f6 --- /dev/null +++ b/src/main/java/zander/library/state/DateTimeUtils.java @@ -0,0 +1,91 @@ +package zander.library.state; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Date; + +public class DateTimeUtils { + + private static final ArrayList DATE_PARSERS = new ArrayList(); + private static final ArrayList TIME_PARSERS = new ArrayList(); + static { + DATE_PARSERS.add(DateTimeFormatter.ofPattern("MMM d[','] u")); + DATE_PARSERS.add(DateTimeFormatter.ofPattern("MMMM d[','] u")); + DATE_PARSERS.add(DateTimeFormatter.ofPattern("MMMMM d[','] u")); + DATE_PARSERS.add(DateTimeFormatter.ofPattern("M'/'d'/'u")); + DATE_PARSERS.add(DateTimeFormatter.ofPattern("M'-'d'-'u")); + + TIME_PARSERS.add(DateTimeFormatter.ofPattern("k':'m[':'s]")); + TIME_PARSERS.add(DateTimeFormatter.ofPattern("h':'m[':'s] a")); + } + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MMMM d, uuuu"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("hh':'mm[':'ss] a"); + + public static LocalDate parseDate(String date) { + if (date == null) { + return null; + } + for (DateTimeFormatter f : DATE_PARSERS) { + try { + return LocalDate.parse(date, f); + } catch (DateTimeParseException e) { + continue; + } + } + return null; + } + + public static LocalTime parseTime(String time) { + if (time == null) { + return null; + } + for (DateTimeFormatter f : TIME_PARSERS) { + try { + return LocalTime.parse(time, f); + } catch (DateTimeParseException e) { + continue; + } + } + return null; + } + + public static String formatDate(LocalDate date) { + if (date == null) { + return null; + } + return date.format(DATE_FORMATTER); + } + + public static String formatTime(LocalTime time) { + if (time == null) { + return null; + } + return time.format(TIME_FORMATTER); + } + + public static LocalDate dateToLocalDate(Date date) { + if (date == null) { + return null; + } + return date.toInstant().atZone(ZoneId.of("GMT")).toLocalDate(); + } + + public static LocalTime dateToLocalTime(Date date) { + if (date == null) { + return null; + } + return date.toInstant().atZone(ZoneId.of("GMT")).toLocalTime().withSecond(0); + } + + public static LocalTime cleanLocalTime(LocalTime time) { + if (time == null) { + return null; + } + return LocalTime.of(time.getHour(), time.getMinute(), 0); + } + +} diff --git a/src/main/java/zander/library/state/LibraryConfig.java b/src/main/java/zander/library/state/LibraryConfig.java new file mode 100644 index 0000000..fbe046a --- /dev/null +++ b/src/main/java/zander/library/state/LibraryConfig.java @@ -0,0 +1,95 @@ +package zander.library.state; + +import java.util.ArrayList; +import java.util.List; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +public class LibraryConfig { + + private String name; + private String license; + private List albums; + + public LibraryConfig() { + this("", "", new ArrayList()); + } + + public LibraryConfig(String name, String license, List albums) { + this.name = name; + this.license = license; + this.albums = albums; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLicense() { + return license; + } + + public void setLicense(String license) { + this.license = license; + } + + public List getAlbums() { + return albums; + } + + public void setAlbums(List albums) { + this.albums = albums; + } + + public static LibraryConfig parseJson(String json) throws ParseException { + JSONParser p = new JSONParser(); + Object o = p.parse(json); + if (!(o instanceof JSONObject)) { + throw new ClassCastException("Json root was not an object"); + } + return parseJson((JSONObject) o); + } + + public static LibraryConfig parseJson(JSONObject json) { + Object nameObj = json.get("library_name"); + Object licenseObj = json.get("license"); + Object albumsObj = json.get("albums"); + if (!(nameObj instanceof String) || !(licenseObj instanceof String)) { + throw new ClassCastException("Name and license must be strings"); + } else if (!(albumsObj instanceof JSONArray)) { + throw new ClassCastException("Albums must be an array"); + } + JSONArray albumsArr = (JSONArray) albumsObj; + final ArrayList albums = new ArrayList(albumsArr.size()); + for (Object o : albumsArr) { + if (!(o instanceof String)) { + throw new ClassCastException("All members of albums must be a string"); + } + albums.add((String) o); + } + return new LibraryConfig((String) nameObj, (String) licenseObj, albums); + } + + @SuppressWarnings("unchecked") + public static JSONObject toJson(LibraryConfig lc) { + JSONObject root = new JSONObject(); + root.put("library_name", lc.getName()); + root.put("license", lc.getLicense()); + JSONArray albums = new JSONArray(); + albums.addAll(lc.getAlbums()); + root.put("albums", albums); + return root; + } + + public static String toJsonString(LibraryConfig lc) { + return toJson(lc).toJSONString(); + } + +} diff --git a/src/main/java/zander/library/state/LibraryState.java b/src/main/java/zander/library/state/LibraryState.java new file mode 100644 index 0000000..728d48d --- /dev/null +++ b/src/main/java/zander/library/state/LibraryState.java @@ -0,0 +1,165 @@ +package zander.library.state; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.json.simple.JSONObject; +import org.json.simple.parser.ParseException; + +import zander.library.Library; +import zander.library.LocalCache; +import zander.library.state.AlbumConfig.AlbumImage; + +public class LibraryState { + + private final Library library; + + private String name; + private String license; + private List albums; + + private String originalName; + private String originalLicense; + private List originalAlbums; + + public LibraryState(Library library) { + this.library = library; + } + + public Library getLibrary() { + return library; + } + + public List getAlbums() { + return albums; + } + + public void setAlbums(List albums) { + this.albums = albums; + } + + public String getLicense() { + return license; + } + + public void setLicense(String license) { + this.license = license; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public AlbumState getAlbumForName(String name) { + for (AlbumState a : albums) { + if (a.getName().equals(name)) { + return a; + } + } + return null; + } + + public AlbumState removeAlbumForName(String name) { + for (int i = 0; i < albums.size(); ++i) { + AlbumState a = albums.get(i); + if (a.getName().equals(name)) { + return albums.remove(i); + } + } + return null; + } + + public boolean hasAlbum(String name) { + return getAlbumForName(name) != null; + } + + public String getOriginalName() { + return originalName; + } + + public String getOriginalLicense() { + return originalLicense; + } + + public List getOriginalAlbumNames() { + return originalAlbums; + } + + private static Double numToDouble(Number num) { + if (num == null) { + return null; + } + return num.doubleValue(); + } + + private static MediaState getMediaFromLibrary(LocalCache cache, String albumPath, AlbumImage ai) throws IOException { + File imageFile = cache.forPath(albumPath + ai.name); + long size = imageFile.length(); + MediaState state = new MediaState(ai.name, ai.description, DateTimeUtils.parseDate(ai.date), DateTimeUtils.parseTime(ai.time), + numToDouble(ai.latitude), numToDouble(ai.longitude), size); + return state; + } + + private static AlbumState getAlbumFromLibrary(LibraryState parent, LibraryConfig libConf, String name) throws IOException, ParseException { + LocalCache cache = parent.library.getLocalCache(); + String albumPath = Library.ALBUMS_FILE_PATH + "/" + name + "/"; + AlbumConfig config; + if (cache.fileExists(albumPath + Library.ALBUM_CONFIG_FILE_NAME)) { + byte[] data = cache.retrieveFile(albumPath + Library.ALBUM_CONFIG_FILE_NAME); + config = AlbumConfig.parseJson(new String(data)); + } else { + config = new AlbumConfig(); + } + ArrayList media = new ArrayList(); + for (AlbumImage mn : config.getImages()) { + media.add(getMediaFromLibrary(cache, albumPath, mn)); + } + AlbumState state = new AlbumState(name, config.getTitle(), config.doesNameFollowTitle(), config.getThumnail(), media); + return state; + } + + public static LibraryState fromLibrary(Library library) throws IOException, ParseException { + LibraryState state = new LibraryState(library); + LocalCache cache = library.getLocalCache(); + LibraryConfig config; + if (cache.fileExists(Library.CONFIG_FILE_PATH)) { + byte[] data = cache.retrieveFile(Library.CONFIG_FILE_PATH); + config = LibraryConfig.parseJson(new String(data)); + } else { + config = new LibraryConfig(); + } + state.name = config.getName(); + state.originalName = state.name; + state.license = config.getLicense(); + state.originalLicense = state.license; + state.albums = new ArrayList(); + state.originalAlbums = new ArrayList(); + for (String album : config.getAlbums()) { + AlbumState as = getAlbumFromLibrary(state, config, album); + state.albums.add(as); + state.originalAlbums.add(as.getNameOnDisk()); + } + return state; + } + + public static JSONObject toJson(LibraryState state) { + final ArrayList albums = new ArrayList(); + for (AlbumState m : state.albums) { + albums.add(m.getName()); + } + LibraryConfig lc = new LibraryConfig(state.name, state.license, albums); + return LibraryConfig.toJson(lc); + } + + public static String toJsonString(LibraryState state) { + JSONObject jo = toJson(state); + return jo.toJSONString(); + } + +} diff --git a/src/main/java/zander/library/state/MediaState.java b/src/main/java/zander/library/state/MediaState.java new file mode 100644 index 0000000..c8b7915 --- /dev/null +++ b/src/main/java/zander/library/state/MediaState.java @@ -0,0 +1,173 @@ +package zander.library.state; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalTime; + +import zander.library.state.AlbumConfig.AlbumImage; + +public class MediaState implements Serializable { + private static final long serialVersionUID = 3132312462137542610L; + + private String name; + private String nameOnDisk; + private String description; + private LocalDate date; + private LocalTime time; + private Double latitude; + private Double longitude; + private long size; + + private final String originalName; + private final String originalDescription; + private final LocalDate originalDate; + private final LocalTime originalTime; + private final Double originalLatitude; + private final Double originalLongitude; + private final long originalSize; + + public MediaState(String name, String description, LocalDate date, LocalTime time, + Double latitude, Double longitude, long size) { + LocalTime cleanTime = DateTimeUtils.cleanLocalTime(time); + + this.name = name; + this.nameOnDisk = name; + this.description = description; + this.date = date; + this.time = cleanTime; + this.latitude = latitude; + this.longitude = longitude; + this.size = size; + + this.originalName = name; + this.originalDescription = description; + this.originalDate = date; + this.originalTime = cleanTime; + this.originalLatitude = latitude; + this.originalLongitude = longitude; + this.originalSize = size; + } + + public boolean hasDescription() { + return description != null; + } + + public boolean hasDate() { + return date != null; + } + + public boolean hasTime() { + return time != null; + } + + public boolean hasLatitude() { + return latitude != null; + } + + public boolean hasLongitude() { + return longitude != null; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public LocalTime getTime() { + return time; + } + + public void setTime(LocalTime time) { + this.time = DateTimeUtils.cleanLocalTime(time); + } + + public Double getLatitude() { + return latitude; + } + + public void setLatitude(Double latitude) { + this.latitude = latitude; + } + + public Double getLongitude() { + return longitude; + } + + public void setLongitude(Double longitude) { + this.longitude = longitude; + } + + public long getFileSize() { + return size; + } + + public void setFileSize(long size) { + this.size = size; + } + + public String getNameOnDisk() { + return nameOnDisk; + } + + public void setNameOnDisk(String nameOnDisk) { + this.nameOnDisk = nameOnDisk; + } + + public String getOriginalName() { + return originalName; + } + + public String getOriginalDescription() { + return originalDescription; + } + + public LocalDate getOriginalDate() { + return originalDate; + } + + public LocalTime getOriginalTime() { + return originalTime; + } + + public Double getOriginalLatitude() { + return originalLatitude; + } + + public Double getOriginalLongitude() { + return originalLongitude; + } + + public long getOriginalSize() { + return originalSize; + } + + public static AlbumImage toAlbumImage(MediaState state) { + AlbumImage ai = new AlbumImage(); + ai.name = state.name; + ai.description = state.description; + ai.date = DateTimeUtils.formatDate(state.date); + ai.time = DateTimeUtils.formatTime(state.time); + ai.latitude = state.latitude; + ai.longitude = state.longitude; + return ai; + } +} diff --git a/src/main/java/zander/ui/ErrorDialog.java b/src/main/java/zander/ui/ErrorDialog.java new file mode 100644 index 0000000..a2f2fc9 --- /dev/null +++ b/src/main/java/zander/ui/ErrorDialog.java @@ -0,0 +1,123 @@ +package zander.ui; + +import java.awt.BorderLayout; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.io.PrintWriter; +import java.io.StringWriter; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.SwingConstants; +import javax.swing.UIManager; + +public class ErrorDialog extends JDialog { + + private static final long serialVersionUID = 7389430189757589219L; + + private Throwable error; + + private final JLabel iconLabel; + private final JLabel messageLabel; + private final JPanel messagePanel; + private final JTextArea errorBox; + private final JScrollPane errorScroll; + private final JButton advancedButton; + private final JButton closeButton; + private final JPanel buttonPanel; + private final JPanel content; + + public ErrorDialog(String title, String message, Throwable error) { + this(null, title, message, error); + } + + public ErrorDialog(Window parent, String title, String message, Throwable error) { + super(parent, title); + this.error = error; + iconLabel = new JLabel(UIManager.getIcon("OptionPane.errorIcon")); + messageLabel = new JLabel(message, SwingConstants.CENTER); + messagePanel = new JPanel(); + messagePanel.setLayout(new BoxLayout(messagePanel, BoxLayout.X_AXIS)); + messagePanel.add(iconLabel); + messagePanel.add(messageLabel); + errorBox = new JTextArea(getErrorMessage()); + errorBox.setRows(getErrorBoxRows()); + errorBox.setColumns(getErrorBoxColumns()); + errorBox.setEditable(false); + errorScroll = new JScrollPane(errorBox); + errorScroll.setVisible(false); + advancedButton = new JButton("Show More..."); + advancedButton.setMnemonic(KeyEvent.VK_S); + advancedButton.addActionListener((e) -> { + errorScroll.setVisible(!errorScroll.isVisible()); + pack(); + }); + closeButton = new JButton("Close"); + advancedButton.setMnemonic(KeyEvent.VK_C); + closeButton.addActionListener((e) -> { + dispose(); + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(advancedButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(closeButton); + content = new JPanel(new BorderLayout()); + content.setLayout(new BorderLayout()); + content.add(messagePanel, BorderLayout.PAGE_START); + content.add(errorScroll, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + setContentPane(content); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + pack(); + UIUtils.centerWindow(this, parent); + } + + private int getErrorBoxRows() { + int nlc = 0; + for (char c : errorBox.getText().toCharArray()) { + if (c == '\n') { + ++nlc; + } + } + return Math.min(nlc, 15); + } + + private int getErrorBoxColumns() { + String[] lines = errorBox.getText().split("\\n"); + int longest = 0; + for (String line : lines) { + if (line.length() > longest) { + longest = line.length(); + } + } + return Math.min(longest, 35); + } + + public String getMessage() { + return messageLabel.getText(); + } + + public void setMessage(String message) { + messageLabel.setText(message); + } + + private String getErrorMessage() { + if (error == null) { + return "No error information available"; + } + final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw); + error.printStackTrace(pw); + return sw.toString(); + } + +} diff --git a/src/main/java/zander/ui/FileChooserField.java b/src/main/java/zander/ui/FileChooserField.java new file mode 100644 index 0000000..7037e81 --- /dev/null +++ b/src/main/java/zander/ui/FileChooserField.java @@ -0,0 +1,88 @@ +package zander.ui; + +import java.awt.GridBagLayout; +import java.awt.GridBagConstraints; +import java.io.File; + +import javax.swing.JButton; +import javax.swing.JFileChooser; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; + +public class FileChooserField extends JPanel { + private static final long serialVersionUID = -6880481322623583547L; + + private final JFileChooser chooser; + private final JTextField pathField; + private final JButton chooserButton; + private Runnable action; + + public FileChooserField() { + this(0); + } + + public FileChooserField(int cols) { + this(cols, "", null); + } + + public FileChooserField(int cols, String path) { + this(cols, path, null); + } + + public FileChooserField(int cols, String path, File dir) { + super(new GridBagLayout()); + chooser = new JFileChooser(dir); + chooser.setMultiSelectionEnabled(false); + chooser.setDialogType(JFileChooser.OPEN_DIALOG); + pathField = new JTextField(cols); + pathField.setText(path); + chooserButton = new JButton("..."); + chooserButton.addActionListener((e) -> { + if (action == null) { + int c = chooser.showDialog(SwingUtilities.windowForComponent(this), "Select"); + if (c == JFileChooser.APPROVE_OPTION) { + pathField.setText(chooser.getSelectedFile().getPath()); + } + } else { + action.run(); + } + }); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.weightx = 1.0f; + gbc.fill = GridBagConstraints.HORIZONTAL; + add(pathField, gbc); + gbc.weightx = 0.0f; + gbc.fill = GridBagConstraints.NONE; + gbc.gridx = 1; + add(chooserButton, gbc); + } + + public JFileChooser getChooser() { + return chooser; + } + + public JTextField getPathField() { + return pathField; + } + + public JButton getChooserBUtton() { + return chooserButton; + } + + public Runnable getAction() { + return action; + } + + public void setAction(Runnable action) { + this.action = action; + } + + @Override + public void setEnabled(boolean e) { + super.setEnabled(e); + pathField.setEnabled(e); + chooserButton.setEnabled(e); + } + +} diff --git a/src/main/java/zander/ui/LibraryFrame.java b/src/main/java/zander/ui/LibraryFrame.java new file mode 100644 index 0000000..0144008 --- /dev/null +++ b/src/main/java/zander/ui/LibraryFrame.java @@ -0,0 +1,248 @@ +package zander.ui; + +import java.awt.Component; +import java.awt.Window; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.prefs.Preferences; + +import javax.swing.JComponent; +import javax.swing.JFrame; + +import org.json.simple.parser.ParseException; + +import zander.library.Library; +import zander.library.LibraryMediaCache; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.ui._import.ImportAlbumSelectDialog; +import zander.ui.album.AlbumSelectPanel; +import zander.ui.library.LibrarySelectFrame; +import zander.ui.media.MediaManagePanel; + +public class LibraryFrame extends JFrame { + private static final long serialVersionUID = 3016763184972515087L; + private static final Preferences LF_CACHE = UIUtils.PREFS_ROOT.node("lf-cache"); + + private final Library realLibrary; + private LibraryState library; + private final Window oldWindow; + private final LibraryMediaCache cache; + private final AlbumSelectPanel albumPanel; + private final ArrayList openHistory = new ArrayList(); + + private boolean canceled = false; + + public LibraryFrame(Library library, Window oldWindow, String localName) { + this.realLibrary = library; + this.oldWindow = oldWindow; + try { + this.library = LibraryState.fromLibrary(library); + } catch (IOException | ParseException e) { + e.printStackTrace(); + ErrorDialog diag = new ErrorDialog("Internal Error", "An internal error has occured! Please try again.", e); + diag.setVisible(true); + dispose(); + oldWindow.setVisible(true); + albumPanel = null; + cache = null; + return; + } catch (ClassCastException e) { + e.printStackTrace(); + ErrorDialog diag = new ErrorDialog("Internal Error", "Library config invalid!", e); + diag.setVisible(true); + dispose(); + oldWindow.setVisible(true); + albumPanel = null; + cache = null; + return; + } + cache = new LibraryMediaCache(this.library); + setTitle(this.library.getName()); + albumPanel = new AlbumSelectPanel(this, openHistory, localName); + canceled = albumPanel.wasCancled(); + addComponentListener(new ComponentAdapter() { + @Override + public void componentMoved(ComponentEvent e) { + updateWindowCache(); + } + + @Override + public void componentResized(ComponentEvent e) { + updateWindowCache(); + } + }); + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + updateWindowCache(); + if (LibrarySelectFrame.ASK_TO_RETURN_TO_LIBRARY_SELECT) { + RememberConfirmDialog bcd = new RememberConfirmDialog(LibraryFrame.this, "Confirm Action", + "Are you sure you would like to exit? " + + "Any un-uploaded changes will be lost!"); + + bcd.setVisible(true); + if (bcd.getResponse() == RememberConfirmDialog.RESPONSE_NO) { + return; + } else if (bcd.rememberAwnser()) { + LibrarySelectFrame.updateAskToBack(false); + } + } + System.exit(0); + } + }); + readWindowCache(); + setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + setContentPane(albumPanel); + } + + public boolean wasCanceled() { + return canceled; + } + + public Library getRealLibrary() { + return realLibrary; + } + + public LibraryState getLibraryState() { + return library; + } + + public void backButtonHandler() { + Component cp = getContentPane(); + if (cp instanceof AlbumSelectPanel) { + if (LibrarySelectFrame.ASK_TO_RETURN_TO_LIBRARY_SELECT) { + RememberConfirmDialog bcd = new RememberConfirmDialog(this, "Confirm Action", + "Are you sure you would like " + + "to return to library select? " + + "Any un-uploaded changes will be lost!"); + bcd.setVisible(true); + if (bcd.getResponse() == RememberConfirmDialog.RESPONSE_NO) { + return; + } else if (bcd.rememberAwnser()) { + LibrarySelectFrame.updateAskToBack(false); + } + } + updateWindowCache(); + dispose(); + oldWindow.setVisible(true); + } else { + setCurrentPanel(albumPanel); + albumPanel.rereadAlbum(); + } + } + + public void setCurrentPanel(JComponent c) { + setContentPane(c); + revalidate(); + repaint(); + String name; + if (library.getName().isBlank()) { + name = "Unamed Library"; + } else { + name = library.getName(); + } + if (c instanceof AlbumSelectPanel) { + setTitle(name); + } else if (c instanceof MediaManagePanel) { + MediaManagePanel p = (MediaManagePanel) c; + String albumName; + if (p.getAlbum().getName().isBlank()) { + albumName = "Unamed Album"; + } else { + albumName = p.getAlbum().getTitle(); + } + setTitle(name + " - " + albumName); + } + } + + public LibraryMediaCache getMediaCache() { + return cache; + } + + public boolean handleImports(String[] files, boolean reorder) { + final ArrayList fal = new ArrayList(); + MediaManagePanel mp = null; + while (mp == null || mp.wasCanceled()) { + ImportAlbumSelectDialog iasd = new ImportAlbumSelectDialog(cache, library); + if (iasd.wasCanceled()) { + canceled = true; + return false; + } + iasd.setVisible(true); + AlbumState na = iasd.getSelectedAlbum(); + if (na == null) { + return false; + } + if (!library.getAlbums().contains(na)) { + library.getAlbums().add(na); + albumPanel.reloadAlbumsFromLibrary(); + } + fal.clear(); + for (String p : files) { + fal.add(new File(p)); + } + mp = new MediaManagePanel(this, na); + openHistory.remove(na); + openHistory.add(na); + while (openHistory.size() > AlbumSelectPanel.ALBUMS_TO_HOLD_IN_MEMORY) { + AlbumState toSwap = openHistory.remove(0); + cache.swapOutAlbum(toSwap.getName(), false); + } + } + setCurrentPanel(mp); + setVisible(true); + mp.importMediaFromFiles(fal, reorder); + return true; + } + + public void handleUploadDone(Component source) { + // updateWindowCache(); + // dispose(); + // oldWindow.setVisible(true); + if (oldWindow instanceof LibrarySelectFrame) { + ((LibrarySelectFrame) oldWindow).promptWebOpen(source); + } + } + + private void readWindowCache() { + int cw = LF_CACHE.getInt("width", -1); + int ch = LF_CACHE.getInt("height", -1); + if (cw < 0 || ch < 0) { + int w = UIUtils.SCREEN_SIZE.width / 2; + int h = UIUtils.SCREEN_SIZE.height / 2; + setSize(w, h); + LF_CACHE.putInt("width", w); + LF_CACHE.putInt("height", h); + } else { + setSize(cw, ch); + } + int cx = LF_CACHE.getInt("x", -1); + int cy = LF_CACHE.getInt("y", -1); + if (cx < 0 || cy < 0) { + UIUtils.centerWindow(this); + LF_CACHE.putInt("x", getX()); + LF_CACHE.putInt("y", getY()); + } else { + setLocation(cx, cy); + } + setExtendedState(LF_CACHE.getInt("extended", NORMAL)); + } + + private void updateWindowCache() { + LF_CACHE.putInt("x", Math.max(0, getX())); + LF_CACHE.putInt("y", Math.max(0, getY())); + LF_CACHE.putInt("width", getWidth()); + LF_CACHE.putInt("height", getHeight()); + int es = getExtendedState(); + if (es != ICONIFIED) { + LF_CACHE.putInt("extended", es); + } + } + +} diff --git a/src/main/java/zander/ui/LibraryUploadDialog.java b/src/main/java/zander/ui/LibraryUploadDialog.java new file mode 100644 index 0000000..cf9fe17 --- /dev/null +++ b/src/main/java/zander/ui/LibraryUploadDialog.java @@ -0,0 +1,492 @@ +package zander.ui; + +import java.awt.Dimension; +import java.awt.Window; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.Objects; + +import javax.imageio.ImageIO; +import javax.swing.BoxLayout; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.Library; +import zander.library.LibraryMediaCache; +import zander.library.RemoteStore; +import zander.library.media.ImageMediaData; +import zander.library.media.MediaData; +import zander.library.media.SerializableImageContainer; +import zander.library.media.VideoMediaData; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.library.state.MediaState; + +public class LibraryUploadDialog extends JDialog { + private static final long serialVersionUID = -2859125891849291L; + private static final Logger LOGGER = LoggerFactory.getLogger(LibraryUploadDialog.class); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Dimension SMALL_THUMBNAIL_SIZE = new Dimension(250, 250); + private static final Dimension MEDIUM_THUMBNAIL_SIZE = new Dimension(500, 500); + private static final Dimension LARGE_THUMBNAIL_SIZE = new Dimension(1000, 1000); + + private final JLabel headerLabel; + private final JProgressBar bar; + private final JTextArea messageArea; + private final JScrollPane messageScroll; + private final JPanel content; + + private int progress = 0; + + public LibraryUploadDialog(Window parent) { + super(parent, "Upload Changes"); + headerLabel = new JLabel("Uploading changes...", SwingConstants.CENTER); + headerLabel.setAlignmentX(JComponent.CENTER_ALIGNMENT); + bar = new JProgressBar(); + bar.setAlignmentX(JComponent.CENTER_ALIGNMENT); + messageArea = new JTextArea(); + messageArea.setEditable(false); + messageScroll = new JScrollPane(messageArea); + messageScroll.setAlignmentX(JComponent.CENTER_ALIGNMENT); + content = new JPanel(); + content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS)); + content.add(headerLabel); + content.add(bar); + content.add(messageScroll); + setContentPane(content); + setMinimumSize(new Dimension(UIUtils.SCREEN_SIZE.width / 3, UIUtils.SCREEN_SIZE.height / 3)); + setSize(getMinimumSize()); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); + setResizable(true); + UIUtils.centerWindow(this, parent); + } + + private void addMessage(String message) { + SwingUtilities.invokeLater(() -> { + String ct = messageArea.getText(); + if (ct.isBlank()) { + messageArea.setText(message); + } else { + messageArea.setText(ct + "\n" + message); + } + LOGGER.info(message); + }); + } + + public boolean upload(LibraryState state, LibraryMediaCache mc) { + int resp = JOptionPane.showConfirmDialog(getOwner(), "Are you sure you would like to upload changes?" + + "\nThis process may take a while and cannot be canceled.", + "Confirm Upload", JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE); + if (resp != JOptionPane.YES_OPTION) { + return false; + } + bar.setMinimum(0); + bar.setMaximum(getUploadLength(state)); + Thread ut = new Thread(() -> { + doUpload(state, mc); + }); + ut.start(); + setVisible(true); + return true; + } + + private void doUpload(LibraryState state, LibraryMediaCache mc) { + addMessage("Starting upload..."); + Library rl = state.getLibrary(); + RemoteStore rs = rl.getRemoteStore(); + try { + rs.doFtpActions(() -> { + addMessage("Moving albums..."); + addMessage("Resolving album movement, 1st pass."); + for (int i = 0; moveAlbums(state); ++i) { + if (i == state.getAlbums().size()) { + throw new IOException("Could not resolve album name conflicts"); + } + addMessage("Resolving album movement, " + getNumberSuffixString(i + 2) + " pass."); + } + addMessage("Done moving albums."); + incrementProgress(); + addMessage("Moving media..."); + moveMedia(state); + addMessage("Done moving media."); + incrementProgress(); + addMessage("Uploading library config..."); + rl.addAsciiFile(Library.CONFIG_FILE_PATH, LibraryState.toJsonString(state)); + incrementProgress(); + addMessage("Uploading album configs..."); + uploadAlbumConfigs(state); + addMessage("Uploading media..."); + uploadMedia(state, mc); + addMessage("Done uploading meadia."); + addMessage("Deleting old files..."); + deleteOldFiles(state); + incrementProgress(); + addMessage("Done deleteing old files."); + }); + } catch (Throwable e) { + e.printStackTrace(); + SwingUtilities.invokeLater(() -> { + setVisible(false); + ErrorDialog ed = new ErrorDialog("Upload Error", "Could not complete upload!", e); + ed.setVisible(true); + }); + } + addMessage("Upload complete. Have a nice day! ^_^"); + SwingUtilities.invokeLater(() -> setVisible(false)); + } + + private void deleteOldFiles(LibraryState state) throws IOException { + Library rl = state.getLibrary(); + RemoteStore rs = rl.getRemoteStore(); + for (String oan : state.getOriginalAlbumNames()) { + if (state.getAlbumForName(oan) == null) { + String ap = getPathForAlbum(oan); + if (rs.fileExists(ap)) { + rl.deleteFile(ap); + } + } + } + deleteOldMedia(state); + } + + private void deleteOldMedia(LibraryState state) throws IOException { + for (AlbumState as : state.getAlbums()) { + deleteOldMediaForAlbum(state, as); + } + } + + private void deleteOldMediaForAlbum(LibraryState state, AlbumState as) throws IOException { + Library rl = state.getLibrary(); + RemoteStore rs = rl.getRemoteStore(); + for (String omn : as.getOriginalMediaNames()) { + if (as.getMediaForName(omn) == null) { + String mp = getPathForMedia(as, omn); + if (rs.fileExists(mp)) { + rl.deleteFile(mp); + rs.setIgnoreManifest(true); + rs.deleteFile(getSmallThumbnailPathForMedia(as, omn)); + rs.deleteFile(getLargeThumbnailPathForMedia(as, omn)); + rs.setIgnoreManifest(false); + } + } + } + } + + private void moveMedia(LibraryState state) throws IOException { + for (AlbumState as : state.getAlbums()) { + addMessage("Resolving media movement for album '" + as.getName() + "', 1st pass."); + for (int i = 0; moveAlbumMedia(state, as); ++i) { + if (i == as.getMedia().size()) { + throw new IOException("Could not resolve media name conflicts for album '" + + as.getName() + "'"); + } + addMessage("Resolving media movement for album '" + as.getName() + "', " + + getNumberSuffixString(i + 2) + " pass."); + } + addMessage("Movement resolved for album '" + as.getName() + "'"); + } + } + + private String getNumberSuffixString(int n) { + int l = n % 10; + switch (l) { + case 1: + return n + "st"; + case 2: + return n + "nd"; + case 3: + return n + "rd"; + default: + return n + "th"; + } + } + + private boolean moveAlbumMedia(LibraryState state, AlbumState as) throws IOException { + Library rl = state.getLibrary(); + RemoteStore rs = rl.getRemoteStore(); + boolean tempFiles = false; + for (MediaState ms : as.getMedia()) { + if (ms.getNameOnDisk() == null) { + continue; + } + String nod = ms.getNameOnDisk(); + String nn = ms.getName(); + if (!nod.equals(nn)) { + if (rs.fileExists(getPathForMedia(as, nn)) && moveMediaToTempFile(state, as, nn)) { + tempFiles = true; + } + rl.moveFile(getPathForMedia(as, nod), getPathForMedia(as, nn)); + rs.setIgnoreManifest(true); + rs.moveFile(getSmallThumbnailPathForMedia(as, nod), getSmallThumbnailPathForMedia(as, nn)); + rs.moveFile(getLargeThumbnailPathForMedia(as, nod), getLargeThumbnailPathForMedia(as, nn)); + rs.setIgnoreManifest(false); + ms.setNameOnDisk(nn); + } + } + return tempFiles; + } + + private boolean moveMediaToTempFile(LibraryState state, AlbumState as, String nn) throws IOException { + MediaState ms = null; + for (MediaState tm : as.getMedia()) { + if (Objects.equals(nn, tm.getNameOnDisk())) { + ms = tm; + break; + } + } + if (ms == null) { + return false; + } + Library rl = state.getLibrary(); + String tn = getTempMediaName(rl, as, nn); + String tp = getPathForMedia(as, tn); + String stp = getSmallThumbnailPathForMedia(as, tn); + String ltp = getLargeThumbnailPathForMedia(as, tn); + rl.moveFile(getPathForMedia(as, ms.getNameOnDisk()), tp); + RemoteStore rs = rl.getRemoteStore(); + rs.setIgnoreManifest(true); + rs.moveFile(getSmallThumbnailPathForMedia(as, ms.getNameOnDisk()), stp); + rs.moveFile(getLargeThumbnailPathForMedia(as, ms.getNameOnDisk()), ltp); + rs.setIgnoreManifest(false); + ms.setNameOnDisk(tn); + return true; + } + + private String getSmallThumbnailPathForMedia(AlbumState as, String media) { + return getSmallThumbnailPathForMedia(as.getNameOnDisk(), media); + } + + private String getSmallThumbnailPathForMedia(String anod, String media) { + return getSmallThumbnailPathForAlbum(anod) + media; + } + + private String getLargeThumbnailPathForMedia(AlbumState as, String media) { + return getLargeThumbnailPathForMedia(as.getNameOnDisk(), media); + } + + private String getLargeThumbnailPathForMedia(String anod, String media) { + return getLargeThumbnailPathForAlbum(anod) + media; + } + + private String getPathForMedia(AlbumState as, String media) { + return getPathForMedia(as.getNameOnDisk(), media); + } + + private String getPathForMedia(String anod, String media) { + return getPathForAlbum(anod) + media; + } + + private void uploadMedia(LibraryState state, LibraryMediaCache mc) throws IOException { + for (AlbumState as : state.getAlbums()) { + uploadAlbumMedia(state, as, mc); + } + } + + private void uploadAlbumMedia(LibraryState state, AlbumState as, LibraryMediaCache mc) throws IOException { + Library rl = state.getLibrary(); + for (MediaState ms : as.getMedia()) { + if (ms.getNameOnDisk() == null) { + MediaData md = mc.getMedia(as.getName(), ms.getName(), null); + if (md == null) { + rl.addBinaryFile(getPathForAlbum(as.getNameOnDisk()) + ms.getName(), new byte[0]); + } else { + byte[] bytes = md.loadRawBytes(); + rl.addBinaryFile(getPathForAlbum(as.getNameOnDisk()) + ms.getName(), bytes); + if (md instanceof ImageMediaData) { + uploadImageThumbnail(state, as, mc, ms, bytes); + } else if (md instanceof VideoMediaData) { + uploadVideoThumbnail(state, as, mc, ms, (VideoMediaData) md); + } + } + incrementProgress(); + } + } + } + + private void uploadVideoThumbnail(LibraryState state, AlbumState as, + LibraryMediaCache mc, MediaState ms, VideoMediaData md) throws IOException { + SerializableImageContainer bigFrame = md.getThumbnailFrame(); + BufferedImage smallFrame; + if (as.getThumbnailName().equals(ms.getName())) { + smallFrame = UIUtils.fitImageToBox(bigFrame.getImage(), MEDIUM_THUMBNAIL_SIZE); + } else { + smallFrame = UIUtils.fitImageToBox(bigFrame.getImage(), SMALL_THUMBNAIL_SIZE); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(smallFrame, "png", out); + Library rl = state.getLibrary(); + RemoteStore rs = rl.getRemoteStore(); + rs.setIgnoreManifest(true); + rs.addBinaryFile(getSmallThumbnailPathForMedia(as, ms.getName()), out.toByteArray()); + rs.setIgnoreManifest(false); + } + + private byte[] getScaledImageData(byte[] rawBytes, Dimension d) { + try { + ByteArrayInputStream in = new ByteArrayInputStream(rawBytes); + BufferedImage origImage = ImageIO.read(in); + BufferedImage scaledImage = UIUtils.fitImageToBox(origImage, d); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(scaledImage, "png", out); + return out.toByteArray(); + } catch (IOException e) { + // Should not happen + throw new RuntimeException(e); + } + } + + private void uploadImageThumbnail(LibraryState state, AlbumState as, LibraryMediaCache mc, + MediaState ms, byte[] rawBytes) throws IOException { + Library rl = state.getLibrary(); + RemoteStore rs = rl.getRemoteStore(); + byte[] smallBytes; + if (as.getThumbnailName().equals(ms.getName())) { + smallBytes = getScaledImageData(rawBytes, MEDIUM_THUMBNAIL_SIZE); + } else { + smallBytes = getScaledImageData(rawBytes, SMALL_THUMBNAIL_SIZE); + } + byte[] largeBytes = getScaledImageData(rawBytes, LARGE_THUMBNAIL_SIZE); + rs.setIgnoreManifest(true); + rs.addBinaryFile(getSmallThumbnailPathForMedia(as, ms.getName()), smallBytes); + rs.addBinaryFile(getLargeThumbnailPathForMedia(as, ms.getName()), largeBytes); + rs.setIgnoreManifest(false); + } + + private void uploadAlbumConfigs(LibraryState state) throws IOException { + Library rl = state.getLibrary(); + for (AlbumState a : state.getAlbums()) { + rl.addAsciiFile(getPathForAlbum(a.getName()) + Library.ALBUM_CONFIG_FILE_NAME, + AlbumState.toJsonString(a)); + incrementProgress(); + } + } + + private boolean moveAlbums(LibraryState state) throws IOException { + boolean tempsExist = false; + Library rl = state.getLibrary(); + for (AlbumState as : state.getAlbums()) { + RemoteStore rs = rl.getRemoteStore(); + String nod = as.getNameOnDisk(); + String nn = as.getName(); + if (nod == null) { + as.setNameOnDisk(nn); + } else if (!nod.equals(nn)) { + if (rs.fileExists(getPathForAlbum(nn)) && moveAlbumToTempFile(state, nn)) { + tempsExist = true; + } + rl.moveFile(getPathForAlbum(nod), getPathForAlbum(nn)); + as.setNameOnDisk(nn); + } + } + return tempsExist; + } + + private boolean moveAlbumToTempFile(LibraryState state, String nod) throws IOException { + AlbumState as = null; + for (AlbumState ta : state.getAlbums()) { + if (Objects.equals(nod, ta.getNameOnDisk())) { + as = ta; + break; + } + } + if (as == null) { + // un untracked file + return false; + } + Library rl = state.getLibrary(); + String tn = getTempAlbumName(rl, as.getName()); + String tp = getPathForAlbum(tn); + rl.moveFile(getPathForAlbum(as.getNameOnDisk()), tp); + as.setNameOnDisk(tn); + return true; + } + + private String getTempMediaName(Library l, AlbumState as, String cn) throws IOException { + String tn = getTempName(l, cn + "-", ".tmp"); + RemoteStore rs = l.getRemoteStore(); + if (rs.fileExists(getPathForMedia(as, tn))) { + try { + return getTempMediaName(l, as, cn); + } catch (StackOverflowError e) { + throw new IOException("Could not generate random file name!"); + } + } + return tn; + } + + private String getTempAlbumName(Library l, String cn) throws IOException { + String tn = getTempName(l, cn + "-", ".tmp"); + RemoteStore rs = l.getRemoteStore(); + if (rs.fileExists(getPathForAlbum(tn))) { + try { + return getTempAlbumName(l, cn); + } catch (StackOverflowError e) { + throw new IOException("Could not generate random file name!"); + } + } + return tn; + } + + private String getTempName(Library l, String prefix, String suffix) { + final StringBuilder sb = new StringBuilder(); + if (prefix != null) { + sb.append(prefix); + } + sb.append(Long.toUnsignedString(RANDOM.nextLong())); + sb.append(Long.toUnsignedString(RANDOM.nextLong())); + if (suffix != null) { + sb.append(suffix); + } + return sb.toString(); + } + + private String getPathForAlbum(String name) { + return Library.ALBUMS_FILE_PATH + "/" + name + "/"; + } + + private String getSmallThumbnailPathForAlbum(String name) { + return Library.THUMBNAILS_FILE_PATH + "/small/" + name + "/"; + } + + private String getLargeThumbnailPathForAlbum(String name) { + return Library.THUMBNAILS_FILE_PATH + "/large/" + name + "/"; + } + + private void incrementProgress() { + SwingUtilities.invokeLater(() -> { + bar.setValue(++progress); + }); + } + + private int getUploadLength(LibraryState state) { + int length = 1; // library config file + ++length; // move albums + ++length; // move media + ++length; // delete files + length += state.getAlbums().size(); // album config count + for (AlbumState as : state.getAlbums()) { + for (MediaState ms : as.getMedia()) { + if (ms.getNameOnDisk() == null) { + ++length; // media that needs to be written to disk + } + } + } + return length; + } +} diff --git a/src/main/java/zander/ui/MediaCellRenderer.java b/src/main/java/zander/ui/MediaCellRenderer.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/zander/ui/OverlayPainterComponent.java b/src/main/java/zander/ui/OverlayPainterComponent.java new file mode 100644 index 0000000..ccf7b40 --- /dev/null +++ b/src/main/java/zander/ui/OverlayPainterComponent.java @@ -0,0 +1,55 @@ +package zander.ui; + +import java.awt.Graphics; + +import javax.swing.JComponent; + +import zander.library.media.SerializableImageContainer; + +public class OverlayPainterComponent extends JComponent { + private static final long serialVersionUID = -91235129812512L; + + private final SerializableImageContainer img; + private int x; + private int y; + private int w; + private int h; + + public OverlayPainterComponent(SerializableImageContainer img, int x, int y, int w, int h) { + this.img = img; + this.x = x; + this.y = y; + this.w = w; + this.h = h; + setOpaque(false); + } + + @Override + protected void paintComponent(Graphics g) { + g.drawImage(img.getImage(), x, y, w, h, null); + } + + public int getOverlayX() { + return x; + } + + public int getOverlayY() { + return y; + } + + public int getOverlayWidth() { + return w; + } + + public int getOverlayHeight() { + return h; + } + + public void setOverlayBounds(int x, int y, int w, int h) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + } + +} diff --git a/src/main/java/zander/ui/ProgressTracker.java b/src/main/java/zander/ui/ProgressTracker.java new file mode 100644 index 0000000..5a8672e --- /dev/null +++ b/src/main/java/zander/ui/ProgressTracker.java @@ -0,0 +1,84 @@ +package zander.ui; + +import java.awt.Window; +import java.util.function.Consumer; + +import javax.swing.SwingUtilities; +import javax.swing.SwingWorker; + +public class ProgressTracker { + + public final class ProgressManager { + public void setProgress(int value) { + SwingUtilities.invokeLater(() -> dialog.setProgress(value)); + } + + public int getProgress() { + return dialog.getProgress(); + } + + public boolean isCanceled() { + return dialog.isCanceled(); + } + + public void setIndeterminate(boolean indeterminate) { + SwingUtilities.invokeLater(() -> dialog.setIndeterminate(indeterminate)); + } + + public boolean isIndeterminate() { + return dialog.isIndeterminate(); + } + + public int getMaximum() { + return dialog.getMaximum(); + } + + public void setMaximum(int max) { + SwingUtilities.invokeLater(() -> { + dialog.setMaximum(max); + }); + } + + public int getMinimum() { + return dialog.getMinimum(); + } + + public void setMinimum(int min) { + SwingUtilities.invokeLater(() -> { + dialog.setMinimum(min); + }); + } + + public void setRange(int min, int max) { + SwingUtilities.invokeLater(() -> { + dialog.setRange(min, max); + }); + } + } + + private final ProgressTrackerDialog dialog; + private final SwingWorker worker; + + public ProgressTracker(Window parent, String title, String message, Consumer action) { + dialog = new ProgressTrackerDialog(parent, title, message); + worker = new SwingWorker() { + @Override + protected Void doInBackground() throws Exception { + action.accept(new ProgressManager()); + return null; + } + }; + worker.addPropertyChangeListener((e) -> { + if ("state".equals(e.getPropertyName())) { + if (SwingWorker.StateValue.DONE == e.getNewValue()) { + dialog.dispose(); + } + } + }); + } + + public void start() { + worker.execute(); + dialog.setVisible(true); + } +} diff --git a/src/main/java/zander/ui/ProgressTrackerDialog.java b/src/main/java/zander/ui/ProgressTrackerDialog.java new file mode 100644 index 0000000..df41b51 --- /dev/null +++ b/src/main/java/zander/ui/ProgressTrackerDialog.java @@ -0,0 +1,120 @@ +package zander.ui; + +import java.awt.Window; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; + +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JProgressBar; + +public class ProgressTrackerDialog extends JDialog { + private static final long serialVersionUID = 171605381923750912L; + + private final JLabel label; + private final JProgressBar bar; + private final JButton button; + private final JPanel content; + + private boolean indeterminate = false; + private boolean canceled = false; + private boolean cancelable = true; + + public ProgressTrackerDialog(Window parent, String title, String message) { + this(parent, title, message, 0, 0); + setIndeterminate(true); + } + + public ProgressTrackerDialog(Window parent, String title, String message, int min, int max) { + super(parent, title); + label = new JLabel(message == null ? "" : message); + label.setAlignmentX(JComponent.CENTER_ALIGNMENT); + bar = new JProgressBar(min, max); + bar.setAlignmentX(JComponent.CENTER_ALIGNMENT); + bar.setIndeterminate(false); + button = new JButton("Cancel"); + button.setAlignmentX(JComponent.CENTER_ALIGNMENT); + button.addActionListener((e) -> { + cancel(); + }); + content = new JPanel(); + content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS)); + content.add(label); + content.add(bar); + content.add(button); + setContentPane(content); + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + cancel(); + } + }); + setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); + setResizable(false); + setAlwaysOnTop(true); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + pack(); + UIUtils.centerWindow(this, parent); + } + + public int getProgress() { + return bar.getValue(); + } + + public void setProgress(int progress) { + bar.setValue(progress); + } + + public boolean isIndeterminate() { + return indeterminate; + } + + public void setIndeterminate(boolean indeterminate) { + this.indeterminate = indeterminate; + bar.setIndeterminate(indeterminate); + } + + public void setMaximum(int max) { + bar.setMaximum(max); + } + + public int getMaximum() { + return bar.getMaximum(); + } + + public void setMinimum(int min) { + bar.setMinimum(min); + } + + public int getMinimum() { + return bar.getMinimum(); + } + + public void setRange(int min, int max) { + setMinimum(min); + setMaximum(max); + } + + public boolean isCanceled() { + return canceled; + } + + public boolean isCancelable() { + return cancelable; + } + + public void setCancelable(boolean cancelable) { + this.cancelable = cancelable; + button.setVisible(cancelable); + } + + public void cancel() { + canceled = true; + setIndeterminate(true); + label.setText("Canceling..."); + } +} diff --git a/src/main/java/zander/ui/RememberConfirmDialog.java b/src/main/java/zander/ui/RememberConfirmDialog.java new file mode 100644 index 0000000..195f82d --- /dev/null +++ b/src/main/java/zander/ui/RememberConfirmDialog.java @@ -0,0 +1,71 @@ +package zander.ui; + +import java.awt.BorderLayout; +import java.awt.Window; +import java.awt.event.KeyEvent; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; + +public class RememberConfirmDialog extends JDialog { + private static final long serialVersionUID = 92109258152L; + + public static final int RESPONSE_NO = 0; + public static final int RESPONSE_YES = 1; + + private final JLabel messageLabel; + private final JCheckBox showCheck; + private final JButton noButton; + private final JButton yesButton; + private final JPanel buttonPanel; + private final JPanel content; + + private int response = RESPONSE_NO; + + public RememberConfirmDialog(Window parent, String title, String message) { + super(parent, title); + messageLabel = new JLabel(message); + showCheck = new JCheckBox("Remember my response"); + showCheck.setMnemonic(KeyEvent.VK_R); + showCheck.setHorizontalAlignment(SwingConstants.CENTER); + noButton = new JButton("No"); + noButton.setMnemonic(KeyEvent.VK_N); + noButton.addActionListener((e) -> dispose()); + yesButton = new JButton("Yes"); + yesButton.addActionListener((e) -> { + response = RESPONSE_YES; + dispose(); + }); + yesButton.setMnemonic(KeyEvent.VK_Y); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(noButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(yesButton); + content = new JPanel(new BorderLayout()); + content.add(messageLabel, BorderLayout.PAGE_START); + content.add(showCheck, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + setResizable(false); + pack(); + setAlwaysOnTop(true); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + UIUtils.centerWindow(this, parent); + } + + public int getResponse() { + return response; + } + + public boolean rememberAwnser() { + return showCheck.isSelected(); + } +} diff --git a/src/main/java/zander/ui/UIUtils.java b/src/main/java/zander/ui/UIUtils.java new file mode 100644 index 0000000..b0c7d52 --- /dev/null +++ b/src/main/java/zander/ui/UIUtils.java @@ -0,0 +1,185 @@ +package zander.ui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.Insets; +import java.awt.RenderingHints; +import java.awt.Toolkit; +import java.awt.Window; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URL; +import java.util.prefs.Preferences; + +import javax.imageio.ImageIO; +import javax.swing.UIManager; +import javax.swing.UIManager.LookAndFeelInfo; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.media.SerializableImageContainer; + +public class UIUtils { + + public static final Dimension SCREEN_SIZE = Toolkit.getDefaultToolkit().getScreenSize(); + public static final Preferences PREFS_ROOT = Preferences.userRoot().node("curator-store"); + public static final String SYSTEM_LAF_NAME = getSystemLafName(); + public static final Dimension THUMBNAIL_SIZE = new Dimension(SCREEN_SIZE.width / 7, SCREEN_SIZE.height / 7); + public static final Dimension IMAGE_PREVEW_SIZE = new Dimension(1280, 720); + + private static final Logger LOGGER = LoggerFactory.getLogger(UIUtils.class); + + private static String getSystemLafName() { + String cp = UIManager.getSystemLookAndFeelClassName(); + for (LookAndFeelInfo laf : UIManager.getInstalledLookAndFeels()) { + if (laf.getClassName().equals(cp)) { + return laf.getName(); + } + } + throw new Error("Could not get system LAF"); + } + + public static BufferedImage loadBufferedImage(URL resource, int w, int h) throws IOException { + try { + BufferedImage i = ImageIO.read(resource); + if (w < 0 && h < 0) { + return i; + } + int nw = w; + int nh = h; + float aspect = (float) i.getWidth() / i.getHeight(); + if (w < 0) { + nw = Math.round(h * aspect); + nh = h; + } else if (h < 0) { + nh = Math.round(w * aspect); + nw = w; + } + BufferedImage fi = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = fi.createGraphics(); + g.drawImage(i.getScaledInstance(nw, nh, BufferedImage.SCALE_SMOOTH), 0, 0, null); + g.dispose(); + return fi; + } catch (IOException e) { + LOGGER.error("Could not load image", e); + throw e; + } + } + + public static SerializableImageContainer loadSerializableImage(URL resource, int w, int h) { + try { + BufferedImage bi = loadBufferedImage(resource, w, h); + return new SerializableImageContainer(bi); + } catch (IOException e) { + LOGGER.error("Could not load image! URL: " + resource.toExternalForm(), e); + ErrorDialog ed = new ErrorDialog("Internal Error", "A fatal error has occured!", e); + ed.setVisible(true); + System.exit(0); + return null; + } + } + + public static int getLargerScreenDimension() { + return Math.max(SCREEN_SIZE.width, SCREEN_SIZE.height); + } + + public static int getSmallerScreenDimension() { + return Math.max(SCREEN_SIZE.width, SCREEN_SIZE.height); + } + + public static Font scaleFont(Font orig, float scale) { + return orig.deriveFont(orig.getSize() * scale); + } + + public static Dimension getInnerWindowSize(Window win) { + Insets i = win.getInsets(); + return new Dimension(win.getWidth() - i.left - i.right, win.getHeight() - i.top - i.bottom); + } + + public static void centerWindow(Window w) { + centerWindow(w, 0, 0, SCREEN_SIZE.width, SCREEN_SIZE.height); + } + + public static void centerWindow(Window w, Window parent) { + if (parent == null) { + centerWindow(w); + } else { + centerWindow(w, parent.getX(), parent.getY(), parent.getWidth(), parent.getHeight()); + } + } + + public static void centerWindow(Window win, int px, int py, int pw, int ph) { + Dimension ws = getInnerWindowSize(win); + win.setLocation(px + ((pw / 2) - (ws.width / 2)), py + ((ph / 2) - (ws.height / 2))); + } + + // From: https://stackoverflow.com/questions/37758061/rotate-a-buffered-image-in-java + public static BufferedImage rotateImage(BufferedImage buffImage, double angle) { + double radian = Math.toRadians(angle); + double sin = Math.abs(Math.sin(radian)); + double cos = Math.abs(Math.cos(radian)); + + int width = buffImage.getWidth(); + int height = buffImage.getHeight(); + + int nWidth = (int) Math.floor((double) width * cos + (double) height * sin); + int nHeight = (int) Math.floor((double) height * cos + (double) width * sin); + + BufferedImage rotatedImage = new BufferedImage( + nWidth, nHeight, BufferedImage.TYPE_INT_ARGB); + + Graphics2D graphics = rotatedImage.createGraphics(); + + graphics.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BICUBIC); + + graphics.translate((nWidth - width) / 2, (nHeight - height) / 2); + // rotation around the center point + graphics.rotate(radian, (double) (width / 2), (double) (height / 2)); + graphics.drawImage(buffImage, 0, 0, null); + graphics.dispose(); + + return rotatedImage; + } + + public static Color scaleColor(Color c, float sr, float sg, float sb) { + int nr = clamp(Math.round(c.getRed() * sr), 0, 255); + int ng = clamp(Math.round(c.getGreen() * sg), 0, 255); + int nb = clamp(Math.round(c.getBlue() * sb), 0, 255); + return new Color(nr, ng, nb); + } + + private static int clamp(int n, int min, int max) { + return n < min ? min : n > max ? max : n; + } + + public static BufferedImage fitImageToBox(BufferedImage source, Dimension dim) { + return fitImageToBox(source, (int) dim.getWidth(), (int) dim.getHeight()); + } + + public static BufferedImage fitImageToBox(BufferedImage source, int mw, int mh) { + int fw; + int fh; + int ow = source.getWidth(); + int oh = source.getHeight(); + if (ow <= mw && oh <= mh) { + fw = ow; + fh = oh; + } else { + float scale = Math.min((float) mw / ow, (float) mh / oh); + fw = (int) Math.floor(scale * ow); + fh = (int) Math.floor(scale * oh); + } + BufferedImage n = new BufferedImage(fw, fh, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = n.createGraphics(); + Image scaled = source.getScaledInstance(fw, fh, BufferedImage.SCALE_SMOOTH); + g.drawImage(scaled, 0, 0, null); + g.dispose(); + return n; + } +} diff --git a/src/main/java/zander/ui/_import/ImportAlbumSelectDialog.java b/src/main/java/zander/ui/_import/ImportAlbumSelectDialog.java new file mode 100644 index 0000000..3622d64 --- /dev/null +++ b/src/main/java/zander/ui/_import/ImportAlbumSelectDialog.java @@ -0,0 +1,215 @@ +package zander.ui._import; + +import java.awt.BorderLayout; +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.ArrayList; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ListSelectionModel; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import zander.library.LibraryMediaCache; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.library.state.MediaState; +import zander.ui.UIUtils; +import zander.ui.album.AlbumCreateDialog; +import zander.ui.cellrenderer.AlbumCellRenderer; +import zander.ui.docs.DocumentationViewer; + +public class ImportAlbumSelectDialog extends JDialog { + private static final long serialVersionUID = 1285137618912358123L; + + private final LibraryState library; + + private final JPopupMenu popupMenu; + private final JMenuItem newItem; + private final JMenuItem selectItem; + private final JLabel searchLabel; + private final JTextField searchField; + private final JPanel searchPanel; + private final DefaultListModel albumModel; + private final JList albumList; + private final JScrollPane albumScroll; + private final JButton cancelButton; + private final JButton backButton; + private final JButton helpButton; + private final JButton newButton; + private final JButton selectButton; + private final JPanel buttonPanel; + private final JPanel content; + + private AlbumState selectedAlbum; + private boolean canceled = false; + + public ImportAlbumSelectDialog(LibraryMediaCache cache, LibraryState library) { + super((JDialog) null, "Select Album"); + this.library = library; + popupMenu = new JPopupMenu(); + newItem = new JMenuItem("New"); + selectItem = new JMenuItem("Select"); + selectItem.setEnabled(false); + popupMenu.add(newItem); + popupMenu.addSeparator(); + popupMenu.add(selectItem); + searchLabel = new JLabel("Search:"); + searchField = new JTextField(); + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { onSearch(); } + @Override + public void removeUpdate(DocumentEvent e) { onSearch(); } + @Override + public void changedUpdate(DocumentEvent e) { onSearch(); } + }); + searchPanel = new JPanel(); + searchPanel.setLayout(new BoxLayout(searchPanel, BoxLayout.X_AXIS)); + searchPanel.add(searchLabel); + searchPanel.add(searchField); + albumModel = new DefaultListModel(); + albumModel.addAll(library.getAlbums()); + albumList = new JList(albumModel); + albumList.setDragEnabled(false); + albumList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + albumList.setLayoutOrientation(JList.HORIZONTAL_WRAP); + albumList.setVisibleRowCount(-1); + albumList.setCellRenderer(new AlbumCellRenderer(cache)); + albumList.addListSelectionListener((e) -> onHighlight()); + albumList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { onListClick(e); } + }); + albumScroll = new JScrollPane(albumList); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> System.exit(0)); + backButton = new JButton("Select Library"); + backButton.setMnemonic(KeyEvent.VK_L); + backButton.addActionListener((e) -> dispose()); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Media/Direct Import")); + newButton = new JButton("New"); + newButton.setMnemonic(KeyEvent.VK_N); + newButton.addActionListener((e) -> onNew()); + selectButton = new JButton("Select"); + selectButton.setMnemonic(KeyEvent.VK_S); + selectButton.setEnabled(false); + selectButton.addActionListener((e) -> onSelect()); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(backButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(newButton); + buttonPanel.add(selectButton); + content = new JPanel(new BorderLayout()); + content.add(searchPanel, BorderLayout.PAGE_START); + content.add(albumScroll, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + setSize((2 * UIUtils.SCREEN_SIZE.width) / 3, (2 * UIUtils.SCREEN_SIZE.height) / 3); + setMinimumSize(getSize()); + setResizable(true); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + System.exit(0); + } + }); + UIUtils.centerWindow(this); + } + + public boolean wasCanceled() { + return canceled; + } + + private void onNew() { + AlbumCreateDialog diag = new AlbumCreateDialog(null); + UIUtils.centerWindow(diag, this); + diag.setVisible(true); + if (diag.getResponse() == AlbumCreateDialog.RESPONSE_CREATE) { + String name = diag.getAlbumName(); + if (library.getAlbumForName(name) != null) { + JOptionPane.showMessageDialog(this, "An album with the name '" + name + "' already exists!", + "Name Conflict", JOptionPane.ERROR_MESSAGE); + } else { + String title = diag.getAlbumTitle(); + selectedAlbum = new AlbumState(name, title, diag.doesNameFollowTitle(), "", new ArrayList()); + dispose(); + } + } + } + + private void onSelect() { + int i = albumList.getSelectedIndex(); + if (i != -1) { + selectAlbum(i); + } + } + + private void selectAlbum(int i) { + selectedAlbum = albumModel.get(i); + dispose(); + } + + private void onHighlight() { + boolean enabled = albumList.getSelectedIndex() != -1; + selectItem.setEnabled(enabled); + selectButton.setEnabled(enabled); + } + + private void onListClick(MouseEvent e) { + if (e.getButton() == MouseEvent.BUTTON1) { + Rectangle r = albumList.getCellBounds(0, albumList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint()) && e.getClickCount() == 2) { + int i = albumList.locationToIndex(e.getPoint()); + selectAlbum(i); + } + } else if (e.getButton() == MouseEvent.BUTTON3) { + Rectangle r = albumList.getCellBounds(0, albumList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + int i = albumList.locationToIndex(e.getPoint()); + if (!albumList.isSelectedIndex(i)) { + albumList.setSelectedIndex(i); + } + } + popupMenu.show(albumList, e.getX(), e.getY()); + } + } + + private void onSearch() { + String search = searchField.getText(); + search = search.trim(); + albumModel.clear(); + for (AlbumState a : library.getAlbums()) { + if (a.getTitle().trim().startsWith(search)) { + albumModel.addElement(a); + } + } + } + + public AlbumState getSelectedAlbum() { + return selectedAlbum; + } +} diff --git a/src/main/java/zander/ui/_import/ImportLibrarySelectDialog.java b/src/main/java/zander/ui/_import/ImportLibrarySelectDialog.java new file mode 100644 index 0000000..02bc76e --- /dev/null +++ b/src/main/java/zander/ui/_import/ImportLibrarySelectDialog.java @@ -0,0 +1,143 @@ +package zander.ui._import; + +import java.awt.BorderLayout; +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; +import javax.swing.UIManager; + +import zander.ui.UIUtils; +import zander.ui.docs.DocumentationViewer; +import zander.ui.library.LibrarySelectFrame.LibraryEntry; + +public class ImportLibrarySelectDialog extends JDialog { + private static final long serialVersionUID = -281751309123958181L; + + private final JPopupMenu popupMenu; + private final JMenuItem selectItem; + private final DefaultListModel libraryModel; + private final JList libraryList; + private final JScrollPane libraryScroll; + private final JButton cancelButton; + private final JButton helpButton; + private final JButton selectButton; + private final JPanel buttonPanel; + private final JPanel content; + + private LibraryEntry selectedLibrary; + + public ImportLibrarySelectDialog(DefaultListModel entries) { + super((JDialog) null, "Select Library"); + popupMenu = new JPopupMenu(); + selectItem = new JMenuItem("Select"); + selectItem.setEnabled(false); + selectItem.addActionListener((e) -> onSelect()); + popupMenu.add(selectItem); + libraryModel = new DefaultListModel(); + for (Object oe : entries.toArray()) { + libraryModel.addElement((LibraryEntry) oe); + } + libraryList = new JList(libraryModel); + libraryList.setFont(UIUtils.scaleFont(UIManager.getFont("List.font"), 2.0f)); + libraryList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + libraryList.setDragEnabled(false); + libraryList.addListSelectionListener((e) -> onHighlight()); + libraryList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { onListClick(e); } + }); + libraryScroll = new JScrollPane(libraryList); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> onCancel()); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Media/Direct Import")); + selectButton = new JButton("Select"); + selectButton.setMnemonic(KeyEvent.VK_S); + selectButton.setEnabled(false); + selectButton.addActionListener((e) -> onSelect()); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(selectButton); + content = new JPanel(new BorderLayout()); + content.add(libraryScroll, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + pack(); + setMinimumSize(getSize()); + setSize((2 * UIUtils.SCREEN_SIZE.width) / 3, (2 * UIUtils.SCREEN_SIZE.height) / 3); + setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + System.exit(0); + } + }); + setResizable(true); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + UIUtils.centerWindow(this); + } + + private void onCancel() { + System.exit(0); + } + + private void onSelect() { + int i = libraryList.getSelectedIndex(); + if (i != -1) { + selectLibrary(i); + } + } + + public LibraryEntry getSelectedLibrary() { + return selectedLibrary; + } + + private void selectLibrary(int i) { + selectedLibrary = libraryModel.get(i); + dispose(); + } + + private void onHighlight() { + boolean enabled = libraryList.getSelectedIndex() != -1; + selectItem.setEnabled(enabled); + selectButton.setEnabled(enabled); + } + + private void onListClick(MouseEvent e) { + if (e.getButton() == MouseEvent.BUTTON1) { + Rectangle r = libraryList.getCellBounds(0, libraryList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint()) && e.getClickCount() == 2) { + int i = libraryList.locationToIndex(e.getPoint()); + selectLibrary(i); + } + } else if (e.getButton() == MouseEvent.BUTTON3) { + Rectangle r = libraryList.getCellBounds(0, libraryList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + int i = libraryList.locationToIndex(e.getPoint()); + if (!libraryList.isSelectedIndex(i)) { + libraryList.setSelectedIndex(i); + } + } + popupMenu.show(libraryList, e.getX(), e.getY()); + } + } +} diff --git a/src/main/java/zander/ui/album/AlbumCreateDialog.java b/src/main/java/zander/ui/album/AlbumCreateDialog.java new file mode 100644 index 0000000..1b0f284 --- /dev/null +++ b/src/main/java/zander/ui/album/AlbumCreateDialog.java @@ -0,0 +1,172 @@ +package zander.ui.album; + +import java.awt.BorderLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Window; +import java.awt.event.KeyEvent; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import org.apache.batik.ext.swing.GridBagConstants; + +import zander.ui.UIUtils; +import zander.ui.docs.DocumentationViewer; + +public class AlbumCreateDialog extends JDialog { + private static final long serialVersionUID = 4903014837170670083L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_CREATE = 1; + + private final JLabel titleLabel; + private final JTextField titleField; + private final JLabel nameLabel; + private final JTextField nameField; + private final JCheckBox nameCheck; + private final JPanel fieldPanel; + private final JButton cancelButton; + private final JButton helpButton; + private final JButton createButton; + private final JPanel buttonPanel; + private final JPanel content; + + private int response = RESPONSE_CANCEL; + + public AlbumCreateDialog(Window parent) { + super(parent, "Create Album"); + titleLabel = new JLabel("Album Title:"); + titleField = new JTextField(20); + titleField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + + @Override + public void removeUpdate(DocumentEvent e) { update(); } + + @Override + public void changedUpdate(DocumentEvent e) { update(); } + + private void update() { + if (!nameCheck.isSelected()) { + nameField.setText(titleField.getText()); + } + } + }); + nameLabel = new JLabel("Custom URL:"); + nameField = new JTextField(20); + nameField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + + @Override + public void removeUpdate(DocumentEvent e) { update(); } + + @Override + public void changedUpdate(DocumentEvent e) { update(); } + + private void update() { + createButton.setEnabled(!nameField.getText().isBlank()); + } + }); + nameLabel.setEnabled(false); + nameField.setEnabled(false); + nameCheck = new JCheckBox("Different Title and URL"); + nameCheck.addActionListener((e) -> { + boolean dtu = nameCheck.isSelected(); + if (!dtu) { + nameField.setText(titleField.getText()); + } + nameLabel.setEnabled(dtu); + nameField.setEnabled(dtu); + }); + fieldPanel = new JPanel(new GridBagLayout()); + buildFieldUI(); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> dispose()); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Albums/Album Settings")); + createButton = new JButton("Create"); + createButton.setMnemonic(KeyEvent.VK_R); + createButton.addActionListener((e) -> { + response = RESPONSE_CREATE; + dispose(); + }); + createButton.setEnabled(false); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(createButton); + content = new JPanel(new BorderLayout()); + content.add(fieldPanel, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + pack(); + setResizable(false); + setAlwaysOnTop(true); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + UIUtils.centerWindow(this, parent); + } + + public void buildFieldUI() { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridwidth = 1; + gbc.gridheight = 1; + gbc.insets = new Insets(1, 1, 1, 1); + gbc.fill = GridBagConstraints.NONE; + gbc.gridx = 0; + gbc.gridy = 0; + gbc.anchor = GridBagConstraints.LINE_END; + fieldPanel.add(titleLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + fieldPanel.add(titleField, gbc); + gbc.gridy = 1; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + fieldPanel.add(nameLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + fieldPanel.add(nameField, gbc); + gbc.fill = GridBagConstants.NONE; + gbc.anchor = GridBagConstraints.CENTER; + gbc.gridx = 0; + gbc.gridy = 2; + gbc.gridwidth = 2; + fieldPanel.add(nameCheck, gbc); + } + + public int getResponse() { + return response; + } + + public String getAlbumName() { + return nameField.getText(); + } + + public String getAlbumTitle() { + return titleField.getText(); + } + + public boolean doesNameFollowTitle() { + return !nameCheck.isSelected(); + } + +} diff --git a/src/main/java/zander/ui/album/AlbumPropertyPanel.java b/src/main/java/zander/ui/album/AlbumPropertyPanel.java new file mode 100644 index 0000000..cbbd212 --- /dev/null +++ b/src/main/java/zander/ui/album/AlbumPropertyPanel.java @@ -0,0 +1,501 @@ +package zander.ui.album; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import zander.library.LibraryMediaCache; +import zander.library.media.ImageMediaComponent; +import zander.library.media.MediaData; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; +import zander.ui.album.AlbumPropertyUpdateEvent.AlbumEventProperty; + +public class AlbumPropertyPanel extends JPanel { + private static final long serialVersionUID = 2338201447544771877L; + + private final LibraryMediaCache cache; + private final LibraryState library; + private final List openHistory; + + private final JLabel thumbLabel; + private final JPanel thumbPanel; + private final JLabel imageCountLabel; + private final JLabel imageCountNumber; + private final JLabel thumbNameLabel; + private final JTextField thumbNameField; + private final JButton thumbNameReset; + private final JLabel titleLabel; + private final JTextField titleField; + private final JButton titleReset; + private final JLabel nameLabel; + private final JTextField nameField; + private final JButton nameReset; + private final JCheckBox nameCheck; + + private final ImageMediaComponent missingImageIcon; + + private AlbumState currentAlbum; + private boolean disableUpdates = false; + + private final ArrayList propertyListeners = new ArrayList(); + + public AlbumPropertyPanel(LibraryMediaCache cache, List openHistory) { + super(new GridBagLayout()); + this.cache = cache; + this.openHistory = openHistory; + missingImageIcon = getMissingImageComponent(); + library = cache.getLibrary(); + thumbLabel = new JLabel("Thumbnail:"); + thumbLabel.setHorizontalAlignment(SwingConstants.CENTER); + thumbPanel = new JPanel(new BorderLayout()); + thumbPanel.setPreferredSize(new Dimension(UIUtils.SCREEN_SIZE.width / 15, UIUtils.SCREEN_SIZE.height / 15)); + thumbPanel.setMinimumSize(new Dimension(UIUtils.SCREEN_SIZE.width / 15, UIUtils.SCREEN_SIZE.height / 15)); + thumbPanel.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { selectThumbnail(); } + }); + thumbNameLabel = new JLabel("Thumbnail Name:"); + thumbNameField = new JTextField(); + thumbNameField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { doUpdate(); } + @Override + public void removeUpdate(DocumentEvent e) { doUpdate(); } + @Override + public void changedUpdate(DocumentEvent e) { doUpdate(); } + private void doUpdate() { + if (!disableUpdates) { + albumThumbnailUpdated(thumbNameField.getText()); + } + } + }); + thumbNameReset = new JButton("↺"); + thumbNameReset.addActionListener((e) -> { + thumbNameField.setText(currentAlbum.getOriginalThumbnail()); + }); + imageCountLabel = new JLabel("Images:"); + imageCountNumber = new JLabel("0"); + titleLabel = new JLabel("Album Title:"); + titleField = new JTextField(); + titleField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + @Override + public void removeUpdate(DocumentEvent e) { update(); } + @Override + public void changedUpdate(DocumentEvent e) { update(); } + + private void update() { + titleReset.setEnabled(!resetStringEquals(titleField.getText(), currentAlbum.getOriginalTitle())); + if (!nameCheck.isSelected()) { + disableUpdates = true; + nameField.setText(titleField.getText()); + disableUpdates = false; + } + } + }); + titleField.addActionListener((e) -> { + if (!disableUpdates) { + albumTitleUpdated(titleField.getText()); + } + }); + titleField.addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent e) { + if (!disableUpdates) { + albumTitleUpdated(titleField.getText()); + } + } + }); + titleReset = new JButton("↺"); + titleReset.addActionListener((e) -> { + titleField.setText(currentAlbum.getOriginalTitle()); + albumTitleUpdated(titleField.getText()); + }); + nameLabel = new JLabel("Custom URL:"); + nameField = new JTextField(); + nameField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + @Override + public void removeUpdate(DocumentEvent e) { update(); } + @Override + public void changedUpdate(DocumentEvent e) { update(); } + + private void update() { + nameReset.setEnabled(!resetStringEquals(nameField.getText(), currentAlbum.getOriginalName())); + } + }); + nameField.addActionListener((e) -> { + if (!disableUpdates) { + albumNameUpdated(nameField.getText()); + } + }); + nameField.addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent e) { + if (!disableUpdates) { + albumNameUpdated(nameField.getText()); + } + } + }); + nameReset = new JButton("↺"); + nameReset.addActionListener((e) -> { + nameField.setText(currentAlbum.getOriginalName()); + albumNameUpdated(nameField.getText()); + }); + nameCheck = new JCheckBox("Different Title and URL"); + nameCheck.addActionListener((e) -> { + if (!disableUpdates) { + boolean en = nameCheck.isSelected(); + if (!en) { + String name = titleField.getText(); + if (checkNameErrorAndShowMessage(name)) { + disableUpdates = true; + nameCheck.setSelected(true); + disableUpdates = false; + return; + } else { + String oldName = currentAlbum.getName(); + currentAlbum.setName(name); + cache.moveAlbum(oldName, name); + disableUpdates = true; + nameField.setText(name); + disableUpdates = false; + } + } + currentAlbum.setNameFollowsTitle(!en); + nameReset.setEnabled(en); + nameField.setEnabled(en); + nameLabel.setEnabled(en); + } + }); + buildUI(); + } + + private ImageMediaComponent getMissingImageComponent() { + try { + BufferedImage bi = UIUtils.loadBufferedImage(ClassLoader.getSystemResource("missing-image-icon.svg"), -1, -1); + return new ImageMediaComponent(bi); + } catch (IOException e) { + ErrorDialog ed = new ErrorDialog("Internal Error", "A fatal error has occured!", e); + ed.setVisible(true); + System.exit(0); + return null; + } + } + + private void buildUI() { + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(1, 1, 1, 1); + c.gridwidth = 1; + c.gridheight = 1; + c.gridx = 0; + c.gridy = 0; + c.weighty = 0.1; + c.weightx = 0.0; + c.anchor = GridBagConstraints.LINE_END; + c.fill = GridBagConstraints.NONE; + add(thumbLabel, c); + c.gridx = 1; + c.anchor = GridBagConstraints.LINE_START; + c.fill = GridBagConstraints.BOTH; + c.weightx = 1.0; + c.weighty = 1.0; + add(thumbPanel, c); + c.weighty = 0.1; + c.gridx = 0; + c.gridy = 1; + c.anchor = GridBagConstraints.LINE_END; + c.fill = GridBagConstraints.NONE; + c.weightx = 0.0; + add(imageCountLabel, c); + c.gridx = 1; + c.anchor = GridBagConstraints.LINE_START; + add(imageCountNumber, c); + c.gridwidth = 1; + c.gridx = 0; + c.gridy = 2; + c.anchor = GridBagConstraints.LINE_END; + add(thumbNameLabel, c); + c.gridx = 1; + c.anchor = GridBagConstraints.CENTER; + c.fill = GridBagConstraints.HORIZONTAL; + add(thumbNameField, c); + c.fill = GridBagConstraints.NONE; + c.anchor = GridBagConstraints.LINE_START; + c.gridx = 2; + add(thumbNameReset, c); + c.gridwidth = 1; + c.gridx = 0; + c.gridy = 3; + c.anchor = GridBagConstraints.LINE_END; + add(titleLabel, c); + c.gridx = 1; + c.anchor = GridBagConstraints.CENTER; + c.fill = GridBagConstraints.HORIZONTAL; + add(titleField, c); + c.fill = GridBagConstraints.NONE; + c.anchor = GridBagConstraints.LINE_START; + c.gridx = 2; + add(titleReset, c); + c.gridx = 0; + c.gridy = 4; + c.anchor = GridBagConstraints.LINE_END; + add(nameLabel, c); + c.gridx = 1; + c.anchor = GridBagConstraints.CENTER; + c.fill = GridBagConstraints.HORIZONTAL; + add(nameField, c); + c.anchor = GridBagConstraints.LINE_START; + c.fill = GridBagConstraints.NONE; + c.gridx = 2; + add(nameReset, c); + c.gridx = 0; + c.gridy = 5; + c.gridwidth = 3; + c.anchor = GridBagConstraints.CENTER; + add(nameCheck, c); + } + + public void setAlbum(AlbumState album) { + setAlbum(album, false); + } + + public void setAlbum(AlbumState album, boolean clearOld) { + disableUpdates = true; + if (currentAlbum != null && !clearOld) { + if (!titleField.getText().equals(currentAlbum.getTitle())) { + currentAlbum.setTitle(titleField.getText()); + } + if (!nameField.getText().equals(currentAlbum.getName())) { + String name = nameField.getText(); + if (!checkNameErrorAndShowMessage(name)) { + String oldName = currentAlbum.getName(); + currentAlbum.setName(name); + cache.moveAlbum(oldName, name); + } else { + currentAlbum.setTitle(currentAlbum.getName()); + } + } + } + currentAlbum = album; + refreshThumbnail(); + thumbNameField.setText(album.getThumbnailName()); + titleField.setText(album.getTitle()); + nameField.setText(album.getName()); + boolean nft = album.doesNameFollowTitle(); + nameCheck.setSelected(!nft); + nameField.setEnabled(!nft); + nameLabel.setEnabled(!nft); + nameReset.setEnabled(!nft); + imageCountNumber.setText(Integer.toString(album.getMedia().size())); + updateResetButtons(); + disableUpdates = false; + } + + public AlbumState getAlbum() { + return currentAlbum; + } + + public String getThumbnailName() { + return thumbNameField.getText(); + } + + public void setThumbnailName(String name) { + thumbNameField.setText(name); + } + + public String getAlbumName() { + return nameField.getText(); + } + + public void setAlbumName(String name) { + nameField.setText(name); + } + + public String getAlbumTitle() { + return titleField.getText(); + } + + public void setAlbumTitle(String title) { + titleField.setText(title); + } + + public LibraryMediaCache getMediaCache() { + return cache; + } + + public LibraryState getLibrary() { + return library; + } + + public void addAlbumPropertyUpdateListener(AlbumPropertyUpdateListener l) { + propertyListeners.add(l); + } + + public void removeAlbumPropertyUpdateListener(AlbumPropertyUpdateListener l) { + propertyListeners.remove(l); + } + + private void fireUpdatePropertyEvent(String oldVal, String newVal, AlbumEventProperty prop) { + AlbumPropertyUpdateEvent e = new AlbumPropertyUpdateEvent(currentAlbum, oldVal, newVal, prop); + for (AlbumPropertyUpdateListener l : propertyListeners) { + l.propertyUpdated(e); + } + } + + private void selectThumbnail() { + AlbumThumbnailSelectDialog diag = + new AlbumThumbnailSelectDialog(SwingUtilities.getWindowAncestor(this), cache, currentAlbum, openHistory); + diag.setVisible(true); + if (diag.getResponse() == AlbumThumbnailSelectDialog.RESPONSE_SELECT && diag.getSelectedThumbnail() != null) { + thumbNameField.setText(diag.getSelectedThumbnail()); + } + } + + private void albumThumbnailUpdated(String thumbnail) { + String oldName = currentAlbum.getThumbnailName(); + currentAlbum.setThumbnailName(thumbnail); + refreshThumbnail(); + thumbNameReset.setEnabled(!resetStringEquals(thumbnail, currentAlbum.getOriginalThumbnail())); + fireUpdatePropertyEvent(oldName, thumbnail, AlbumEventProperty.THUMBNAIL); + } + + private void albumTitleUpdated(String title) { + if (!nameCheck.isSelected()) { + if (!title.equals(currentAlbum.getName()) && library.hasAlbum(title)) { + disableUpdates = true; + titleField.setText(currentAlbum.getName()); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "An album with the title \"" + title + "\" is invalid becuase it " + + "result in a URL that is the same as one that exists!", + "Name Conflict", JOptionPane.ERROR_MESSAGE); + return; + } else if (title.isBlank()) { + disableUpdates = true; + titleField.setText(currentAlbum.getName()); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "The title \"" + title + "\" is invalid becaue it would result in an " + + " invalid URL!", + "Invalid Name", JOptionPane.ERROR_MESSAGE); + return; + } else if (title.contains("/")) { + disableUpdates = true; + titleField.setText(currentAlbum.getName()); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "The title \"" + title + + "\" is invalid because it contains the character '/' which is not " + + "allowed in URLs!", + "Invalid Name", JOptionPane.ERROR_MESSAGE); + return; + } + String oldName = currentAlbum.getName(); + currentAlbum.setName(title); + cache.moveAlbum(oldName, title); + } + String oldTitle = currentAlbum.getTitle(); + currentAlbum.setTitle(title); + titleReset.setEnabled(!resetStringEquals(title, currentAlbum.getOriginalTitle())); + fireUpdatePropertyEvent(oldTitle, title, AlbumEventProperty.TITLE); + } + + /** @return true if an error occured, false otherwise */ + private boolean checkNameErrorAndShowMessage(String name) { + if (name.equals(currentAlbum.getName())) { + return false; + } else if (library.hasAlbum(name)) { + disableUpdates = true; + nameField.setText(currentAlbum.getName()); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "An album with the URL \"" + name + "\" already exists!", + "Name Conflict", JOptionPane.ERROR_MESSAGE); + return true; + } else if (name.isBlank()) { + disableUpdates = true; + nameField.setText(currentAlbum.getName()); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "The URL \"" + name + "\" is invalid!", + "Invalid Name", JOptionPane.ERROR_MESSAGE); + return true; + } else if (name.contains("/")) { + disableUpdates = true; + nameField.setText(currentAlbum.getName()); + disableUpdates = false; + JOptionPane.showMessageDialog(this, + "The URL \"" + name + "\" is invalid because it contains the character '/'!", "Invalid Name", + JOptionPane.ERROR_MESSAGE); + return true; + } + return false; + } + + private void albumNameUpdated(String name) { + if (!currentAlbum.getName().equals(name)) { + if (!checkNameErrorAndShowMessage(name)) { + String oldName = currentAlbum.getName(); + currentAlbum.setName(name); + cache.moveAlbum(oldName, name); + } + } + nameReset.setEnabled(!resetStringEquals(nameField.getText(), currentAlbum.getOriginalName())); + } + + private void refreshThumbnail() { + MediaData md = cache.getCheckedMedia(currentAlbum.getName(), currentAlbum.getThumbnailName(), null); + thumbPanel.removeAll(); + if (md != null) { + Component thumbComp = md.createThumbnailComponent(); + thumbPanel.add(thumbComp, BorderLayout.CENTER); + } else { + thumbPanel.add(missingImageIcon, BorderLayout.CENTER); + } + } + + private void updateResetButtons() { + thumbNameReset.setEnabled(!resetStringEquals(thumbNameField.getText(), currentAlbum.getOriginalThumbnail())); + nameReset.setEnabled(!resetStringEquals(nameField.getText(), currentAlbum.getOriginalName()) && + nameCheck.isSelected()); + titleReset.setEnabled(!resetStringEquals(titleField.getText(), currentAlbum.getOriginalTitle())); + } + + private static boolean resetStringEquals(String s1, String s2) { + if (s1 != null && s1.isEmpty()) { + s1 = null; + } + if (s2 != null && s2.isEmpty()) { + s2 = null; + } + return Objects.equals(s1, s2); + } + + public void updateName() { + albumNameUpdated(nameField.getText()); + } +} diff --git a/src/main/java/zander/ui/album/AlbumPropertyUpdateEvent.java b/src/main/java/zander/ui/album/AlbumPropertyUpdateEvent.java new file mode 100644 index 0000000..c20e56f --- /dev/null +++ b/src/main/java/zander/ui/album/AlbumPropertyUpdateEvent.java @@ -0,0 +1,45 @@ +package zander.ui.album; + +import java.util.EventObject; + +import zander.library.state.AlbumState; + +public class AlbumPropertyUpdateEvent extends EventObject { + private static final long serialVersionUID = 1836669249042617105L; + + public static enum AlbumEventProperty { + NAME, + TITLE, + THUMBNAIL + } + + private final AlbumState source; + private final String oldValue; + private final String newValue; + private final AlbumEventProperty property; + + public AlbumPropertyUpdateEvent(AlbumState source, String oldValue, String newValue, AlbumEventProperty property) { + super(source); + this.source = source; + this.oldValue = oldValue; + this.newValue = newValue; + this.property = property; + } + + public AlbumEventProperty getProperty() { + return property; + } + + public String getNewValue() { + return newValue; + } + + public String getOldValue() { + return oldValue; + } + + @Override + public AlbumState getSource() { + return source; + } +} diff --git a/src/main/java/zander/ui/album/AlbumPropertyUpdateListener.java b/src/main/java/zander/ui/album/AlbumPropertyUpdateListener.java new file mode 100644 index 0000000..9f92471 --- /dev/null +++ b/src/main/java/zander/ui/album/AlbumPropertyUpdateListener.java @@ -0,0 +1,9 @@ +package zander.ui.album; + +import java.util.EventListener; + +public interface AlbumPropertyUpdateListener extends EventListener { + + public void propertyUpdated(AlbumPropertyUpdateEvent e); + +} diff --git a/src/main/java/zander/ui/album/AlbumSelectPanel.java b/src/main/java/zander/ui/album/AlbumSelectPanel.java new file mode 100644 index 0000000..83042ad --- /dev/null +++ b/src/main/java/zander/ui/album/AlbumSelectPanel.java @@ -0,0 +1,420 @@ +package zander.ui.album; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Rectangle; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.BoxLayout; +import javax.swing.DefaultListModel; +import javax.swing.DropMode; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.TransferHandler; +import javax.swing.UIManager; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import zander.library.LibraryMediaCache; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.library.state.MediaState; +import zander.ui.LibraryFrame; +import zander.ui.LibraryUploadDialog; +import zander.ui.ProgressTracker; +import zander.ui.UIUtils; +import zander.ui.cellrenderer.AlbumCellRenderer; +import zander.ui.docs.DocumentationViewer; +import zander.ui.media.MediaManagePanel; +import zander.ui.transferhandlers.ListArrangeTransferHandler; + +public class AlbumSelectPanel extends JPanel { + private static final long serialVersionUID = -3560698858822813839L; + + public static final int ALBUMS_TO_HOLD_IN_MEMORY = 5; + + private final LibraryFrame parent; + private final LibraryState library; + private final LibraryMediaCache cache; + private final JPopupMenu popupMenu; + private final JMenuItem openItem; + private final JMenuItem deleteItem; + private final JMenuItem newItem; + private final JMenuItem editItem; + private final JButton backButton; + private final JButton libraryPropButton; + private final JLabel searchLabel; + private final JTextField searchField; + private final JButton uploadButton; + private final JButton helpButton; + private final JButton newButton; + private final JButton deleteButton; + private final JButton openButton; + private final JButton editButton; + private final JPanel buttonPanel; + private final JLabel screenLabel; + private final JPanel topPanel; + private final DefaultListModel albumModel; + private final TransferHandler albumTransferHandler; + private final JList albumList; + private final JScrollPane albumScroll; + private final AlbumPropertyPanel propsPanel; + private final JLabel manyPropsLabel; + private final JLabel noPropsLabel; + private final JSplitPane splitPane; + + private int lastClicked = -1; + private Component currentComp; + private boolean firstToggle = true; + private boolean canceled = false; + private final List openHistory; + + public AlbumSelectPanel(LibraryFrame parent, List openHistory, String localName) { + super(new BorderLayout()); + this.parent = parent; + this.openHistory = openHistory; + this.library = parent.getLibraryState(); + cache = parent.getMediaCache(); + popupMenu = new JPopupMenu(); + openItem = new JMenuItem("Open"); + openItem.setEnabled(false); + openItem.addActionListener(this::openAlbumHandler); + deleteItem = new JMenuItem("Delete"); + deleteItem.setEnabled(false); + deleteItem.addActionListener(this::deleteAlbumHandler); + newItem = new JMenuItem("New"); + newItem.addActionListener((e) -> newAlbum()); + editItem = new JMenuItem("Toggle Property Panel"); + editItem.addActionListener(this::toggleEditPanelHandler); + popupMenu.add(openItem); + popupMenu.add(deleteItem); + popupMenu.addSeparator(); + popupMenu.add(newItem); + popupMenu.add(editItem); + backButton = new JButton("Back to Libraries"); + backButton.setMnemonic(KeyEvent.VK_B); + backButton.addActionListener((e) -> parent.backButtonHandler()); + libraryPropButton = new JButton("Library Properties"); + libraryPropButton.setMnemonic(KeyEvent.VK_L); + libraryPropButton.addActionListener((e) -> { + LibraryPropertyDialog diag = new LibraryPropertyDialog(SwingUtilities.getWindowAncestor(this), library); + diag.setVisible(true); + if (diag.getResponse() == LibraryPropertyDialog.RESPONSE_SAVE) { + library.setName(diag.getLibraryName()); + library.setLicense(diag.getLibraryLicense()); + } + }); + searchLabel = new JLabel("Search:"); + searchField = new JTextField(10); + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { update(); } + + @Override + public void removeUpdate(DocumentEvent e) { update(); } + + @Override + public void changedUpdate(DocumentEvent e) { update(); } + + public void update() { + doSearch(searchField.getText()); + } + }); + uploadButton = new JButton("Upload"); + uploadButton.setMnemonic(KeyEvent.VK_U); + uploadButton.addActionListener((e) -> { + LibraryUploadDialog diag = new LibraryUploadDialog(SwingUtilities.getWindowAncestor(this)); + if (diag.upload(library, cache)) { + parent.handleUploadDone(this); + } + }); + helpButton = new JButton("?"); + helpButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (currentComp == splitPane) { + DocumentationViewer.show(AlbumSelectPanel.this, "Albums/Album Settings"); + } else { + DocumentationViewer.show(AlbumSelectPanel.this, "Albums/Managing Albums"); + } + } + }); + newButton = new JButton("New"); + newButton.setMnemonic(KeyEvent.VK_N); + newButton.addActionListener((e) -> newAlbum()); + deleteButton = new JButton("Delete"); + deleteButton.setMnemonic(KeyEvent.VK_D); + deleteButton.setEnabled(false); + deleteButton.addActionListener(this::deleteAlbumHandler); + openButton = new JButton("Open"); + openButton.setMnemonic(KeyEvent.VK_O); + openButton.setEnabled(false); + openButton.addActionListener(this::openAlbumHandler); + editButton = new JButton("Toggle Property Panel"); + editButton.setMnemonic(KeyEvent.VK_T); + editButton.addActionListener(this::toggleEditPanelHandler); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(backButton); + buttonPanel.add(libraryPropButton); + buttonPanel.add(searchLabel); + buttonPanel.add(searchField); + buttonPanel.add(uploadButton); + buttonPanel.add(helpButton); + buttonPanel.add(newButton); + buttonPanel.add(deleteButton); + buttonPanel.add(openButton); + buttonPanel.add(editButton); + screenLabel = new JLabel(localName, SwingConstants.CENTER); + screenLabel.setFont(UIUtils.scaleFont(UIManager.getFont("Label.font"), 2.0f)); + topPanel = new JPanel(new BorderLayout()); + topPanel.add(buttonPanel, BorderLayout.CENTER); + topPanel.add(screenLabel, BorderLayout.PAGE_END); + albumModel = new DefaultListModel(); + albumTransferHandler = new ListArrangeTransferHandler(new Runnable() { + @Override + public void run() { + propsPanel.updateName(); + }; + }, () -> { + library.getAlbums().clear(); + for (int i = 0; i < albumModel.size(); ++i) { + library.getAlbums().add(albumModel.get(i)); + } + }); + albumList = new JList(albumModel); + albumList.setDropMode(DropMode.INSERT); + albumList.setDragEnabled(true); + albumList.setLayoutOrientation(JList.HORIZONTAL_WRAP); + albumList.setVisibleRowCount(-1); + albumList.setCellRenderer(new AlbumCellRenderer(cache)); + albumList.setTransferHandler(albumTransferHandler); + albumList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() == MouseEvent.BUTTON1) { + Rectangle r = albumList.getCellBounds(0, albumList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + if (e.getClickCount() == 1) { + int i = albumList.locationToIndex(e.getPoint()); + lastClicked = i; + } else if (e.getClickCount() == 2) { + int i = albumList.locationToIndex(e.getPoint()); + openAlbum(i); + } + } + } else if (e.getButton() == MouseEvent.BUTTON3) { + Rectangle r = albumList.getCellBounds(0, albumList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + int i = albumList.locationToIndex(e.getPoint()); + if ((e.getModifiersEx() & InputEvent.CTRL_DOWN_MASK) == InputEvent.CTRL_DOWN_MASK) { + albumList.addSelectionInterval(i, i); + } else if (lastClicked != -1 + && (e.getModifiersEx() & InputEvent.SHIFT_DOWN_MASK) == InputEvent.SHIFT_DOWN_MASK) { + albumList.setSelectionInterval(lastClicked, i); + } else if (!albumList.isSelectedIndex(i)) { + albumList.setSelectedIndex(i); + } + } + int numSel = albumList.getSelectedIndices().length; + openItem.setEnabled(numSel == 1); + deleteItem.setEnabled(numSel > 0); + popupMenu.show(albumList, e.getX(), e.getY()); + } + } + }); + albumScroll = new JScrollPane(albumList); + propsPanel = new AlbumPropertyPanel(cache, openHistory); + propsPanel.addAlbumPropertyUpdateListener((e) -> { + revalidate(); + repaint(); + }); + manyPropsLabel = new JLabel("Multiple items selected.", JLabel.CENTER); + noPropsLabel = new JLabel("No items selected.", JLabel.CENTER); + splitPane = new JSplitPane(); + splitPane.setRightComponent(noPropsLabel); + splitPane.setOneTouchExpandable(false); + add(topPanel, BorderLayout.PAGE_START); + add(albumScroll, BorderLayout.CENTER); + currentComp = albumScroll; + albumList.addListSelectionListener((e) -> refreshAlbums()); + loadMedia(); + } + + private void loadMedia() { + ProgressTracker pt = new ProgressTracker(null, "Loading Thumbnails", "Loading thumbnails...", (t) -> { + t.setMinimum(0); + t.setMaximum(library.getAlbums().size()); + t.setIndeterminate(false); + for (AlbumState album : library.getAlbums()) { + cache.getMedia(album.getName(), album.getThumbnailName(), t); + albumModel.addElement(album); + t.setProgress(t.getProgress() + 1); + if (t.isCanceled()) { + canceled = true; + break; + } + } + }); + pt.start(); + } + + public boolean wasCancled() { + return canceled; + } + + private void toggleEditPanelHandler(ActionEvent e) { + if (firstToggle) { + splitPane.setDividerLocation((int) (getWidth() / 2)); + firstToggle = false; + } + if (currentComp == albumScroll) { + int div = splitPane.getDividerLocation(); + remove(albumScroll); + add(splitPane, BorderLayout.CENTER); + currentComp = splitPane; + splitPane.setLeftComponent(albumScroll); + splitPane.setDividerLocation(div); + } else { + remove(splitPane); + add(albumScroll, BorderLayout.CENTER); + currentComp = albumScroll; + } + revalidate(); + } + + private void refreshAlbums() { + int numSel = albumList.getSelectedIndices().length; + if (numSel == 0) { + int div = splitPane.getDividerLocation(); + splitPane.setRightComponent(noPropsLabel); + splitPane.setDividerLocation(div); + } else if (numSel == 1) { + if (splitPane.getRightComponent() != propsPanel) { + int div = splitPane.getDividerLocation(); + splitPane.setRightComponent(propsPanel); + splitPane.setDividerLocation(div); + } + propsPanel.setAlbum(albumList.getSelectedValue()); + } else if (splitPane.getRightComponent() != manyPropsLabel) { + int div = splitPane.getDividerLocation(); + splitPane.setRightComponent(manyPropsLabel); + splitPane.setDividerLocation(div); + } + revalidate(); + openButton.setEnabled(numSel == 1); + deleteButton.setEnabled(numSel > 0); + } + + public void rereadAlbum() { + AlbumState as = albumList.getSelectedValue(); + if (as != null) { + propsPanel.setAlbum(albumList.getSelectedValue(), true); + repaint(); + revalidate(); + } + } + + public void reloadAlbumsFromLibrary() { + doSearch(searchField.getText()); + } + + private void openAlbumHandler(ActionEvent e) { + int i = albumList.getSelectedIndex(); + if (i != -1) { + openAlbum(i); + } + } + + private void openAlbum(int i) { + albumList.setSelectedIndex(i); + AlbumState a = albumModel.get(i); + MediaManagePanel p = new MediaManagePanel(parent, a); + openHistory.remove(a); + openHistory.add(a); + while (openHistory.size() > ALBUMS_TO_HOLD_IN_MEMORY) { + AlbumState toSwap = openHistory.remove(0); + cache.swapOutAlbum(toSwap.getName(), false); + } + if (!p.wasCanceled()) { + parent.setCurrentPanel(p); + } + } + + private void deleteAlbumHandler(ActionEvent e) { + List as = albumList.getSelectedValuesList(); + int response; + if (as.size() == 1) { + response = JOptionPane.showConfirmDialog(this, "Are you sure you want to delete the album '" + as.get(0).getName() + "'?", + "Confirm Delete", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + } else { + response = JOptionPane.showConfirmDialog(this, "Are you sure you want to delete " + as.size() + " albums?", + "Confirm Delete", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + } + if (response == JOptionPane.YES_OPTION) { + for (AlbumState a : as) { + deleteAlbum(a); + } + } + } + + private void deleteAlbum(AlbumState a) { + albumModel.removeElement(a); + library.removeAlbumForName(a.getName()); + cache.removeAlbum(a.getName()); + openHistory.remove(a); + } + + private void newAlbum() { + AlbumCreateDialog diag = new AlbumCreateDialog(SwingUtilities.getWindowAncestor(this)); + diag.setVisible(true); + if (diag.getResponse() == AlbumCreateDialog.RESPONSE_CREATE) { + String name = diag.getAlbumName(); + if (library.getAlbumForName(name) != null) { + JOptionPane.showMessageDialog(this, "An album with the name '" + name + "' already exists!", + "Name Conflict", JOptionPane.ERROR_MESSAGE); + } else { + AlbumState na = new AlbumState(name, diag.getAlbumTitle(), diag.doesNameFollowTitle(), + "", new ArrayList()); + na.setNameOnDisk(null); + library.getAlbums().add(na); + albumModel.addElement(na); + openAlbum(albumModel.getSize() - 1); + } + } + } + + private void doSearch(String search) { + if (!search.isBlank()) { + albumList.setDragEnabled(false); + albumList.setTransferHandler(null); + } else { + albumList.setDragEnabled(true); + albumList.setTransferHandler(albumTransferHandler); + } + search = search.trim(); + albumModel.clear(); + for (AlbumState a : library.getAlbums()) { + if (a.getTitle().trim().startsWith(search)) { + albumModel.addElement(a); + } + } + } +} diff --git a/src/main/java/zander/ui/album/AlbumThumbnailSelectDialog.java b/src/main/java/zander/ui/album/AlbumThumbnailSelectDialog.java new file mode 100644 index 0000000..92f5c40 --- /dev/null +++ b/src/main/java/zander/ui/album/AlbumThumbnailSelectDialog.java @@ -0,0 +1,175 @@ +package zander.ui.album; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.Rectangle; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; + +import zander.library.LibraryMediaCache; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.library.state.MediaState; +import zander.ui.ProgressTracker; +import zander.ui.UIUtils; +import zander.ui.cellrenderer.MediaCellRenderer; + +public class AlbumThumbnailSelectDialog extends JDialog { + private static final long serialVersionUID = -9035406003694248528L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_SELECT = 1; + + private int response = RESPONSE_CANCEL; + + private final LibraryMediaCache cache; + private final LibraryState library; + private final AlbumState album; + private final List openHistory; + + private final DefaultListModel listModel; + private final JList thumbList; + private final JScrollPane scroll; + private final JButton cancelButton; + private final JButton selectButton; + private final JPanel buttonPanel; + private final JPanel content; + + private boolean valid = true; + + public AlbumThumbnailSelectDialog(Window parent, LibraryMediaCache cache, AlbumState album, List openHistory) { + super(parent, "Select Thumbnail"); + this.cache = cache; + this.library = cache.getLibrary(); + this.album = album; + this.openHistory = openHistory; + listModel = new DefaultListModel(); + thumbList = new JList(listModel); + thumbList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + thumbList.setLayoutOrientation(JList.HORIZONTAL_WRAP); + thumbList.setVisibleRowCount(-1); + thumbList.setCellRenderer(new MediaCellRenderer(cache, album)); + thumbList.setSelectedValue(album.getThumbnailName(), true); + thumbList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() == MouseEvent.BUTTON1) { + Rectangle r = thumbList.getCellBounds(0, thumbList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + if (e.getClickCount() == 2) { + int i = thumbList.locationToIndex(e.getPoint()); + thumbList.setSelectedIndex(i); + response = RESPONSE_SELECT; + dispose(); + } + } + } + } + }); + scroll = new JScrollPane(thumbList); + cancelButton = new JButton("Canel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> { + dispose(); + }); + selectButton = new JButton("Select"); + selectButton.setMnemonic(KeyEvent.VK_S); + selectButton.addActionListener((e) -> { + response = RESPONSE_SELECT; + dispose(); + }); + thumbList.addListSelectionListener((e) -> { + if (thumbList.getSelectedIndex() == -1) { + selectButton.setEnabled(false); + } else { + selectButton.setEnabled(true); + } + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(selectButton); + content = new JPanel(new BorderLayout()); + content.add(scroll, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + if (parent != null) { + setSize((2 * parent.getWidth()) / 3, (2 * parent.getHeight()) / 3); + } else { + pack(); + } + setMinimumSize(new Dimension(content.getPreferredSize().width, 200)); + UIUtils.centerWindow(this, parent); + setResizable(true); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + setAlwaysOnTop(true); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + loadMedia(); + } + + private void loadMedia() { + ProgressTracker pt = new ProgressTracker(null, "Loading Media", "Loading media...", (t) -> { + t.setIndeterminate(false); + t.setMinimum(0); + t.setMaximum(album.getMedia().size()); + for (MediaState media : album.getMedia()) { + cache.getMedia(album.getName(), media.getName(), t); + t.setProgress(t.getProgress() + 1); + listModel.addElement(media); + if (t.isCanceled()) { + valid = false; + break; + } + } + openHistory.remove(album); + openHistory.add(album); + while (openHistory.size() > AlbumSelectPanel.ALBUMS_TO_HOLD_IN_MEMORY) { + AlbumState toSwap = openHistory.remove(0); + cache.swapOutAlbum(toSwap.getName(), false); + } + }); + pt.start(); + } + + @Override + public void setVisible(boolean visible) { + if (valid) { + super.setVisible(visible); + } + } + + public LibraryMediaCache getMediaCahce() { + return cache; + } + + public LibraryState getLibrary() { + return library; + } + + public AlbumState getAlbum() { + return album; + } + + public String getSelectedThumbnail() { + return thumbList.getSelectedValue().getName(); + } + + public int getResponse () { + return response; + } + +} diff --git a/src/main/java/zander/ui/album/LibraryPropertyDialog.java b/src/main/java/zander/ui/album/LibraryPropertyDialog.java new file mode 100644 index 0000000..f13a5ea --- /dev/null +++ b/src/main/java/zander/ui/album/LibraryPropertyDialog.java @@ -0,0 +1,171 @@ +package zander.ui.album; + +import java.awt.Insets; +import java.awt.BorderLayout; +import java.awt.GridBagLayout; +import java.awt.GridBagConstraints; +import java.awt.event.KeyEvent; +import java.util.Objects; +import java.awt.Window; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import zander.library.state.LibraryState; +import zander.ui.UIUtils; +import zander.ui.docs.DocumentationViewer; + +public class LibraryPropertyDialog extends JDialog { + private static final long serialVersionUID = -4761044964061547045L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_SAVE = 1; + + private final LibraryState library; + + private final JLabel nameLabel; + private final JTextField nameField; + private final JButton nameReset; + private final JLabel licenseLabel; + private final JTextField licenseField; + private final JButton licenseReset; + private final JPanel fieldPanel; + private final JButton cancelButton; + private final JButton helpButton; + private final JButton saveButton; + private final JPanel buttonPanel; + private final JPanel content; + + private int response = RESPONSE_CANCEL; + + public LibraryPropertyDialog(Window parent, LibraryState library) { + super(parent, "Library Properties"); + this.library = library; + nameLabel = new JLabel("Name:"); + nameField = new JTextField(20); + nameField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + @Override + public void removeUpdate(DocumentEvent e) { update(); } + @Override + public void changedUpdate(DocumentEvent e) { update(); } + private void update() { + nameReset.setEnabled(!resetStringEquals(nameField.getText(), library.getOriginalName())); + } + }); + nameReset = new JButton("↺"); + nameField.setText(library.getName()); + nameReset.addActionListener((e) -> nameField.setText(library.getOriginalName())); + licenseLabel = new JLabel("License:"); + licenseField = new JTextField(20); + licenseField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + @Override + public void removeUpdate(DocumentEvent e) { update(); } + @Override + public void changedUpdate(DocumentEvent e) { update(); } + private void update() { + licenseReset.setEnabled(!resetStringEquals(licenseField.getText(), library.getOriginalLicense())); + } + }); + licenseReset = new JButton("↺"); + licenseField.setText(library.getLicense()); + licenseReset.addActionListener((e) -> licenseField.setText(library.getOriginalLicense())); + fieldPanel = new JPanel(new GridBagLayout()); + buildFieldUI(); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> dispose()); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Albums/Library Settings")); + saveButton = new JButton("Save"); + saveButton.setMnemonic(KeyEvent.VK_S); + saveButton.addActionListener((e) -> { + response = RESPONSE_SAVE; + dispose(); + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(saveButton); + content = new JPanel(new BorderLayout()); + content.add(fieldPanel, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + pack(); + setAlwaysOnTop(true); + setResizable(false); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + UIUtils.centerWindow(this, parent); + } + + private void buildFieldUI() { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridwidth = 1; + gbc.gridheight = 1; + gbc.insets = new Insets(1, 1, 1, 1); + gbc.fill = GridBagConstraints.NONE; + gbc.gridx = 0; + gbc.gridy = 0; + gbc.anchor = GridBagConstraints.LINE_END; + fieldPanel.add(nameLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.CENTER; + fieldPanel.add(nameField, gbc); + gbc.gridx = 2; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_START; + fieldPanel.add(nameReset, gbc); + gbc.gridy = 1; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + fieldPanel.add(licenseLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.CENTER; + fieldPanel.add(licenseField, gbc); + gbc.gridx = 2; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_START; + fieldPanel.add(licenseReset, gbc); + } + + public LibraryState getLibrary() { + return library; + } + + public int getResponse() { + return response; + } + + public String getLibraryName() { + return nameField.getText(); + } + + public String getLibraryLicense() { + return licenseField.getText(); + } + + private static boolean resetStringEquals(String s1, String s2) { + if (s1 != null && s1.isEmpty()) { + s1 = null; + } + if (s2 != null && s2.isEmpty()) { + s2 = null; + } + return Objects.equals(s1, s2); + } +} diff --git a/src/main/java/zander/ui/cellrenderer/AlbumCellRenderer.java b/src/main/java/zander/ui/cellrenderer/AlbumCellRenderer.java new file mode 100644 index 0000000..8398131 --- /dev/null +++ b/src/main/java/zander/ui/cellrenderer/AlbumCellRenderer.java @@ -0,0 +1,131 @@ +package zander.ui.cellrenderer; + +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.image.BufferedImage; +import java.io.IOException; + +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JTextPane; +import javax.swing.ListCellRenderer; +import javax.swing.text.SimpleAttributeSet; +import javax.swing.text.StyleConstants; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.LibraryMediaCache; +import zander.library.media.ImageMediaComponent; +import zander.library.media.MediaData; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; + +public class AlbumCellRenderer extends JPanel implements ListCellRenderer { + private static final long serialVersionUID = 4031761489389255796L; + private static final Logger LOGGER = LoggerFactory.getLogger(AlbumCellRenderer.class); + + private final LibraryMediaCache cache; + private final LibraryState library; + private final ImageMediaComponent missingImageIcon; + private final JTextPane name; + private final SimpleAttributeSet attribs; + + public AlbumCellRenderer(LibraryMediaCache cache) { + setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + this.cache = cache; + this.library = cache.getLibrary(); + missingImageIcon = getMissingImageComponent(); + name = new JTextPane(); + name.setOpaque(false); + attribs = new SimpleAttributeSet(); + StyleConstants.setAlignment(attribs, StyleConstants.ALIGN_CENTER); + } + + private ImageMediaComponent getMissingImageComponent() { + try { + BufferedImage bi = UIUtils.loadBufferedImage(ClassLoader.getSystemResource("missing-image-icon.svg"), -1, -1); + ImageMediaComponent c = new ImageMediaComponent(bi); + c.setPreferredSize(UIUtils.THUMBNAIL_SIZE); + return c; + } catch (IOException e) { + LOGGER.error("Could not load missing image icon!", e); + ErrorDialog ed = new ErrorDialog("Internal Error", "A fatal error has occured!", e); + ed.setVisible(true); + System.exit(0); + return null; + } + } + + public LibraryState getLibrary() { + return library; + } + + @Override + public void setFont(Font font) { + super.setFont(font); + if (name != null) { + name.setFont(font); + } + } + + @Override + public void setForeground(Color fg) { + super.setForeground(fg); + if (name != null) { + name.setForeground(fg); + } + } + + @Override + public void setBackground(Color bg) { + super.setBackground(bg); + if (name != null) { + name.setBackground(bg); + } + } + + private void setAlbumName(String text) { + this.name.setText(text); + this.name.getStyledDocument().setParagraphAttributes(0, text.length(), attribs, false); + } + + @Override + public Component getListCellRendererComponent(JList list, AlbumState value, int index, + boolean isSelected, boolean cellHasFocus) { + Color bg; + Color fg; + if (isSelected) { + bg = list.getSelectionBackground(); + fg = list.getSelectionForeground(); + } else { + bg = list.getBackground(); + fg = list.getForeground(); + } + setBackground(bg); + setForeground(fg); + setFont(list.getFont()); + removeAll(); + setAlbumName(value.getTitle() + " (" + value.getMedia().size() + ")"); + MediaData m = cache.getCheckedMedia(value.getName(), value.getThumbnailName(), null); + Component c; + if (m != null) { + c = m.createThumbnailComponent(); + } else { + c = missingImageIcon; + } + name.setPreferredSize(new Dimension(Math.round(c.getPreferredSize().width * 1.5f), c.getPreferredSize().height / 3)); + c.setMinimumSize(UIUtils.THUMBNAIL_SIZE); + add(c); + add(name); + return this; + } + +} diff --git a/src/main/java/zander/ui/cellrenderer/MediaCellRenderer.java b/src/main/java/zander/ui/cellrenderer/MediaCellRenderer.java new file mode 100644 index 0000000..958914e --- /dev/null +++ b/src/main/java/zander/ui/cellrenderer/MediaCellRenderer.java @@ -0,0 +1,166 @@ +package zander.ui.cellrenderer; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.image.BufferedImage; +import java.io.IOException; + +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.JLayeredPane; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JTextPane; +import javax.swing.ListCellRenderer; +import javax.swing.text.SimpleAttributeSet; +import javax.swing.text.StyleConstants; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.LibraryMediaCache; +import zander.library.media.ImageMediaComponent; +import zander.library.media.MediaData; +import zander.library.media.SerializableImageContainer; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.library.state.MediaState; +import zander.ui.ErrorDialog; +import zander.ui.OverlayPainterComponent; +import zander.ui.UIUtils; + +public class MediaCellRenderer extends JPanel implements ListCellRenderer { + private static final long serialVersionUID = 8224231026387257322L; + private static final Logger LOGGER = LoggerFactory.getLogger(MediaCellRenderer.class); + private static final SerializableImageContainer STAR_IMAGE = UIUtils + .loadSerializableImage(ClassLoader.getSystemResource("star.svg"), -1, -1); + + private final LibraryMediaCache cache; + private final AlbumState album; + private final LibraryState library; + private final JLayeredPane layeredPane; + private final JPanel compPanel; + private final OverlayPainterComponent overlay; + private final ImageMediaComponent missingImageIcon; + private final JTextPane name; + private final SimpleAttributeSet attribs; + + public MediaCellRenderer(LibraryMediaCache cache, AlbumState album) { + setLayout(new BorderLayout()); + setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + this.cache = cache; + this.album = album; + this.library = cache.getLibrary(); + layeredPane = new JLayeredPane(); + add(layeredPane, BorderLayout.CENTER); + compPanel = new JPanel(); + compPanel.setLayout(new BoxLayout(compPanel, BoxLayout.Y_AXIS)); + overlay = new OverlayPainterComponent(STAR_IMAGE, 0, 0, 0, 0); + missingImageIcon = getMissingImageComponent(); + name = new JTextPane(); + name.setOpaque(false); + attribs = new SimpleAttributeSet(); + StyleConstants.setAlignment(attribs, StyleConstants.ALIGN_CENTER); + layeredPane.add(compPanel, Integer.valueOf(0)); + layeredPane.add(overlay, Integer.valueOf(1)); + } + + private ImageMediaComponent getMissingImageComponent() { + try { + BufferedImage bi = UIUtils.loadBufferedImage(ClassLoader.getSystemResource("missing-image-icon.svg"), -1, -1); + return new ImageMediaComponent(bi); + } catch (IOException e) { + LOGGER.error("Could not load missing image icon!", e); + ErrorDialog ed = new ErrorDialog("Internal Error", "A fatal error has occured!", e); + ed.setVisible(true); + System.exit(0); + return null; + } + } + + public LibraryState getLibrary() { + return library; + } + + @Override + public void setFont(Font font) { + super.setFont(font); + if (name != null) { + name.setFont(font); + } + } + + @Override + public void setForeground(Color fg) { + super.setForeground(fg); + if (name != null) { + name.setForeground(fg); + } + } + + @Override + public void setBackground(Color bg) { + super.setBackground(bg); + if (name != null) { + name.setBackground(bg); + } + } + + private void setNameLabel(MediaState state) { + String text = state.getName(); + this.name.setText(text); + this.name.getStyledDocument().setParagraphAttributes(0, text.length(), attribs, false); + } + + @Override + public Component getListCellRendererComponent(JList list, MediaState value, int index, + boolean isSelected, boolean cellHasFocus) { + Color bg; + Color fg; + if (isSelected) { + bg = list.getSelectionBackground(); + fg = list.getSelectionForeground(); + } else { + bg = list.getBackground(); + fg = list.getForeground(); + } + setBackground(bg); + setForeground(fg); + compPanel.setBackground(bg); + compPanel.setForeground(fg); + setFont(list.getFont()); + setNameLabel(value); + MediaData m = cache.getMedia(album.getName(), value.getName(), null); + Component c; + if (m != null) { + c = m.createThumbnailComponent(); + } else { + c = missingImageIcon; + } + c.setPreferredSize(UIUtils.THUMBNAIL_SIZE); + name.setPreferredSize(new Dimension(Math.round(c.getPreferredSize().width * 1.5f), c.getPreferredSize().height / 3)); + compPanel.removeAll(); + compPanel.add(c); + compPanel.add(name); + Dimension ns = compPanel.getLayout().preferredLayoutSize(compPanel); + setSize(ns); + setPreferredSize(ns); + compPanel.setLocation(0, 0); + compPanel.setSize(getPreferredSize()); + if (album.getThumbnailName().equals(value.getName())) { + overlay.setBounds(compPanel.getBounds()); + int scd = Math.min(getWidth(), getHeight()); + int op = (int) Math.floor(scd * 0.05f); + int os = (int) Math.floor(scd * 0.15f); + overlay.setOverlayBounds(op, op, os, os); + overlay.setVisible(true); + } else { + overlay.setVisible(false); + } + return this; + } + +} diff --git a/src/main/java/zander/ui/docs/CuratorAboutPanel.java b/src/main/java/zander/ui/docs/CuratorAboutPanel.java new file mode 100644 index 0000000..c3e9c30 --- /dev/null +++ b/src/main/java/zander/ui/docs/CuratorAboutPanel.java @@ -0,0 +1,97 @@ +package zander.ui.docs; + +import java.awt.BorderLayout; +import java.awt.Desktop; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JEditorPane; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.SwingConstants; +import javax.swing.UIManager; +import javax.swing.event.HyperlinkEvent; + +import zander.Start; +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; + +public class CuratorAboutPanel extends JPanel { + private static final long serialVersionUID = 2835012513857102851L; + + private ImageIcon logoIcon; + + private final JLabel logoLabel; + private final JLabel nameLabel; + private final JPanel namePanel; + private final JLabel versionLabel; + private final JPanel headerPanel; + private final JEditorPane infoPane; + private final JScrollPane infoScroll; + + public CuratorAboutPanel() { + loadResources(); + logoLabel = new JLabel(logoIcon); + nameLabel = new JLabel("Curator"); + nameLabel.setFont(UIUtils.scaleFont(UIManager.getFont("Label.font"), 4.0f)); + namePanel = new JPanel(); + namePanel.setLayout(new BoxLayout(namePanel, BoxLayout.X_AXIS)); + namePanel.add(Box.createHorizontalGlue()); + namePanel.add(logoLabel); + namePanel.add(nameLabel); + namePanel.add(Box.createHorizontalGlue()); + versionLabel = new JLabel("Version " + getVersionString(), SwingConstants.CENTER); + versionLabel.setFont(UIUtils.scaleFont(UIManager.getFont("Label.font"), 2.0f)); + headerPanel = new JPanel(new BorderLayout()); + headerPanel.add(namePanel, BorderLayout.CENTER); + headerPanel.add(versionLabel, BorderLayout.PAGE_END); + infoPane = new JEditorPane(); + infoPane.addHyperlinkListener((e) -> { + if (e.getEventType() == HyperlinkEvent.EventType.ACTIVATED) { + URL u = e.getURL(); + try { + Desktop.getDesktop().browse(u.toURI()); + } catch (URISyntaxException | IOException ex) { + ex.printStackTrace(); + ErrorDialog ed = new ErrorDialog("Link Error", "Could not open link: " + u.toExternalForm(), ex); + ed.setVisible(true); + } + } + }); + infoPane.setEditable(false); + infoPane.setContentType("text/html"); + try { + infoPane.setPage(ClassLoader.getSystemResource("docs/info.html")); + } catch (IOException e) { + // do nothing + } + infoScroll = new JScrollPane(infoPane); + setLayout(new BorderLayout()); + setBackground(UIManager.getColor("EditorPane.background")); + add(headerPanel, BorderLayout.PAGE_START); + add(infoScroll, BorderLayout.CENTER); + } + + private String getVersionString() { + return Start.CURATOR_VERSION; + } + + private void loadResources() { + try { + int ld = UIUtils.getLargerScreenDimension(); + logoIcon = new ImageIcon(UIUtils.loadBufferedImage(ClassLoader.getSystemResource("logo.svg"), -1, + Math.round(ld * 0.07f))); + } catch (IOException e) { + e.printStackTrace(); + ErrorDialog ed = new ErrorDialog("Internal Error", "An internal error has occurend, please try again.", e); + ed.setVisible(true); + System.exit(1); + } + } + +} diff --git a/src/main/java/zander/ui/docs/DocumentationViewer.java b/src/main/java/zander/ui/docs/DocumentationViewer.java new file mode 100644 index 0000000..12cbc1a --- /dev/null +++ b/src/main/java/zander/ui/docs/DocumentationViewer.java @@ -0,0 +1,295 @@ +package zander.ui.docs; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Desktop; +import java.awt.Dialog; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Arrays; +import java.util.NoSuchElementException; + +import javax.swing.JCheckBox; +import javax.swing.JEditorPane; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTree; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.event.HyperlinkEvent; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import javax.swing.tree.TreeSelectionModel; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; + +public class DocumentationViewer extends JFrame { + private static final long serialVersionUID = -129157128310235L; + private static final Logger LOGGER = LoggerFactory.getLogger(DocumentationViewer.class); + private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance(); + private static final DocumentBuilder DOCUMENT_BUILDER; + static { + try { + DOCUMENT_BUILDER = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); + } catch (ParserConfigurationException e) { + throw new Error("Could not create XML parser!", e); + } + } + + private static class DocumentationEntry { + public String name; + public URL url; + + public DocumentationEntry(String name, URL url) { + this.name = name; + this.url = url; + } + + @Override + public String toString() { + return name; + } + } + + private final DefaultMutableTreeNode treeRoot; + private final DefaultMutableTreeNode aboutNode; + private final CuratorAboutPanel aboutPanel; + private final DefaultTreeModel treeModel; + private final JTree tree; + private final JScrollPane treeScroll; + private final JCheckBox onTopCheck; + private final JPanel treePanel; + private final JEditorPane viewer; + private final JScrollPane viewerScroll; + private final JSplitPane content; + + public DocumentationViewer(String structureResource) { + super("Curator Help"); + treeRoot = getDocumentFromString(structureResource); + aboutNode = new DefaultMutableTreeNode("About"); + treeRoot.add(aboutNode); + aboutPanel = new CuratorAboutPanel(); + treeModel = new DefaultTreeModel(treeRoot); + tree = new JTree(treeModel); + tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); + tree.setRootVisible(false); + tree.setExpandsSelectedPaths(true); + tree.addTreeSelectionListener((e) -> { + if (e.getNewLeadSelectionPath() != null) { + DefaultMutableTreeNode node = (DefaultMutableTreeNode) e.getNewLeadSelectionPath().getLastPathComponent(); + if (node == aboutNode) { + openAbout(); + } else { + openHelp((DefaultMutableTreeNode) e.getNewLeadSelectionPath().getLastPathComponent()); + } + } + }); + treeScroll = new JScrollPane(tree); + onTopCheck = new JCheckBox("Stay above other windows"); + onTopCheck.setHorizontalAlignment(SwingConstants.CENTER); + onTopCheck.setMnemonic(KeyEvent.VK_S); + onTopCheck.addActionListener((e) -> { + setAlwaysOnTop(onTopCheck.isSelected()); + }); + treePanel = new JPanel(new BorderLayout()); + treePanel.add(treeScroll, BorderLayout.CENTER); + treePanel.add(onTopCheck, BorderLayout.PAGE_END); + viewer = new JEditorPane(); + viewer.setContentType("text/html"); + viewer.setEditable(false); + viewer.addHyperlinkListener((e) -> { + if (e.getEventType() == HyperlinkEvent.EventType.ACTIVATED) { + String tu = e.getDescription(); + if (tu.startsWith("help:")) { + if (tu.length() <= 6) { + return; + } + String path = tu.substring(5); + openHelp(path); + } else if (tu.startsWith("http://") || tu.startsWith("https://")) { + URL u = e.getURL(); + try { + Desktop.getDesktop().browse(u.toURI()); + } catch (URISyntaxException | IOException ex) { + LOGGER.error("Could not open url: {}", u.toExternalForm(), ex); + ErrorDialog ed = new ErrorDialog("Link Error", "Could not open link: " + + u.toExternalForm(), ex); + ed.setVisible(true); + } + } + } + }); + viewerScroll = new JScrollPane(viewer); + content = new JSplitPane(); + content.setOneTouchExpandable(true); + content.setLeftComponent(treePanel); + content.setRightComponent(viewerScroll); + setContentPane(content); + setModalExclusionType(Dialog.ModalExclusionType.APPLICATION_EXCLUDE); + setResizable(true); + setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); + setSize((2 * UIUtils.SCREEN_SIZE.width) / 3, (2 * UIUtils.SCREEN_SIZE.height) / 3); + expandFirstLevelNodes(); + } + + public void openHelp(String page) { + String[] path = page.split("/"); + if (path.length != 0) { + openHelp(path, treeRoot); + } + setVisible(true); + } + + private void openHelp(String[] path, DefaultMutableTreeNode node) { + int ns = node.getChildCount(); + for (int i = 0; i < ns; ++i) { + DefaultMutableTreeNode c = (DefaultMutableTreeNode) node.getChildAt(i); + if (path[0].equals(String.valueOf(c.getUserObject()))) { + openHelp(Arrays.copyOfRange(path, 1, path.length), c); + return; + } + } + tree.setSelectionPath(new TreePath(node.getPath())); + openHelp(node); + } + + private void openHelp(DefaultMutableTreeNode node) { + setTitle("Curator Help"); + Object uo = node.getUserObject(); + if (uo instanceof DocumentationEntry) { + DocumentationEntry e = (DocumentationEntry) uo; + try { + if (content.getRightComponent() != viewerScroll) { + int div = content.getDividerLocation(); + content.setRightComponent(viewerScroll); + content.setDividerLocation(div); + revalidate(); + } + viewer.setPage(e.url); + return; + } catch (IOException ex) { + // ignore + } + } + } + + private void openAbout() { + tree.setSelectionPath(new TreePath(aboutNode.getPath())); + if (content.getRightComponent() != aboutPanel) { + int div = content.getDividerLocation(); + content.setRightComponent(aboutPanel); + content.setDividerLocation(div); + revalidate(); + } + setTitle("About Curator"); + LOGGER.info("Oppened about"); + setVisible(true); + } + + private DefaultMutableTreeNode getDocumentFromString(String resource) { + try { + try (InputStream in = ClassLoader.getSystemResourceAsStream(resource)) { + Document doc = DOCUMENT_BUILDER.parse(in); + Element root = doc.getDocumentElement(); + return getDocumentNode(root); + } + } catch (Throwable e) { + LOGGER.error("Error parsing json while initializing DocumentationFrame", e); + System.exit(1); + return null; + } + } + + private DefaultMutableTreeNode getDocumentNode(Node rn) { + if (rn.getNodeType() == Node.ELEMENT_NODE) { + Element element = (Element) rn; + String name = element.getAttribute("name"); + DefaultMutableTreeNode tn = new DefaultMutableTreeNode(); + if (element.getTagName().equalsIgnoreCase("dir")) { + tn.setUserObject(name); + NodeList nl = element.getChildNodes(); + for (int i = 0; i < nl.getLength(); ++i) { + DefaultMutableTreeNode child = getDocumentNode(nl.item(i)); + if (child != null) { + tn.add(child); + } + } + } else if (element.getTagName().equalsIgnoreCase("file")) { + String value = element.getTextContent(); + URL url = ClassLoader.getSystemResource(value); + DocumentationEntry entry = new DocumentationEntry(name, url); + tn.setUserObject(entry); + } + return tn; + } + return null; + } + + private void expandFirstLevelNodes() { + try { + DefaultMutableTreeNode fn = (DefaultMutableTreeNode) treeRoot.getFirstChild(); + tree.setSelectionPath(new TreePath(fn.getPath())); + } catch (NoSuchElementException e) { + // do nothing + } + } + + private static final String DOCS_PATH = "docs.xml"; + private static DocumentationViewer FRAME; + static { + if (!SwingUtilities.isEventDispatchThread()) { + try { + SwingUtilities.invokeAndWait(() -> FRAME = new DocumentationViewer(DOCS_PATH)); + } catch (Throwable e) { + LOGGER.error("Could not create documentation viewer!", e); + System.exit(1); + } + } else { + FRAME = new DocumentationViewer(DOCS_PATH); + } + } + + public static void show(String path) { + show((Window) null, path); + } + + public static void show(Component comp, String path) { + show(SwingUtilities.getWindowAncestor(comp), path); + } + + public static void show(Window parent, String path) { + if (!FRAME.isVisible()) { + UIUtils.centerWindow(FRAME, parent); + } + FRAME.openHelp(path); + } + + public static void showAbout() { + FRAME.openAbout(); + } + + public static void showAbout(Window parent) { + if (!FRAME.isVisible()) { + UIUtils.centerWindow(FRAME, parent); + } + showAbout(); + } +} diff --git a/src/main/java/zander/ui/library/CuratorOptionsDialog.java b/src/main/java/zander/ui/library/CuratorOptionsDialog.java new file mode 100644 index 0000000..afb0f27 --- /dev/null +++ b/src/main/java/zander/ui/library/CuratorOptionsDialog.java @@ -0,0 +1,221 @@ +package zander.ui.library; + +import java.awt.BorderLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.io.File; +import java.util.ArrayList; +import java.util.Set; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultComboBoxModel; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.UIManager; +import javax.swing.UIManager.LookAndFeelInfo; + +import zander.library.secrets.SecretsFactory; +import zander.ui.FileChooserField; +import zander.ui.UIUtils; +import zander.ui.docs.DocumentationViewer; + +public class CuratorOptionsDialog extends JDialog { + private static final long serialVersionUID = -1839450524327574007L; + + public static final String NO_DEFAULT_LIBRARY = "Do not automatically open any libraries"; + public static final String LAST_DEFAULT_LIBRARY = "Automatically open the last oppened library"; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_SAVE = 1; + + private int response = RESPONSE_CANCEL; + + private final JLabel defaultPathLabel; + private final FileChooserField defaultPathField; + private final JLabel passStoreLabel; + private final JComboBox passStoreBox; + private final JLabel lafLabel; + private final JComboBox lafBox; + private final JLabel defaultLibraryLabel; + private final DefaultComboBoxModel defaultLibraryModel; + private final JComboBox defaultLibraryBox; + private final JLabel openWebLabel; + private final JComboBox openWebBox; + private final JLabel askToBackLabel; + private final JCheckBox askToBackCheck; + private final JPanel settingsPanel; + private final JButton cancelButton; + private final JButton helpButton; + private final JButton saveButton; + private final JPanel buttonPanel; + private final JPanel content; + + public CuratorOptionsDialog(Window parent) { + super(parent, "Curator Settings"); + defaultPathLabel = new JLabel("Default Library Location:"); + defaultPathField = new FileChooserField(20, LibrarySelectFrame.DEFAULT_LIBRARY_PATH, new File(LibrarySelectFrame.DEFAULT_LIBRARY_PATH)); + defaultPathField.getChooser().setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + defaultPathField.getChooser().setDialogTitle("Select Default Library Location"); + passStoreLabel = new JLabel("Password Store Backend:"); + passStoreBox = new JComboBox(getPasswordBackends()); + passStoreBox.setSelectedItem(LibrarySelectFrame.SECRETS_FACTORY); + lafLabel = new JLabel("Theme:"); + lafBox = new JComboBox(getLookAndFeels()); + setInitialLookAndFeel(); + defaultLibraryLabel = new JLabel("Default Library:"); + defaultLibraryModel = new DefaultComboBoxModel(LibrarySelectFrame.LIBRARY_ENTRIES.toArray()); + defaultLibraryModel.insertElementAt(NO_DEFAULT_LIBRARY, 0); + defaultLibraryModel.insertElementAt(LAST_DEFAULT_LIBRARY, 1); + defaultLibraryBox = new JComboBox(defaultLibraryModel); + defaultLibraryBox.setSelectedItem(LibrarySelectFrame.DEFAULT_LIBRARY); + openWebLabel = new JLabel("Default Upload Action:"); + openWebBox = new JComboBox(new String[] {"Always ask", "Never open library", "Always open library"}); + openWebBox.setSelectedIndex(LibrarySelectFrame.LIBRARY_WEB_HANDLER); + askToBackLabel = new JLabel("Ask Before Exiting Album:"); + askToBackCheck = new JCheckBox(); + askToBackCheck.setSelected(LibrarySelectFrame.ASK_TO_RETURN_TO_LIBRARY_SELECT); + settingsPanel = new JPanel(new GridBagLayout()); + buildSettingsPanel(); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> { + dispose(); + }); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Libraries/Global Settings")); + saveButton = new JButton("Save"); + saveButton.setMnemonic(KeyEvent.VK_S); + saveButton.addActionListener((e) -> { + dispose(); + response = RESPONSE_SAVE; + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(saveButton); + content = new JPanel(new BorderLayout()); + content.add(settingsPanel, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + setResizable(false); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + pack(); + UIUtils.centerWindow(this, parent); + } + + private void setInitialLookAndFeel() { + if (LibrarySelectFrame.LOOK_AND_FEEL.equals(UIUtils.SYSTEM_LAF_NAME)) { + lafBox.setSelectedIndex(0); + } else { + lafBox.setSelectedItem(LibrarySelectFrame.LOOK_AND_FEEL); + } + } + + private String[] getLookAndFeels() { + final ArrayList lafs = new ArrayList(); + for (LookAndFeelInfo laf : UIManager.getInstalledLookAndFeels()) { + if (laf.getName().equals(UIUtils.SYSTEM_LAF_NAME)) { + lafs.add(0, "Default (" + laf.getName() + ")"); + } else { + lafs.add(laf.getName()); + } + } + return lafs.toArray(new String[0]); + } + + private SecretsFactory[] getPasswordBackends() { + Set sfs = SecretsFactory.getFactories(); + ArrayList backends = new ArrayList(); + backends.add(SecretsFactory.getDefaultFactory()); + for (SecretsFactory sf : sfs) { + if (sf.isSupported()) { + backends.add(sf); + } + } + return backends.toArray(new SecretsFactory[0]); + } + + private void buildSettingsPanel() { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(1, 1, 1, 1); + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.gridy = 0; + gbc.gridx = 0; + settingsPanel.add(defaultPathLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + settingsPanel.add(defaultPathField, gbc); + gbc.gridy = 1; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + settingsPanel.add(passStoreLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + settingsPanel.add(passStoreBox, gbc); + gbc.gridy = 2; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + settingsPanel.add(lafLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + settingsPanel.add(lafBox, gbc); + gbc.gridy = 3; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + settingsPanel.add(defaultLibraryLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + settingsPanel.add(defaultLibraryBox, gbc); + gbc.gridy = 4; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + settingsPanel.add(openWebLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + settingsPanel.add(openWebBox, gbc); + gbc.gridy = 5; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + settingsPanel.add(askToBackLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + settingsPanel.add(askToBackCheck, gbc); + } + + public int getResponse() { + return response; + } + + private String getSelectedLafString() { + if (lafBox.getSelectedIndex() == 0) { + return UIUtils.SYSTEM_LAF_NAME; + } + return lafBox.getItemAt(lafBox.getSelectedIndex()); + } + + public void updateSettigs() { + LibrarySelectFrame.updateLibraryPath(defaultPathField.getPathField().getText()); + if (!passStoreBox.getSelectedItem().equals(LibrarySelectFrame.SECRETS_FACTORY)) { + LibrarySelectFrame.updateSecretsFactory(passStoreBox.getItemAt(passStoreBox.getSelectedIndex())); + } + LibrarySelectFrame.updateLookAndFeel(getSelectedLafString()); + LibrarySelectFrame.updateDefaultLibrarySelection(defaultLibraryBox.getSelectedItem()); + LibrarySelectFrame.updateWebOpenHandler(openWebBox.getSelectedIndex()); + LibrarySelectFrame.updateAskToBack(askToBackCheck.isSelected()); + } + +} diff --git a/src/main/java/zander/ui/library/LibraryCreateDialog.java b/src/main/java/zander/ui/library/LibraryCreateDialog.java new file mode 100644 index 0000000..b38239f --- /dev/null +++ b/src/main/java/zander/ui/library/LibraryCreateDialog.java @@ -0,0 +1,269 @@ +package zander.ui.library; + +import java.awt.BorderLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.io.File; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import zander.ui.FileChooserField; +import zander.ui.UIUtils; +import zander.ui.docs.DocumentationViewer; + +public class LibraryCreateDialog extends JDialog { + private static final long serialVersionUID = 5562277185250462433L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_CREATE = 1; + + private int response = RESPONSE_CANCEL; + + private final JLabel nameLabel; + private final JTextField nameField; + private final JLabel urlLabel; + private final JTextField urlField; + private final JLabel ftpLabel; + private final JTextField ftpField; + private final JLabel usernameLabel; + private final JTextField usernameField; + private final JLabel passwordLabel; + private final JPasswordField passwordField; + private final JCheckBox defaultPathCheck; + private final JLabel pathLabel; + private final FileChooserField pathField; + private final JPanel entryPanel; + private final JButton cancelButton; + private final JButton helpButton; + private final JButton createButton; + private final JPanel buttonPanel; + private final JPanel panel; + + public LibraryCreateDialog(Window parent) { + super(parent, "Create New Library"); + nameLabel = new JLabel("Name:"); + nameField = new JTextField(10); + urlLabel = new JLabel("(Optional) URL:"); + urlField = new JTextField(10); + ftpLabel = new JLabel("FTP:"); + ftpField = new JTextField(10); + usernameLabel = new JLabel("Username:"); + usernameField = new JTextField(10); + passwordLabel = new JLabel("Password:"); + passwordField = new JPasswordField(10); + defaultPathCheck = new JCheckBox("Use default library location"); + defaultPathCheck.setSelected(true); + pathLabel = new JLabel("Library Path:"); + pathField = new FileChooserField(10, LibrarySelectFrame.DEFAULT_LIBRARY_PATH + "/", new File(LibrarySelectFrame.DEFAULT_LIBRARY_PATH)); + pathField.getChooser().setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + pathField.getChooser().setDialogTitle("Select Library Location"); + pathField.setEnabled(false); + nameField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { + update(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + update(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + update(); + } + + private void update() { + if (defaultPathCheck.isSelected()) { + pathField.getPathField().setText(LibrarySelectFrame.DEFAULT_LIBRARY_PATH + "/" + nameField.getText()); + } + } + }); + defaultPathCheck.addItemListener((e) -> { + if (defaultPathCheck.isSelected()) { + pathField.getPathField().setText(LibrarySelectFrame.DEFAULT_LIBRARY_PATH + "/" + nameField.getText()); + pathField.setEnabled(false); + } else { + pathField.setEnabled(true); + } + }); + entryPanel = new JPanel(new GridBagLayout()); + buildEntryPanel(); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> { + dispose(); + }); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Libraries/Local Library Settings")); + createButton = new JButton("Create"); + createButton.setMnemonic(KeyEvent.VK_R); + createButton.setEnabled(false); + createButton.addActionListener((e) -> { + if (nameField.getText().contains("/")) { + JOptionPane.showMessageDialog(this, "Library names cannot contain the '/' character!", "Library Error", JOptionPane.ERROR_MESSAGE); + } else { + dispose(); + response = RESPONSE_CREATE; + } + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(createButton); + final DocumentListener formFillListener = new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { + update(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + update(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + update(); + } + + private void update() { + if (!nameField.getText().isBlank() && !ftpField.getText().isBlank() + && !usernameField.getText().isBlank() && !pathField.getPathField().getText().isBlank()) { + createButton.setEnabled(true); + } else { + createButton.setEnabled(false); + } + } + }; + nameField.getDocument().addDocumentListener(formFillListener); + ftpField.getDocument().addDocumentListener(formFillListener); + usernameField.getDocument().addDocumentListener(formFillListener); + pathField.getPathField().getDocument().addDocumentListener(formFillListener); + panel = new JPanel(new BorderLayout()); + panel.add(entryPanel, BorderLayout.CENTER); + panel.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(panel); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + setModalityType(DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + setResizable(false); + setAlwaysOnTop(true); + pack(); + UIUtils.centerWindow(this, parent); + } + + private void buildEntryPanel() { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(1, 1, 1, 1); + gbc.gridwidth = 1; + gbc.gridheight = 1; + gbc.gridy = 0; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.fill = GridBagConstraints.NONE; + entryPanel.add(nameLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(nameField, gbc); + gbc.gridy = 1; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(urlLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(urlField, gbc); + gbc.gridy = 2; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(ftpLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + gbc.fill = GridBagConstraints.HORIZONTAL; + entryPanel.add(ftpField, gbc); + gbc.gridy = 3; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(usernameLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + gbc.fill = GridBagConstraints.HORIZONTAL; + entryPanel.add(usernameField, gbc); + gbc.gridy = 4; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(passwordLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(passwordField, gbc); + gbc.gridy = 5; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.CENTER; + gbc.fill = GridBagConstraints.NONE; + gbc.gridwidth = 2; + entryPanel.add(defaultPathCheck, gbc); + gbc.gridwidth = 1; + gbc.gridy = 6; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(pathLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(pathField, gbc); + } + + public int getResponse() { + return response; + } + + public String getLibraryName() { + return nameField.getText(); + } + + public String getURL() { + return urlField.getText(); + } + + public String getFTP() { + return ftpField.getText(); + } + + public String getUsername() { + return usernameField.getText(); + } + + public String getPassword() { + return new String(passwordField.getPassword()); + } + + public String getLibraryFile() { + return pathField.getPathField().getText(); + } + +} diff --git a/src/main/java/zander/ui/library/LibraryDeleteDialog.java b/src/main/java/zander/ui/library/LibraryDeleteDialog.java new file mode 100644 index 0000000..d89b323 --- /dev/null +++ b/src/main/java/zander/ui/library/LibraryDeleteDialog.java @@ -0,0 +1,89 @@ +package zander.ui.library; + +import java.awt.BorderLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Window; +import java.awt.event.KeyEvent; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.UIManager; + +import zander.ui.UIUtils; + +public class LibraryDeleteDialog extends JDialog { + private static final long serialVersionUID = 1L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_DELETE = 1; + + private int response = RESPONSE_CANCEL; + + private final JLabel iconLabel; + private final JLabel messageLabel; + private final JCheckBox diskBox; + private final JPanel dialogPanel; + private final JButton cancelButton; + private final JButton deleteButton; + private final JPanel buttonPanel; + private final JPanel content; + + public LibraryDeleteDialog(Window parent, String name) { + super(parent, "Delete Library"); + iconLabel = new JLabel(UIManager.getIcon("OptionPane.questionIcon")); + messageLabel = new JLabel("Are you sure you would like to delete the library '" + name + "'?"); + diskBox = new JCheckBox("Delete From Disk (Cannot Be Undone)"); + dialogPanel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridy = 0; + gbc.gridx = 0; + dialogPanel.add(iconLabel, gbc); + gbc.gridx = 1; + dialogPanel.add(messageLabel, gbc); + gbc.gridy = 1; + gbc.gridx = 0; + gbc.gridwidth = 2; + dialogPanel.add(diskBox, gbc); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> { + dispose(); + }); + deleteButton = new JButton("Delete"); + cancelButton.setMnemonic(KeyEvent.VK_D); + deleteButton.addActionListener((e) -> { + dispose(); + response = RESPONSE_DELETE; + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(deleteButton); + content = new JPanel(new BorderLayout()); + content.add(dialogPanel, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + setResizable(false); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + pack(); + UIUtils.centerWindow(this, parent); + } + + public int getResponse() { + return response; + } + + public boolean isDeleteFromDisk() { + return diskBox.isSelected(); + } + +} diff --git a/src/main/java/zander/ui/library/LibraryEditDialog.java b/src/main/java/zander/ui/library/LibraryEditDialog.java new file mode 100644 index 0000000..92e9a58 --- /dev/null +++ b/src/main/java/zander/ui/library/LibraryEditDialog.java @@ -0,0 +1,290 @@ +package zander.ui.library; + +import java.awt.BorderLayout; +import java.awt.GridBagLayout; +import java.awt.GridBagConstraints; +import java.awt.Insets; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.io.File; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import zander.ui.FileChooserField; +import zander.ui.UIUtils; +import zander.ui.docs.DocumentationViewer; + +public class LibraryEditDialog extends JDialog { + private static final long serialVersionUID = -6438014653555033365L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_SAVE = 1; + + private int response = RESPONSE_CANCEL; + + private final JLabel nameLabel; + private final JTextField nameField; + private final JLabel urlLabel; + private final JTextField urlField; + private final JLabel ftpLabel; + private final JTextField ftpField; + private final JLabel usernameLabel; + private final JTextField usernameField; + private final JLabel passwordLabel; + private final JPasswordField passwordField; + private final JCheckBox defaultPathCheck; + private final JLabel pathLabel; + private final FileChooserField pathField; + private final JPanel entryPanel; + private final JButton cancelButton; + private final JButton helpButton; + private final JButton clearCacheButton; + private final JButton saveButton; + private final JPanel buttonPanel; + private final JPanel panel; + + public LibraryEditDialog(Window parent, String name, String url, String ftp, String username, String password, String file, Runnable cacheClearCallback) { + super(parent, "Edit Library"); + nameLabel = new JLabel("Name:"); + nameField = new JTextField(10); + nameField.setText(name); + urlLabel = new JLabel("(Optional) URL:"); + urlField = new JTextField(10); + urlField.setText(url); + ftpLabel = new JLabel("FTP:"); + ftpField = new JTextField(10); + ftpField.setText(ftp); + usernameLabel = new JLabel("Username:"); + usernameField = new JTextField(10); + usernameField.setText(username); + passwordLabel = new JLabel("Password:"); + passwordField = new JPasswordField(10); + passwordField.setText(password); + defaultPathCheck = new JCheckBox("Use default library location"); + pathLabel = new JLabel("Library Path:"); + pathField = new FileChooserField(10, file, new File(LibrarySelectFrame.DEFAULT_LIBRARY_PATH)); + if (file.equals(LibrarySelectFrame.DEFAULT_LIBRARY_PATH + "/" + name)) { + defaultPathCheck.setSelected(true); + pathField.setEnabled(false); + } else { + defaultPathCheck.setSelected(false); + } + pathField.getChooser().setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + pathField.getChooser().setDialogTitle("Select Library Location"); + nameField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { + update(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + update(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + update(); + } + + private void update() { + if (defaultPathCheck.isSelected()) { + pathField.getPathField().setText(LibrarySelectFrame.DEFAULT_LIBRARY_PATH + "/" + nameField.getText()); + } + } + }); + defaultPathCheck.addItemListener((e) -> { + if (defaultPathCheck.isSelected()) { + pathField.getPathField().setText(LibrarySelectFrame.DEFAULT_LIBRARY_PATH + "/" + nameField.getText()); + pathField.setEnabled(false); + } else { + pathField.setEnabled(true); + } + }); + entryPanel = new JPanel(new GridBagLayout()); + buildEntryPanel(); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> { + dispose(); + }); + clearCacheButton = new JButton("Clear Cache"); + clearCacheButton.setMnemonic(KeyEvent.VK_L); + clearCacheButton.addActionListener((e) -> { + int c = JOptionPane.showConfirmDialog(this, "Are you sure you would like to clear this library's cache?", + "Clear Cahce", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + if (cacheClearCallback != null && c == JOptionPane.YES_OPTION) { + cacheClearCallback.run(); + } + }); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Libraries/Local Library Settings")); + saveButton = new JButton("Save"); + saveButton.setMnemonic(KeyEvent.VK_S); + saveButton.setEnabled(false); + saveButton.addActionListener((e) -> { + if (nameField.getText().contains("/")) { + JOptionPane.showMessageDialog(this, "Library names cannot contain the '/' character!", "Library Error", JOptionPane.ERROR_MESSAGE); + } else { + dispose(); + response = RESPONSE_SAVE; + } + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(clearCacheButton); + buttonPanel.add(saveButton); + final DocumentListener formFillListener = new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { + update(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + update(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + update(); + } + + private void update() { + if (!nameField.getText().isBlank() && !ftpField.getText().isBlank() + && !usernameField.getText().isBlank() && !pathField.getPathField().getText().isBlank()) { + saveButton.setEnabled(true); + } else { + saveButton.setEnabled(false); + } + } + }; + nameField.getDocument().addDocumentListener(formFillListener); + ftpField.getDocument().addDocumentListener(formFillListener); + usernameField.getDocument().addDocumentListener(formFillListener); + pathField.getPathField().getDocument().addDocumentListener(formFillListener); + panel = new JPanel(new BorderLayout()); + panel.add(entryPanel, BorderLayout.CENTER); + panel.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(panel); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + setModalityType(DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + setResizable(false); + setAlwaysOnTop(true); + pack(); + UIUtils.centerWindow(this, parent); + formFillListener.changedUpdate(null); + } + + private void buildEntryPanel() { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(1, 1, 1, 1); + gbc.gridwidth = 1; + gbc.gridheight = 1; + gbc.gridy = 0; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.fill = GridBagConstraints.NONE; + entryPanel.add(nameLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + gbc.fill = GridBagConstraints.HORIZONTAL; + entryPanel.add(nameField, gbc); + gbc.gridy = 1; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(urlLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + gbc.fill = GridBagConstraints.HORIZONTAL; + entryPanel.add(urlField, gbc); + gbc.gridy = 2; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(ftpLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + gbc.fill = GridBagConstraints.HORIZONTAL; + entryPanel.add(ftpField, gbc); + gbc.gridy = 3; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(usernameLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + gbc.fill = GridBagConstraints.HORIZONTAL; + entryPanel.add(usernameField, gbc); + gbc.gridy = 4; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(passwordLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(passwordField, gbc); + gbc.gridy = 5; + gbc.gridx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.CENTER; + gbc.gridwidth = 2; + entryPanel.add(defaultPathCheck, gbc); + gbc.gridwidth = 1; + gbc.gridy = 6; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(pathLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(pathField, gbc); + } + + public int getResponse() { + return response; + } + + public String getLibraryName() { + return nameField.getText(); + } + + public String getURL() { + return urlField.getText(); + } + + public String getFTP() { + return ftpField.getText(); + } + + public String getUsername() { + return usernameField.getText(); + } + + public String getPassword() { + return new String(passwordField.getPassword()); + } + + public String getLibraryFile() { + return pathField.getPathField().getText(); + } + +} diff --git a/src/main/java/zander/ui/library/LibraryExistsDialog.java b/src/main/java/zander/ui/library/LibraryExistsDialog.java new file mode 100644 index 0000000..48cc795 --- /dev/null +++ b/src/main/java/zander/ui/library/LibraryExistsDialog.java @@ -0,0 +1,82 @@ +package zander.ui.library; + +import java.awt.BorderLayout; +import java.awt.Window; +import java.awt.event.KeyEvent; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.UIManager; + +import zander.ui.UIUtils; + +public class LibraryExistsDialog extends JDialog { + private static final long serialVersionUID = -7358868972104777634L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_DELETE = 1; + public static final int RESPONSE_IMPORT = 2; + + private int response = RESPONSE_CANCEL; + + private final JLabel iconLabel; + private final JLabel messageLabel; + private final JPanel messagePanel; + private final JButton cancelButton; + private final JButton deleteButton; + private final JButton importButton; + private final JPanel buttonPanel; + private final JPanel content; + + public LibraryExistsDialog(Window window) { + super(window, "Library Exists"); + iconLabel = new JLabel(UIManager.getIcon("OptionPane.warningIcon")); + messageLabel = new JLabel("A library already exists in this location. You can import it, delete it, or cancel the opperation."); + messagePanel = new JPanel(); + messagePanel.setLayout(new BoxLayout(messagePanel, BoxLayout.X_AXIS)); + messagePanel.add(iconLabel); + messagePanel.add(messageLabel); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> { + dispose(); + }); + deleteButton = new JButton("Delete"); + deleteButton.setMnemonic(KeyEvent.VK_D); + deleteButton.addActionListener((e) -> { + dispose(); + response = RESPONSE_DELETE; + }); + importButton = new JButton("Import"); + importButton.setMnemonic(KeyEvent.VK_I); + importButton.addActionListener((e) -> { + dispose(); + response = RESPONSE_IMPORT; + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(deleteButton); + buttonPanel.add(importButton); + content = new JPanel(new BorderLayout()); + content.add(messagePanel, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + setResizable(false); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + pack(); + UIUtils.centerWindow(this, window); + } + + public int getResponse() { + return response; + } + +} diff --git a/src/main/java/zander/ui/library/LibraryImportDialog.java b/src/main/java/zander/ui/library/LibraryImportDialog.java new file mode 100644 index 0000000..70dc3a4 --- /dev/null +++ b/src/main/java/zander/ui/library/LibraryImportDialog.java @@ -0,0 +1,201 @@ +package zander.ui.library; + +import java.awt.BorderLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Window; +import java.awt.event.KeyEvent; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import zander.ui.FileChooserField; +import zander.ui.UIUtils; +import zander.ui.docs.DocumentationViewer; + +public class LibraryImportDialog extends JDialog { + private static final long serialVersionUID = 8184071881411029915L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_IMPORT = 1; + + private int response = RESPONSE_CANCEL; + + private final JLabel urlLabel; + private final JTextField urlField; + private final JLabel ftpLabel; + private final JTextField ftpField; + private final JLabel usernameLabel; + private final JTextField usernameField; + private final JLabel passwordLabel; + private final JPasswordField passwordField; + private final JLabel fileLabel; + private final FileChooserField fileField; + private final JPanel entryPanel; + private final JButton cancelButton; + private final JButton helpButton; + private final JButton importButton; + private final JPanel buttonPanel; + private final JPanel content; + + public LibraryImportDialog(Window parent) { + super(parent, "Import Library"); + urlLabel = new JLabel("(Optional) URL:"); + urlField = new JTextField(10); + ftpLabel = new JLabel("FTP:"); + ftpField = new JTextField(10); + usernameLabel = new JLabel("Username:"); + usernameField = new JTextField(10); + passwordLabel = new JLabel("Password:"); + passwordField = new JPasswordField(10); + fileLabel = new JLabel("Source:"); + fileField = new FileChooserField(10); + fileField.getChooser().setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + fileField.getChooser().setMultiSelectionEnabled(false); + fileField.getChooser().setDialogType(JFileChooser.OPEN_DIALOG); + fileField.getChooser().setDialogTitle("Select Library Location"); + entryPanel = new JPanel(new GridBagLayout()); + buildEntryPanel(); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> { + dispose(); + }); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Libraries/Local Library Settings")); + importButton = new JButton("Import"); + importButton.setMnemonic(KeyEvent.VK_I); + importButton.setEnabled(false); + importButton.addActionListener((e) -> { + dispose(); + response = RESPONSE_IMPORT; + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(importButton); + final DocumentListener formFillListener = new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { + update(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + update(); + } + + + @Override + public void changedUpdate(DocumentEvent e) { + update(); + } + + private void update() { + importButton.setEnabled(!ftpField.getText().isBlank() && !usernameField.getText().isBlank() && !fileField.getPathField().getText().isBlank()); + } + }; + ftpField.getDocument().addDocumentListener(formFillListener); + usernameField.getDocument().addDocumentListener(formFillListener); + fileField.getPathField().getDocument().addDocumentListener(formFillListener); + content = new JPanel(new BorderLayout()); + content.add(entryPanel, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + setResizable(false); + pack(); + UIUtils.centerWindow(this, parent); + } + + private void buildEntryPanel() { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridwidth = 1; + gbc.gridheight = 1; + gbc.insets = new Insets(1, 1, 1, 1); + gbc.fill = GridBagConstraints.NONE; + gbc.gridy = 0; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + entryPanel.add(urlLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(urlField, gbc); + gbc.gridy = 1; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.fill = GridBagConstraints.NONE; + entryPanel.add(ftpLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(ftpField, gbc); + gbc.gridy = 2; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.fill = GridBagConstraints.NONE; + entryPanel.add(usernameLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(usernameField, gbc); + gbc.gridy = 3; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.fill = GridBagConstraints.NONE; + entryPanel.add(passwordLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(passwordField, gbc); + gbc.gridy = 4; + gbc.gridx = 0; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.fill = GridBagConstraints.NONE; + entryPanel.add(fileLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + entryPanel.add(fileField, gbc); + } + + public int getResponse() { + return response; + } + + public String getURL() { + return urlField.getText(); + } + + public String getFTP() { + return ftpField.getText(); + } + + public String getUsername() { + return usernameField.getText(); + } + + public String getPassword() { + return new String(passwordField.getPassword()); + } + + public String getFile() { + return fileField.getPathField().getText(); + } + +} diff --git a/src/main/java/zander/ui/library/LibrarySelectFrame.java b/src/main/java/zander/ui/library/LibrarySelectFrame.java new file mode 100644 index 0000000..cd122cc --- /dev/null +++ b/src/main/java/zander/ui/library/LibrarySelectFrame.java @@ -0,0 +1,997 @@ +package zander.ui.library; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Desktop; +import java.awt.Font; +import java.awt.Rectangle; +import java.awt.Window; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.prefs.Preferences; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultListModel; +import javax.swing.DropMode; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.UnsupportedLookAndFeelException; +import javax.swing.event.ListDataEvent; +import javax.swing.event.ListDataListener; + +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.Library; +import zander.library.Manifest.ManifestParseException; +import zander.library.secrets.Secrets; +import zander.library.secrets.SecretsFactory; +import zander.ui.ErrorDialog; +import zander.ui.LibraryFrame; +import zander.ui.RememberConfirmDialog; +import zander.ui.UIUtils; +import zander.ui._import.ImportLibrarySelectDialog; +import zander.ui.docs.DocumentationViewer; +import zander.ui.transferhandlers.ListArrangeTransferHandler; + +public class LibrarySelectFrame extends JFrame { + private static final long serialVersionUID = 5170663213012571338L; + private static final Logger LOGGER = LoggerFactory.getLogger(LibrarySelectFrame.class); + private static final Preferences LSF_CACHE = UIUtils.PREFS_ROOT.node("lsf-cache"); + private static final Preferences CURATOR_SETTINGS = UIUtils.PREFS_ROOT.node("settings"); + + public static final String SECRETS_KEY = "curator-secrets/"; + + public static class LibraryEntry implements Serializable { + private static final long serialVersionUID = 2910805274300568269L; + + public String name; + public String url; + public String ftp; + public File file; + + public LibraryEntry(String name, String url, String ftp, File file) { + this.name = name; + this.url = url; + this.ftp = ftp; + this.file = file; + } + + @Override + public String toString() { + if (url == null || url.length() == 0) { + return name; + } + return name + " (" + url + ")"; + } + + @Override + public boolean equals(Object o) { + if (o instanceof LibraryEntry) { + LibraryEntry e = (LibraryEntry) o; + return e.name.equals(name) && e.url.equals(url) && e.file.getPath().equals(file.getPath()); + } + return false; + } + + @Override + public int hashCode() { + return (name + "/" + url).hashCode(); + } + } + + public static String DEFAULT_LIBRARY_PATH = getDefaultLibraryPath(); + public static SecretsFactory SECRETS_FACTORY = loadSecretsFactory(); + public static Object DEFAULT_LIBRARY = loadDefaultLibrarySelection(); + public static LibraryEntry LAST_LIBRARY = settingsGetObject("last-library", null); + public static final DefaultListModel LIBRARY_ENTRIES = new DefaultListModel(); + public static String LOOK_AND_FEEL; + public static int LIBRARY_WEB_HANDLER = loadLibraryWebHandler(); + public static boolean ASK_TO_RETURN_TO_LIBRARY_SELECT = loadAskToBack(); + + public static final int LIBRARY_WEB_ASK = 0; + public static final int LIBRARY_WEB_DONT_OPEN = 1; + public static final int LIBRARY_WEB_OPEN = 2; + + private ImageIcon logoIcon; + private final JLabel logoLabel; + private final JLabel titleLabel; + private final JPanel titlePanel; + private final JLabel screenLabel; + private final JPanel topPanel; + private final JList list; + private final JScrollPane listScroll; + private final JMenuItem openItem; + private final JMenuItem editItem; + private final JMenuItem deleteItem; + private final JMenuItem newItem; + private final JMenuItem importItem; + private final JPopupMenu listPopup; + private final JButton quitButton; + private final JButton helpButton; + private final JButton optionsButton; + private final JButton importButton; + private final JButton newButton; + private final JButton deleteButton; + private final JButton editButton; + private final JButton openButton; + private final JPanel buttonPanel; + private final JPanel content; + + public LibrarySelectFrame(String[] args) { + super("Curator"); + loadInitialLookAndFeel(); + loadResources(); + readWindowCache(); + logoLabel = new JLabel(logoIcon); + logoLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, Math.round(UIUtils.SCREEN_SIZE.width * 0.01f))); + titleLabel = new JLabel("Curator"); + titleLabel.setFont(getTitleFont()); + titlePanel = new JPanel(); + titlePanel.setLayout(new BoxLayout(titlePanel, BoxLayout.X_AXIS)); + titlePanel.add(logoLabel); + titlePanel.add(titleLabel); + screenLabel = new JLabel("Libraries:"); + screenLabel.setFont(getScreenLabelFont()); + topPanel = new JPanel(new BorderLayout()); + topPanel.add(titlePanel, BorderLayout.CENTER); + topPanel.add(screenLabel, BorderLayout.PAGE_END); + list = new JList(LIBRARY_ENTRIES); + list.setFont(getListFont()); + list.setDropMode(DropMode.INSERT); + list.setDragEnabled(true); + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + list.setTransferHandler(new ListArrangeTransferHandler()); + openItem = new JMenuItem("Open"); + openItem.addActionListener((e) -> { + int i = list.getSelectedIndex(); + if (i >= 0) { + openLibrary(i, false); + } + }); + editItem = new JMenuItem("Edit"); + editItem.addActionListener((e) -> { + int i = list.getSelectedIndex(); + if (i >= 0) { + editLibrary(i); + } + }); + deleteItem = new JMenuItem("Delete"); + deleteItem.addActionListener((e) -> { + int i = list.getSelectedIndex(); + if (i >= 0) { + askDeleteLibrary(i); + } + }); + newItem = new JMenuItem("New"); + newItem.addActionListener((e) -> { + LibraryCreateDialog nld = new LibraryCreateDialog(this); + nld.setVisible(true); + if (nld.getResponse() == LibraryCreateDialog.RESPONSE_CREATE) { + createLibrary(nld.getLibraryName(), nld.getURL(), nld.getFTP(), + nld.getUsername(), nld.getPassword(), new File(nld.getLibraryFile())); + } + }); + importItem = new JMenuItem("Import"); + importItem.addActionListener((e) -> { + LibraryImportDialog lid = new LibraryImportDialog(this); + lid.setVisible(true); + if (lid.getResponse() == LibraryImportDialog.RESPONSE_IMPORT) { + importLibrary(lid.getURL(), lid.getFTP(), lid.getUsername(), lid.getPassword(), new File(lid.getFile())); + } + }); + listPopup = new JPopupMenu(); + listPopup.add(openItem); + listPopup.add(editItem); + listPopup.add(deleteItem); + listPopup.addSeparator(); + listPopup.add(newItem); + listPopup.add(importItem); + list.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() == 2) { + Rectangle r = list.getCellBounds(0, list.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + int i = list.locationToIndex(e.getPoint()); + openLibrary(i, false); + } + } else if (e.getButton() == MouseEvent.BUTTON3) { + Rectangle r = list.getCellBounds(0, list.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + int i = list.locationToIndex(e.getPoint()); + list.setSelectedIndex(i); + } + boolean enabled = list.getSelectedIndex() >= 0; + openItem.setEnabled(enabled); + deleteItem.setEnabled(enabled); + editItem.setEnabled(enabled); + listPopup.show(list, e.getX(), e.getY()); + } + } + }); + loadLibraryList(); + LIBRARY_ENTRIES.addListDataListener(new ListDataListener(){ + @Override + public void intervalAdded(ListDataEvent e) { + saveLibraryList(); + } + + @Override + public void intervalRemoved(ListDataEvent e) { + saveLibraryList(); + } + + @Override + public void contentsChanged(ListDataEvent e) { + saveLibraryList(); + } + }); + listScroll = new JScrollPane(list); + quitButton = new JButton("Quit"); + quitButton.setMnemonic(KeyEvent.VK_Q); + quitButton.addActionListener((e) -> { + System.exit(0); + }); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Libraries/Select Window")); + optionsButton = new JButton("Settings"); + optionsButton.setMnemonic(KeyEvent.VK_S); + optionsButton.addActionListener((e) -> { + CuratorOptionsDialog cod = new CuratorOptionsDialog(this); + cod.setVisible(true); + if (cod.getResponse() == CuratorOptionsDialog.RESPONSE_SAVE) { + cod.updateSettigs(); + SwingUtilities.updateComponentTreeUI(this); + SwingUtilities.updateComponentTreeUI(listPopup); + screenLabel.setFont(getScreenLabelFont()); + titleLabel.setFont(getTitleFont()); + list.setFont(getListFont()); + } + }); + importButton = new JButton("Import"); + importButton.setMnemonic(KeyEvent.VK_I); + importButton.addActionListener((e) -> { + LibraryImportDialog lid = new LibraryImportDialog(this); + lid.setVisible(true); + if (lid.getResponse() == LibraryImportDialog.RESPONSE_IMPORT) { + importLibrary(lid.getURL(), lid.getFTP(), lid.getUsername(), lid.getPassword(), new File(lid.getFile())); + } + }); + newButton = new JButton("New"); + newButton.setMnemonic(KeyEvent.VK_N); + newButton.addActionListener((e) -> { + LibraryCreateDialog nld = new LibraryCreateDialog(this); + nld.setVisible(true); + if (nld.getResponse() == LibraryCreateDialog.RESPONSE_CREATE) { + createLibrary(nld.getLibraryName(), nld.getURL(), nld.getFTP(), + nld.getUsername(), nld.getPassword(), new File(nld.getLibraryFile())); + } + }); + deleteButton = new JButton("Delete"); + deleteButton.setMnemonic(KeyEvent.VK_D); + deleteButton.setEnabled(false); + deleteButton.addActionListener((e) -> { + int i = list.getSelectedIndex(); + if (i >= 0) { + askDeleteLibrary(i); + } + }); + editButton = new JButton("Edit"); + editButton.setMnemonic(KeyEvent.VK_E); + editButton.setEnabled(false); + editButton.addActionListener((e) -> { + int i = list.getSelectedIndex(); + if (i >= 0) { + editLibrary(i); + } + }); + openButton = new JButton("Open"); + openButton.setMnemonic(KeyEvent.VK_O); + openButton.setEnabled(false); + openButton.addActionListener((e) -> { + int i = list.getSelectedIndex(); + if (i >= 0) { + openLibrary(i, false); + } + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(quitButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(optionsButton); + buttonPanel.add(importButton); + buttonPanel.add(newButton); + buttonPanel.add(deleteButton); + buttonPanel.add(editButton); + buttonPanel.add(openButton); + list.addListSelectionListener((e) -> { + boolean enabled = list.getSelectedIndex() >= 0; + openButton.setEnabled(enabled); + deleteButton.setEnabled(enabled); + editButton.setEnabled(enabled); + }); + content = new JPanel(new BorderLayout()); + content.add(topPanel, BorderLayout.PAGE_START); + content.add(listScroll, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + addComponentListener(new ComponentAdapter() { + @Override + public void componentMoved(ComponentEvent e) { + updateWindowCache(); + } + + @Override + public void componentResized(ComponentEvent e) { + updateWindowCache(); + } + }); + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + updateWindowCache(); + System.exit(0); + } + }); + setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + handleAutoOpenOrShow(args); + } + + private Font getScreenLabelFont() { + return UIUtils.scaleFont(UIManager.getFont("Label.font"), 2.0f); + } + + private Font getListFont() { + return UIUtils.scaleFont(UIManager.getFont("List.font"), 2.0f); + } + + private Font getTitleFont() { + return UIUtils.scaleFont(UIManager.getFont("Label.font"), 5.0f); + } + + private void handleAutoOpenOrShow(String[] args) { + if (args.length != 0 && args[0].equals("-i")) { + handleImport(args, false, false); + } else if (args.length != 0 && args[0].equals("-s")) { + handleImport(args, true, false); + } else if (DEFAULT_LIBRARY.equals(CuratorOptionsDialog.LAST_DEFAULT_LIBRARY) && LAST_LIBRARY != null) { + openLibrary(LAST_LIBRARY, true); + } else if (DEFAULT_LIBRARY instanceof LibraryEntry) { + openLibrary((LibraryEntry) DEFAULT_LIBRARY, true); + } else { + setVisible(true); + } + } + + private void handleImport(String[] args, boolean reorder, boolean forceAsk) { + if (LIBRARY_ENTRIES.size() == 0) { + JOptionPane.showMessageDialog(null, "No libraries found! Please open Curator normally and add a library.", + "Import Error", JOptionPane.ERROR_MESSAGE); + System.exit(1); + } else if (args.length == 1) { + JOptionPane.showMessageDialog(null, "No files for importing provided!", "Import Error", JOptionPane.ERROR_MESSAGE); + System.exit(1); + } + String[] files = Arrays.copyOfRange(args, 1, args.length); + for (String file : files) { + LOGGER.info("File to import: '{}'", file); + } + LibraryEntry en; + if (forceAsk) { + ImportLibrarySelectDialog ilsd = new ImportLibrarySelectDialog(LIBRARY_ENTRIES); + ilsd.setVisible(true); + en = ilsd.getSelectedLibrary(); + } else if (DEFAULT_LIBRARY.equals(CuratorOptionsDialog.LAST_DEFAULT_LIBRARY) && LAST_LIBRARY != null) { + en = LAST_LIBRARY; + } else if (DEFAULT_LIBRARY instanceof LibraryEntry) { + en = (LibraryEntry) DEFAULT_LIBRARY; + } else { + ImportLibrarySelectDialog ilsd = new ImportLibrarySelectDialog(LIBRARY_ENTRIES); + ilsd.setVisible(true); + en = ilsd.getSelectedLibrary(); + } + Library lib = syncLibrary(en); + if (lib == null) { + SwingUtilities.invokeLater(() -> handleImport(args, reorder, true)); + return; + } + LibraryFrame lf = new LibraryFrame(lib, this, en.name); + if (lf.wasCanceled()) { + SwingUtilities.invokeLater(() -> handleImport(args, reorder, true)); + return; + } + while (!lf.handleImports(files, reorder)) { + ImportLibrarySelectDialog ilsd = new ImportLibrarySelectDialog(LIBRARY_ENTRIES); + ilsd.setVisible(true); + en = ilsd.getSelectedLibrary(); + lib = syncLibrary(en); + if (lib == null) { + SwingUtilities.invokeLater(() -> handleImport(args, reorder, true)); + return; + } + lf = new LibraryFrame(lib, this, en.name); + if (lf.wasCanceled()) { + SwingUtilities.invokeLater(() -> handleImport(args, reorder, true)); + return; + } + } + } + + private void clearLibraryCache(LibraryEntry en) { + try { + if (en.file.exists()) { + recursiveDelete(en.file); + } + } catch (IOException e) { + LOGGER.error("Could not clear library cache!", e); + ErrorDialog ed = new ErrorDialog("Library Error", "Could not clear library cache!", e); + ed.setVisible(true); + } + } + + private void editLibrary(int index) { + editLibrary(LIBRARY_ENTRIES.get(index)); + } + + private String formatLibraryUrl(String url) { + if (!url.matches("^https?\\:\\/\\/.*")) { + return "https://" + url; + } + return url; + } + + private void editLibrary(LibraryEntry en) { + Secrets s = SECRETS_FACTORY.createSecrets(SECRETS_KEY + en.name); + LibraryEditDialog led = new LibraryEditDialog(this, en.name, en.url, en.ftp, + s.getUsername(), s.getPassword(), en.file.getPath(), + () -> clearLibraryCache(en)); + led.setVisible(true); + if (led.getResponse() == LibraryEditDialog.RESPONSE_SAVE) { + String testName = ""; + String testPath = ""; + if (!led.getLibraryName().equals(en.name)) { + testName = led.getName(); + } + if (!led.getLibraryFile().equals(en.file.getPath())) { + testPath = led.getLibraryFile(); + } + if (canCreateLibrary(testName, testPath)) { + try { + if (!led.getLibraryName().equals(en.name)) { + Secrets ns = SECRETS_FACTORY.createSecrets(SECRETS_KEY + led.getLibraryName()); + ns.setAll(s.getKeys()); + s.delete(); + } else { + s.setUsername(led.getUsername()); + s.setPassword(led.getPassword()); + } + File nf = new File(led.getLibraryFile()); + if (en.file.exists() && !en.file.renameTo(nf)) { + throw new IOException("Could not move library to: '" + nf.getPath() + "'"); + } + en.name = led.getLibraryName(); + en.url = formatLibraryUrl(led.getURL()); + en.ftp = led.getFTP(); + en.file = nf; + saveLibraryList(); + list.repaint(); + } catch (IOException e) { + LOGGER.error("Could not move library", e); + ErrorDialog ed = new ErrorDialog("Library Error", "Could not move library", e); + ed.setVisible(true); + } + } else { + Exception e = new Exception("A library with that name or path already exists!"); + LOGGER.error("Library had a conflict!", e ); + ErrorDialog ed = new ErrorDialog("Library Error", "A library with that name or path already exists!", e); + ed.setVisible(true); + } + } + } + + private void openLibrary(int index, boolean isAuto) { + openLibrary(LIBRARY_ENTRIES.get(index), isAuto); + } + + private void openLibrary(LibraryEntry en, boolean isAuto) { + Library lib = syncLibrary(en); + if (lib != null) { + updateWindowCache(); + setVisible(false); + LibraryFrame lf = new LibraryFrame(lib, this, en.name); + if (lf.wasCanceled()) { + setVisible(true); + } else { + lf.setVisible(true); + } + } else if (isAuto) { + setVisible(true); + } + } + + private Library syncLibrary(LibraryEntry en) { + LAST_LIBRARY = en; + settingsPutObject("last-library", en); + Secrets s = SECRETS_FACTORY.createSecrets(SECRETS_KEY + en.name); + try { + Library lib = new Library(en.file, new URI(en.ftp), s); + LibrarySyncDialog lsd = new LibrarySyncDialog(null, lib); + if (lsd.sync() == LibrarySyncDialog.STATUS_OK) { + return lib; + } + } catch (URISyntaxException e) { + LOGGER.error("Bad URL: '{}'", en.ftp, e); + ErrorDialog ed = new ErrorDialog("Library Error", "The url '" + en.ftp + "' is malformed!", e); + ed.setVisible(true); + } catch (IOException | ManifestParseException e) { + LOGGER.error("Could not open library!", e); + ErrorDialog ed = new ErrorDialog("Library Error", "An internal error occurend! Could not open library.", e); + ed.setVisible(true); + } + return null; + } + + public String getNameFromLibraryConfigFile(String path) { + JSONParser p = new JSONParser(); + try { + FileReader in = new FileReader(path); + JSONObject root = (JSONObject) p.parse(in); + in.close(); + return (String) root.get("library_name"); + } catch (IOException e) { + LOGGER.error("Could not read library config: '{}'", e); + ErrorDialog ed = new ErrorDialog("Import Error", "Could not read library config file!", e); + ed.setVisible(true); + } catch (ParseException | ClassCastException e) { + LOGGER.error("Library config invalid: '{}'", e); + ErrorDialog ed = new ErrorDialog("Import Error", "Library config file was invalid!", e); + ed.setVisible(true); + } + return null; + } + + public void promptWebOpen(Component source) { + if (LAST_LIBRARY != null && !LAST_LIBRARY.url.isBlank()) { + int opt = LIBRARY_WEB_HANDLER; + if (opt == LIBRARY_WEB_ASK) { + Window sw; + if (source == null) { + sw = null; + } else { + sw = SwingUtilities.getWindowAncestor(source); + } + RememberConfirmDialog diag = new RememberConfirmDialog(sw, "Confirm Open", + "Upload complete! Would you like " + + "to open this library's website: \"" + + LAST_LIBRARY.url + "\"?"); + diag.setVisible(true); + if (diag.getResponse() == RememberConfirmDialog.RESPONSE_NO) { + opt = LIBRARY_WEB_DONT_OPEN; + } else { + opt = LIBRARY_WEB_OPEN; + } + if (diag.rememberAwnser()) { + updateWebOpenHandler(opt); + } + } + if (opt == LIBRARY_WEB_OPEN) { + Desktop d = Desktop.getDesktop(); + try { + d.browse(new URI(LAST_LIBRARY.url)); + } catch (Exception e) { + LOGGER.warn("Could not open URL: \"{}\"", LAST_LIBRARY.url, e); + ErrorDialog ed = new ErrorDialog("Could not open URL", "Could not open library with URL: " + + "\"" + LAST_LIBRARY.url + "\"!", e); + ed.setVisible(true); + } + } + } + } + + private void importLibrary(String url, String ftp, String username, String password, File dir) { + File cf = null; + try { + cf = dir.getCanonicalFile(); + } catch (IOException e) { + LOGGER.error("Could not resolve file path: '{}'", dir.getPath(), e); + ErrorDialog ed = new ErrorDialog("Import Error", "An internal error has occured, please try again.", e); + ed.setVisible(true); + return; + } + String name = getNameFromLibraryConfigFile(cf.getPath() + "/config.json"); + if (!canCreateLibrary(name, cf.getPath())) { + JOptionPane.showMessageDialog(this, "A library with that path already exists.", "Library Error", JOptionPane.ERROR_MESSAGE); + return; + } + Secrets s = SECRETS_FACTORY.createSecrets(SECRETS_KEY + name); + s.setUsername(username); + s.setPassword(password); + LibraryEntry en = new LibraryEntry(name, url, ftp, cf); + LIBRARY_ENTRIES.add(0, en); + openLibrary(en, false); + } + + private void askDeleteLibrary(int index) { + LibraryEntry en = LIBRARY_ENTRIES.get(index); + LibraryDeleteDialog ldd = new LibraryDeleteDialog(this, en.name); + ldd.setVisible(true); + if (ldd.getResponse() == LibraryDeleteDialog.RESPONSE_DELETE) { + deleteLibrary(index, ldd.isDeleteFromDisk()); + } + } + + private void recursiveDelete(File f) throws IOException { + if (f.isDirectory()) { + for (File c : f.listFiles()) { + recursiveDelete(c); + } + } + if (!f.delete()) { + throw new IOException("Could not delete file: '" + f.getPath() + "'"); + } + } + + private void deleteLibrary(int index, boolean disk) { + LibraryEntry en = LIBRARY_ENTRIES.get(index); + if (disk) { + try { + if (en.file.exists()) { + recursiveDelete(en.file); + } + } catch (IOException e) { + LOGGER.error("Could not delete file: '{}'", en.file.getPath(), e); + ErrorDialog ed = new ErrorDialog("Library Deletion Error", "Could not delete library '" + en.name + "'!", e); + ed.setVisible(true); + return; + } + } + Secrets s = SECRETS_FACTORY.createSecrets(SECRETS_KEY + en.name); + s.delete(); + if (LAST_LIBRARY.equals(en)) { + settingsPutObject("last-library", null); + LAST_LIBRARY = null; + } + if (DEFAULT_LIBRARY.equals(en)) { + updateDefaultLibrarySelection(CuratorOptionsDialog.NO_DEFAULT_LIBRARY); + } + LIBRARY_ENTRIES.remove(index); + } + + private boolean canCreateLibrary(String name, String path) { + int max = LIBRARY_ENTRIES.getSize(); + for (int i = 0; i < max; ++i) { + LibraryEntry e = LIBRARY_ENTRIES.get(i); + if (e.name.equals(name) || e.file.getPath().equals(path)) { + return false; + } + } + return true; + } + + private void createLibrary(String name, String url, String ftp, String username, String password, File file) { + File cf = null; + try { + cf = file.getCanonicalFile(); + } catch (IOException e) { + LOGGER.error("Could not query pathname for file: '{}'", file.getPath()); + ErrorDialog ed = new ErrorDialog("Library Error", "An internal error occured. Please try again.", e); + ed.setVisible(true); + return; + } + if (!canCreateLibrary(name, cf.getPath())) { + JOptionPane.showMessageDialog(this, "A library with that name or path already exists.", "Library Error", JOptionPane.ERROR_MESSAGE); + return; + } + File configFile = new File(cf.getPath() + "/config.json"); + if (configFile.exists()) { + LibraryExistsDialog led = new LibraryExistsDialog(this); + led.setVisible(true); + try { + switch (led.getResponse()) { + case LibraryExistsDialog.RESPONSE_DELETE: + recursiveDelete(cf); + break; + case LibraryExistsDialog.RESPONSE_IMPORT: + importLibrary(url, ftp, username, password, file); + return; + default: + return; + } + } catch (IOException e) { + LOGGER.error("Could not delete or import library!", e); + ErrorDialog ed = new ErrorDialog("Internal Error", "An internal error has occured! Please try again.", e); + ed.setVisible(true); + return; + } + } else if (cf.exists()) { + int c = JOptionPane.showConfirmDialog(this, "A non-library file already exists here. Delete it?", "File Exists", + JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + if (c == JOptionPane.YES_OPTION) { + try { + if (!cf.delete()) { + throw new IOException("Could not delete file: '" + cf.getPath() + "'"); + } + } catch (IOException e) { + LOGGER.error("Could not delete library!", e); + ErrorDialog ed = new ErrorDialog("Internal Error", "An internal error has occured! Please try again.", e); + ed.setVisible(true); + return; + } + } + } + Secrets s = SECRETS_FACTORY.createSecrets(SECRETS_KEY + name); + s.setUsername(username); + s.setPassword(password); + LibraryEntry en = new LibraryEntry(name, url, ftp, cf); + LIBRARY_ENTRIES.add(0, en); + openLibrary(en, false); + } + + private static Object loadDefaultLibrarySelection() { + return settingsGetObject("default-library", CuratorOptionsDialog.NO_DEFAULT_LIBRARY); + } + + public static void updateDefaultLibrarySelection(Object def) { + DEFAULT_LIBRARY = def; + settingsPutObject("default-library", def); + } + + private static int loadLibraryWebHandler() { + return CURATOR_SETTINGS.getInt("web-open", LIBRARY_WEB_ASK); + } + + public static void updateWebOpenHandler(int def) { + LIBRARY_WEB_HANDLER = def; + CURATOR_SETTINGS.putInt("web-open", def); + } + + private static boolean loadAskToBack() { + return CURATOR_SETTINGS.getBoolean("ask-to-back", true); + } + + public static void updateAskToBack(boolean def) { + ASK_TO_RETURN_TO_LIBRARY_SELECT = def; + CURATOR_SETTINGS.putBoolean("ask-to-back", def); + } + + private void loadLibraryList() { + LibraryEntry[] ents = settingsGetObject("entries", new LibraryEntry[0]); + LIBRARY_ENTRIES.addAll(Arrays.asList(ents)); + } + + private void saveLibraryList() { + LibraryEntry[] ents = new LibraryEntry[LIBRARY_ENTRIES.getSize()]; + for (int i = 0; i < ents.length; ++i) { + ents[i] = LIBRARY_ENTRIES.get(i); + } + settingsPutObject("entries", ents); + } + + private void readWindowCache() { + int cw = LSF_CACHE.getInt("width", -1); + int ch = LSF_CACHE.getInt("height", -1); + if (cw < 0 || ch < 0) { + int w = UIUtils.SCREEN_SIZE.width / 2; + int h = UIUtils.SCREEN_SIZE.height / 2; + setSize(w, h); + LSF_CACHE.putInt("width", w); + LSF_CACHE.putInt("height", h); + } else { + setSize(cw, ch); + } + int cx = LSF_CACHE.getInt("x", -1); + int cy = LSF_CACHE.getInt("y", -1); + if (cx < 0 || cy < 0) { + UIUtils.centerWindow(this); + LSF_CACHE.putInt("x", getX()); + LSF_CACHE.putInt("y", getY()); + } else { + setLocation(cx, cy); + } + setExtendedState(LSF_CACHE.getInt("extended", NORMAL)); + } + + private void updateWindowCache() { + LSF_CACHE.putInt("x", Math.max(0, getX())); + LSF_CACHE.putInt("y", Math.max(0, getY())); + LSF_CACHE.putInt("width", getWidth()); + LSF_CACHE.putInt("height", getHeight()); + int es = getExtendedState(); + if (es != ICONIFIED) { + LSF_CACHE.putInt("extended", es); + } + } + + private void loadResources() { + try { + int ld = UIUtils.getLargerScreenDimension(); + logoIcon = new ImageIcon(UIUtils.loadBufferedImage(ClassLoader.getSystemResource("logo.svg"), -1, + Math.round(ld * 0.07f))); + } catch (IOException e) { + LOGGER.error("Could not load logo from resource", e); + ErrorDialog ed = new ErrorDialog("Internal Error", "An internal error has occurend, please try again.", e); + ed.setVisible(true); + } + } + + private static String getDefaultLibraryPath() { + String xdg = System.getenv("XDG_CONFIG_HOME"); + String back; + if (xdg == null) { + back = System.getProperty("user.home") + "/.config/curator"; + } else { + back = xdg + "/curator"; + } + String path = CURATOR_SETTINGS.get("library-dir", back); + File file = new File(path); + try { + File cf = file.getCanonicalFile(); + if (!file.exists()) { + File pf = cf.getParentFile(); + pf.mkdirs(); + if (!cf.mkdir()) { + throw new IOException("Could not create library store: '" + cf.getPath() + "'"); + } + } + return cf.getPath(); + } catch (IOException e) { + LOGGER.error("Could not create library store", e); + ErrorDialog ed = new ErrorDialog("Fatal Error", "Could not create library store!", e); + ed.setVisible(true); + System.exit(0); + return null; + } + } + + public static boolean updateLibraryPath(String path) { + File file = new File(path); + File cf = null; + try { + if (!file.exists()) { + cf = file.getCanonicalFile(); + File pf = cf.getParentFile(); + pf.mkdirs(); + if (!cf.mkdir()) { + throw new IOException("Could not create library store: '" + cf.getPath() + "'"); + } + } + if (path.length() > 1 && path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + DEFAULT_LIBRARY_PATH = path; + CURATOR_SETTINGS.put("library-dir", DEFAULT_LIBRARY_PATH); + return true; + } catch (IOException e) { + LOGGER.error("Could not create library store", e); + ErrorDialog ed = new ErrorDialog("Fatal Error", "Could not create library store!", e); + ed.setVisible(true); + return false; + } + } + + private static SecretsFactory loadSecretsFactory() { + return settingsGetObject("secrets-backend", SecretsFactory.getDefaultFactory()); + } + + public static boolean updateSecretsFactory(SecretsFactory sf) { + SecretsFactory osf = SECRETS_FACTORY; + SECRETS_FACTORY = sf; + settingsPutObject("secrets-backend", SECRETS_FACTORY); + int max = LIBRARY_ENTRIES.size(); + for (int i = 0; i < max; ++i) { + LibraryEntry e = LIBRARY_ENTRIES.get(i); + Secrets o = osf.createSecrets(SECRETS_KEY + e.name); + Secrets n = sf.createSecrets(SECRETS_KEY + e.name); + n.setAll(o.getKeys()); + o.delete(); + } + return true; + } + + public static boolean updateLookAndFeel(String name) { + if (loadLookAndFeel(name, false)) { + CURATOR_SETTINGS.put("laf", name); + return true; + } + return false; + } + + private static void loadInitialLookAndFeel() { + if (!loadLookAndFeel(CURATOR_SETTINGS.get("laf", UIUtils.SYSTEM_LAF_NAME), true)) { + System.exit(0); + } + } + + private static boolean loadLookAndFeel(String laf, boolean fallback) { + if (laf.length() != 0) { + try { + UIManager.setLookAndFeel(UIManager.createLookAndFeel(laf)); + LOOK_AND_FEEL = laf; + return true; + } catch (UnsupportedLookAndFeelException e) { + LOGGER.error("Could not load look and feel '{}'", laf); + ErrorDialog ed = new ErrorDialog("Fatal Error", "Could not load theme '" + laf + "'.", e); + ed.setVisible(true); + if (!fallback) { + return false; + } + } + } + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + LOOK_AND_FEEL = UIUtils.SYSTEM_LAF_NAME; + return true; + } catch (UnsupportedLookAndFeelException | ClassNotFoundException | InstantiationException | + IllegalAccessException e) { + LOGGER.error("Could not load system look and feel"); + ErrorDialog ed = new ErrorDialog("Fatal Error", "Could not load system default theme.", e); + ed.setVisible(true); + return false; + } + } + + public static void settingsPutObject(String key, Object o) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + ObjectOutputStream out = new ObjectOutputStream(baos); + out.writeObject(o); + out.close(); + } catch (IOException e) { + // ignore + } + CURATOR_SETTINGS.putByteArray(key, baos.toByteArray()); + } + + @SuppressWarnings("unchecked") + public static T settingsGetObject(String key, T def) { + byte[] baDef = new byte[0]; + byte[] baVal = CURATOR_SETTINGS.getByteArray(key, baDef); + if (baVal == baDef) { + return def; + } + ByteArrayInputStream bais = new ByteArrayInputStream(baVal); + try { + ObjectInputStream in = new ObjectInputStream(bais); + Object o = in.readObject(); + in.close(); + return (T) o; + } catch (IOException | ClassCastException | ClassNotFoundException e) { + return def; + } + } + +} diff --git a/src/main/java/zander/ui/library/LibrarySyncDialog.java b/src/main/java/zander/ui/library/LibrarySyncDialog.java new file mode 100644 index 0000000..53b28c5 --- /dev/null +++ b/src/main/java/zander/ui/library/LibrarySyncDialog.java @@ -0,0 +1,133 @@ +package zander.ui.library; + +import java.awt.BorderLayout; +import java.awt.Window; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.SwingUtilities; + +import zander.library.Library; +import zander.library.Library.SyncStatus; +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; + +public class LibrarySyncDialog extends JDialog { + private static final long serialVersionUID = -7042632100445849219L; + + public static final int STATUS_OK = 0; + public static final int STATUS_CANCELED = 1; + public static final int STATUS_FAILED = 2; + + private final Library library; + + private JLabel messageLabel; + private JProgressBar progressBar; + private JTextArea statusArea; + private JScrollPane statusScroll; + private JPanel statusPanel; + private JButton cancelButton; + private JPanel buttonPanel; + private final JPanel content; + + private SyncStatus status; + + private String lastNote = ""; + + public LibrarySyncDialog(Window parent, Library library) { + super(parent, "Sync Library"); + this.library = library; + messageLabel = new JLabel("Syncing Library..."); + progressBar = new JProgressBar(0, 1); + progressBar.setValue(0); + progressBar.setStringPainted(true); + statusArea = new JTextArea(10, 15); + statusArea.setEditable(false); + statusScroll = new JScrollPane(statusArea); + statusPanel = new JPanel(); + statusPanel.setLayout(new BoxLayout(statusPanel, BoxLayout.Y_AXIS)); + statusPanel.add(progressBar); + statusPanel.add(statusScroll); + cancelButton = new JButton("Cancel"); + cancelButton.addActionListener((e) -> { + if (status != null && status.isStarted() && !status.isDone()) { + status.cancel(); + } + dispose(); + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(cancelButton); + content = new JPanel(new BorderLayout()); + content.add(messageLabel, BorderLayout.PAGE_START); + content.add(statusPanel, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + setMinimumSize(getPreferredSize()); + UIUtils.centerWindow(this, parent); + } + + public Library getLibrary() { + return library; + } + + private void addMessage(String m) { + String t = statusArea.getText(); + if (t.isBlank()) { + statusArea.setText(m); + } else { + statusArea.setText(t + "\n" + m); + } + } + + private void syncUpdateAction(SyncStatus status) { + if (!SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(() -> syncUpdateAction(status)); + } else { + if (status.isFailed()) { + dispose(); + ErrorDialog ed = new ErrorDialog("Sync Failed", "Failed to sync local cahce to remote store!", status.getError()); + ed.setVisible(true); + } else if (status.isDone()) { + dispose(); + } else { + if (status.getTotal() < 0) { + progressBar.setIndeterminate(true); + } else { + progressBar.setIndeterminate(false); + progressBar.setMaximum(status.getTotal()); + } + progressBar.setValue(status.getCurrent()); + String note = status.getNote(); + if (note != null && !note.isBlank() && !note.equals(lastNote)) { + addMessage(note); + lastNote = note; + } + } + } + } + + public int sync() { + status = library.syncLocalToRemoteAsync(this::syncUpdateAction); + setVisible(true); + dispose(); + if (status.isFailed()) { + return STATUS_FAILED; + } else if (status.isCanceled()) { + return STATUS_CANCELED; + } + return STATUS_OK; + } + +} diff --git a/src/main/java/zander/ui/media/MediaManagePanel.java b/src/main/java/zander/ui/media/MediaManagePanel.java new file mode 100644 index 0000000..22f919a --- /dev/null +++ b/src/main/java/zander/ui/media/MediaManagePanel.java @@ -0,0 +1,747 @@ +package zander.ui.media; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Rectangle; +import java.awt.Window; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import javax.swing.BoxLayout; +import javax.swing.DefaultListModel; +import javax.swing.DropMode; +import javax.swing.JButton; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.TransferHandler; +import javax.swing.UIManager; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.filechooser.FileNameExtensionFilter; + +import com.drew.imaging.ImageProcessingException; + +import org.apache.tika.Tika; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import zander.library.LibraryMediaCache; +import zander.library.media.FFmpeg; +import zander.library.media.MediaData; +import zander.library.media.MediaLoader; +import zander.library.metadata.MediaMetadata; +import zander.library.metadata.MetadataLoader; +import zander.library.state.AlbumState; +import zander.library.state.LibraryState; +import zander.library.state.MediaState; +import zander.ui.ErrorDialog; +import zander.ui.LibraryFrame; +import zander.ui.LibraryUploadDialog; +import zander.ui.ProgressTracker; +import zander.ui.ProgressTracker.ProgressManager; +import zander.ui.UIUtils; +import zander.ui.cellrenderer.MediaCellRenderer; +import zander.ui.docs.DocumentationViewer; +import zander.ui.transferhandlers.ListArrangeTransferHandler; + +public class MediaManagePanel extends JPanel { + private static final long serialVersionUID = 2125609797237528074L; + private static final Logger LOGGER = LoggerFactory.getLogger(MediaManagePanel.class); + private static final Tika TIKA = new Tika(); + + private final LibraryState library; + private final AlbumState album; + private final LibraryMediaCache cache; + + private final JPopupMenu popupMenu; + private final JMenuItem deleteItem; + private final JMenuItem exportItem; + private final JMenuItem addItem; + private final JMenuItem editItem; + private final JButton backButton; + private final JLabel searchLabel; + private final JTextField searchField; + private final JButton uploadButton; + private final JButton helpButton; + private final JButton setThumbnailButton; + private final JButton deleteButton; + private final JButton exportButton; + private final JButton addButton; + private final JButton editButton; + private final JPanel buttonPanel; + private final JLabel screenLabel; + private final JPanel topPanel; + private final DefaultListModel mediaModel; + private final TransferHandler mediaTransferHandler; + private final JList mediaList; + private final JScrollPane mediaScroll; + private final MediaPropertyPanel mediaPanel; + private final JLabel multipleMediaLabel; + private final JLabel noMediaLabel; + private final JSplitPane splitPane; + + private int lastClicked = -1; + private Component currentComp; + private boolean canceled = false; + private boolean firstToggle = true; + + public MediaManagePanel(LibraryFrame parent, AlbumState album) { + super(new BorderLayout()); + library = parent.getLibraryState(); + this.album = album; + this.cache = parent.getMediaCache(); + popupMenu = new JPopupMenu(); + deleteItem = new JMenuItem("Delete"); + deleteItem.setEnabled(false); + deleteItem.addActionListener((e) -> deleteMediaHandler()); + exportItem = new JMenuItem("Export"); + exportItem.setEnabled(false); + exportItem.addActionListener((e) -> exportMediaHandler()); + addItem = new JMenuItem("Add"); + addItem.addActionListener((e) -> addMedia()); + editItem = new JMenuItem("Toggle Property Panel"); + editItem.addActionListener((e) -> togglePropertyPanel()); + popupMenu.add(deleteItem); + popupMenu.addSeparator(); + popupMenu.add(exportItem); + popupMenu.add(addItem); + popupMenu.add(editItem); + backButton = new JButton("Back to Albums"); + backButton.setMnemonic(KeyEvent.VK_B); + backButton.addActionListener((e) -> parent.backButtonHandler()); + searchLabel = new JLabel("Search:"); + searchField = new JTextField(); + searchField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + @Override + public void removeUpdate(DocumentEvent e) { update(); } + @Override + public void changedUpdate(DocumentEvent e) { update(); } + + private void update() { + doSearch(searchField.getText()); + } + }); + uploadButton = new JButton("Upload"); + uploadButton.setMnemonic(KeyEvent.VK_U); + uploadButton.addActionListener((e) -> { + LibraryUploadDialog diag = new LibraryUploadDialog(SwingUtilities.getWindowAncestor(this)); + if (diag.upload(library, cache)) { + parent.handleUploadDone(this); + } + }); + helpButton = new JButton("?"); + helpButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (currentComp == splitPane) { + DocumentationViewer.show(MediaManagePanel.this, "Media/Media Properties"); + } else { + DocumentationViewer.show(MediaManagePanel.this, "Media/Managing Media"); + } + } + }); + setThumbnailButton = new JButton("Set As Thumbnail"); + setThumbnailButton.setMnemonic(KeyEvent.VK_T); + setThumbnailButton.setEnabled(false); + setThumbnailButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + MediaState tm = mediaList.getSelectedValue(); + if (tm != null) { + album.setThumbnailName(tm.getName()); + mediaPanel.updateIsThumbnail(); + } + repaint(); + } + }); + deleteButton = new JButton("Delete"); + deleteButton.addActionListener((e) -> deleteMediaHandler()); + deleteButton.setEnabled(false); + deleteButton.setMnemonic(KeyEvent.VK_D); + exportButton = new JButton("Export"); + exportButton.addActionListener((e) -> exportMediaHandler()); + exportButton.setEnabled(false); + exportButton.setMnemonic(KeyEvent.VK_E); + addButton = new JButton("Add"); + addButton.addActionListener((e) -> addMedia()); + addButton.setMnemonic(KeyEvent.VK_A); + editButton = new JButton("Toggle Property Panel"); + editButton.setMnemonic(KeyEvent.VK_O); + editButton.addActionListener((e) -> togglePropertyPanel()); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(backButton); + buttonPanel.add(searchLabel); + buttonPanel.add(searchField); + buttonPanel.add(uploadButton); + buttonPanel.add(helpButton); + buttonPanel.add(setThumbnailButton); + buttonPanel.add(deleteButton); + buttonPanel.add(exportButton); + buttonPanel.add(addButton); + buttonPanel.add(editButton); + screenLabel = new JLabel(album.getName(), SwingConstants.CENTER); + screenLabel.setFont(UIUtils.scaleFont(UIManager.getFont("Label.font"), 2.0f)); + topPanel = new JPanel(new BorderLayout()); + topPanel.add(buttonPanel, BorderLayout.CENTER); + topPanel.add(screenLabel, BorderLayout.PAGE_END); + mediaModel = new DefaultListModel(); + mediaTransferHandler = new ListArrangeTransferHandler(new Runnable() { + @Override + public void run() { + mediaPanel.updateName(); + } + }, () -> { + album.getMedia().clear(); + for (int i = 0; i< mediaModel.size(); ++i) { + album.getMedia().add(mediaModel.get(i)); + } + }); + mediaList = new JList(mediaModel); + mediaList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() == MouseEvent.BUTTON1) { + Rectangle r = mediaList.getCellBounds(0, mediaList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + int i = mediaList.locationToIndex(e.getPoint()); + lastClicked = i; + } + } else if (e.getButton() == MouseEvent.BUTTON3) { + Rectangle r = mediaList.getCellBounds(0, mediaList.getLastVisibleIndex()); + if (r != null && r.contains(e.getPoint())) { + int i = mediaList.locationToIndex(e.getPoint()); + if ((e.getModifiersEx() & InputEvent.CTRL_DOWN_MASK) == InputEvent.CTRL_DOWN_MASK) { + mediaList.addSelectionInterval(i, i); + } else if (lastClicked != -1 + && (e.getModifiersEx() & InputEvent.SHIFT_DOWN_MASK) == InputEvent.SHIFT_DOWN_MASK) { + mediaList.setSelectionInterval(lastClicked, i); + } else if (!mediaList.isSelectedIndex(i)) { + mediaList.setSelectedIndex(i); + } + } + int numSel = mediaList.getSelectedIndices().length; + boolean enabled = numSel > 0; + deleteItem.setEnabled(enabled); + exportItem.setEnabled(enabled); + popupMenu.show(mediaList, e.getX(), e.getY()); + } + } + }); + mediaList.addListSelectionListener((e) -> refreshMedia()); + mediaList.setTransferHandler(mediaTransferHandler); + mediaList.setLayoutOrientation(JList.HORIZONTAL_WRAP); + mediaList.setVisibleRowCount(-1); + mediaList.setDragEnabled(true); + mediaList.setDropMode(DropMode.INSERT); + mediaList.setCellRenderer(new MediaCellRenderer(cache, album)); + mediaScroll = new JScrollPane(mediaList); + mediaPanel = new MediaPropertyPanel(cache, album); + multipleMediaLabel = new JLabel("Multiple items selected.", JLabel.CENTER); + noMediaLabel = new JLabel("No items selected.", JLabel.CENTER); + splitPane = new JSplitPane(); + splitPane.setOneTouchExpandable(false); + splitPane.setRightComponent(noMediaLabel); + add(topPanel, BorderLayout.PAGE_START); + add(mediaScroll, BorderLayout.CENTER); + currentComp = mediaScroll; + loadMedia(); + } + + private void loadMedia() { + ProgressTracker pt = new ProgressTracker(null, "Loading Media", "Loading media...", (t) -> { + t.setMinimum(0); + t.setMaximum(album.getMedia().size()); + t.setIndeterminate(false); + for (MediaState media : album.getMedia()) { + cache.getMedia(album.getName(), media.getName(), t); + mediaModel.addElement(media); + t.setProgress(t.getProgress() + 1); + if (t.isCanceled()) { + canceled = true; + break; + } + } + }); + pt.start(); + } + + public boolean wasCanceled() { + return canceled; + } + + private void togglePropertyPanel() { + if (firstToggle) { + splitPane.setDividerLocation((int) (getWidth() / 2)); + firstToggle = false; + } + if (currentComp == splitPane) { + remove(currentComp); + add(mediaScroll, BorderLayout.CENTER); + currentComp = mediaScroll; + } else { + int div = splitPane.getDividerLocation(); + remove(currentComp); + add(splitPane, BorderLayout.CENTER); + currentComp = splitPane; + splitPane.setLeftComponent(mediaScroll); + splitPane.setDividerLocation(div); + } + revalidate(); + } + + private void refreshMedia() { + int numSel = mediaList.getSelectedIndices().length; + if (numSel == 0) { + if (splitPane.getRightComponent() != mediaPanel) { + int div = splitPane.getDividerLocation(); + splitPane.setRightComponent(noMediaLabel); + splitPane.setDividerLocation(div); + } + } else if (numSel == 1) { + if (splitPane.getRightComponent() != mediaPanel) { + int div = splitPane.getDividerLocation(); + splitPane.setRightComponent(mediaPanel); + splitPane.setDividerLocation(div); + } + mediaPanel.setCurrentMedia(mediaList.getSelectedValue()); + } else { + if (splitPane.getRightComponent() != multipleMediaLabel) { + int div = splitPane.getDividerLocation(); + splitPane.setRightComponent(multipleMediaLabel); + splitPane.setDividerLocation(div); + } + } + revalidate(); + boolean enabled = numSel != 0; + deleteButton.setEnabled(enabled); + exportButton.setEnabled(enabled); + setThumbnailButton.setEnabled(numSel == 1); + } + + public LibraryState getLibraryState() { + return library; + } + + public AlbumState getAlbum() { + return album; + } + + private void deleteMedia(MediaState media) { + album.getMedia().remove(media); + mediaModel.removeElement(media); + cache.removeMedia(album.getName(), media.getName()); + } + + private void deleteMediaHandler() { + List ma = mediaList.getSelectedValuesList(); + int response; + if (ma.size() == 1) { + response = JOptionPane.showConfirmDialog(this, "Are you sure you would like to delete '" + ma.get(0).getName() + "'?", + "Confirm Delete", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + } else { + response = JOptionPane.showConfirmDialog(this, "Are you sure you would like to delete " + ma.size() + " items?", + "Confirm Delete", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + } + if (response == JOptionPane.YES_OPTION) { + for (MediaState m : ma) { + deleteMedia(m); + } + } + } + + private void doSearch(String search) { + if (search.isBlank()) { + mediaList.setTransferHandler(mediaTransferHandler); + mediaList.setDragEnabled(true); + } else { + mediaList.setTransferHandler(null); + mediaList.setDragEnabled(false); + } + search = search.trim(); + mediaModel.clear(); + for (MediaState m : album.getMedia()) { + if (m.getName().trim().startsWith(search)) { + mediaModel.addElement(m); + } + } + } + + private final class MediaAddTransaction { + public MediaData data; + public MediaState state; + + public MediaAddTransaction(MediaData data, MediaState state) { + this.data = data; + this.state = state; + } + + public void apply() { + MediaState old = album.getMediaForName(state.getName()); + if (old != null) { + album.getMedia().remove(old); + } + cache.putMedia(album.getName(), state.getName(), data); + album.getMedia().add(state); + } + } + + private void addMedia() { + importMediaFromFiles(promptToAddFiles(), true); + } + + private void findFiles(File root, List files) { + if (root.isDirectory()) { + for (File child : root.listFiles()) { + findFiles(child, files); + } + } else { + files.add(root); + } + } + + private static class FileNameComparor implements Comparator { + @Override + public int compare(File f1, File f2) { + String n1 = getNameWithoutExtension(f1.getName()); + String n2 = getNameWithoutExtension(f2.getName()); + int i1; + try { + i1 = Integer.parseInt(n1); + } catch (NumberFormatException e) { + return 1; + } + int i2; + try { + i2 = Integer.parseInt(n2); + } catch (NumberFormatException e) { + return -1; + } + return i1 - i2; + } + + private String getNameWithoutExtension(String fn) { + int i = fn.lastIndexOf("."); + if (i == -1 || i == 0) { + return fn; + } + return fn.substring(0, i); + } + } + + private Set checkTransactionConflicts(List files) { + final HashSet conflicts = new HashSet(); + for (File f : files) { + String fn = f.getName(); + for (File o : files) { + if (o != f && fn.equals(o.getName())) { + conflicts.add(fn); + } + } + } + return conflicts; + } + + public void importMediaFromFiles(List utFiles, boolean reorder) { + final ArrayList files = new ArrayList(); + for (File f : utFiles) { + findFiles(f, files); + } + if (files.isEmpty()) { + return; + } + Set conflicts = checkTransactionConflicts(files); + if (!conflicts.isEmpty()) { + NameConflictListDialog diag = new NameConflictListDialog(SwingUtilities.getWindowAncestor(this), conflicts); + diag.setVisible(true); + return; + } + if (reorder) { + Collections.sort(files, new FileNameComparor()); + } + checkImportConflicts(files); + final ArrayList transactions = new ArrayList(); + AtomicReference f = new AtomicReference(); + ProgressTracker pt = new ProgressTracker(SwingUtilities.getWindowAncestor(this), "Adding Media", + "Adding media...", (t) -> { + try { + t.setIndeterminate(false); + t.setMinimum(0); + t.setMaximum(files.size()); + for (int i = 0; i < files.size(); ++i) { + f.set(files.get(i)); + transactions.add(importMediaFromFile(f.get(), t)); + t.setProgress(i + 1); + if (t.isCanceled()) { + transactions.clear(); + return; + } + } + } catch (Throwable e) { + LOGGER.error("Could not load media file!", e); + SwingUtilities.invokeLater(() -> { + ErrorDialog diag = new ErrorDialog("Error Addng Media", + "The media \"" + f.get().getPath() + "\" could not be loaded! No more media will be processed.", e); + diag.setVisible(true); + }); + transactions.clear(); + } + }); + pt.start(); + int origMediaCount = album.getMedia().size(); + for (MediaAddTransaction mat : transactions) { + mat.apply(); + } + if (!transactions.isEmpty()) { + if (origMediaCount == 0) { + album.setThumbnailName(album.getMedia().get(0).getName()); + } + // Refresh media and search + doSearch(searchField.getText()); + } + } + + private void checkImportConflicts(ArrayList files) { + final Window parent = SwingUtilities.getWindowAncestor(this); + int mode = MediaNameConflictDialog.RESPONSE_CANCEL; + for (Iterator i = files.iterator(); i.hasNext();) { + File f = i.next(); + String name = f.getName(); + if (name.equals("data.json")) { + JOptionPane.showMessageDialog(parent, "The name 'data.json' is invalid because it is reserved!" + + "\nThis file will not be preocessed.", + "Invalid Name", JOptionPane.ERROR_MESSAGE); + i.remove(); + } else if (album.getMediaForName(name) != null) { + if (mode == MediaNameConflictDialog.RESPONSE_CANCEL) { + MediaNameConflictDialog diag = new MediaNameConflictDialog(parent, + "Media with the name '" + name + "' already exists!"); + diag.setVisible(true); + if (diag.getResponse() == MediaNameConflictDialog.RESPONSE_CANCEL) { + files.clear(); + return; + } else if (diag.getApplyToAll()) { + mode = diag.getResponse(); + } + if (diag.getResponse() == MediaNameConflictDialog.RESPONSE_KEEP) { + i.remove(); + } else if (diag.getResponse() == MediaNameConflictDialog.RESPONSE_REPLACE) { + // We are replacing the version currently loaded, we therefore do nothing + continue; + } + } else if (mode == MediaNameConflictDialog.RESPONSE_KEEP /* keep all */) { + i.remove(); + } else if (mode == MediaNameConflictDialog.RESPONSE_REPLACE /* replace all */) { + // We are replacing the version currently loaded, we therefore do nothing + continue; + } + } + } + } + + private ArrayList promptToAddFiles() { + final FileNameExtensionFilter filter = new FileNameExtensionFilter("Video, audio, and image files", + "png", "jpg", "jpeg", "ico", "svg", "bmp", "gif", "mp4", "ogg", "webm", "mov", "qt", "mp3", "wav"); + final JFileChooser chooser = new JFileChooser(); + chooser.setFileFilter(filter); + chooser.setMultiSelectionEnabled(true); + chooser.setDialogTitle("Select Media"); + chooser.setAcceptAllFileFilterUsed(true); + chooser.setApproveButtonText("Add"); + chooser.setApproveButtonMnemonic(KeyEvent.VK_A); + chooser.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES); + chooser.setDialogType(JFileChooser.OPEN_DIALOG); + int resp = chooser.showDialog(this, null); + final ArrayList media = new ArrayList(); + if (resp == JFileChooser.APPROVE_OPTION) { + for (File f : chooser.getSelectedFiles()) { + media.add(f); + } + } + return media; + } + + private MediaAddTransaction importMediaFromFile(File file, ProgressManager t) throws IOException, + ImageProcessingException { + FileInputStream fin = new FileInputStream(file); + byte[] data = fin.readAllBytes(); + fin.close(); + ByteArrayInputStream bin = new ByteArrayInputStream(data); + String type = TIKA.detect(bin); + if (type.equals("video/quicktime")) { + return importMovFromFile(file, data, t); + } + return importStandardMedia(file, data, t); + } + + private MediaAddTransaction importStandardMedia(File file, byte[] data, ProgressManager t) + throws IOException, ImageProcessingException { + String fname = file.getName(); + MediaData mediaData = MediaLoader.loadBytes(file.getCanonicalFile(), data, true, t); + MediaMetadata metadata = MetadataLoader.fromBytes(data); + MediaState m = new MediaState(fname, metadata.getDescription(), metadata.getDate(), metadata.getTime(), + metadata.getLatitude(), metadata.getLongitude(), data.length); + m.setNameOnDisk(null); + return new MediaAddTransaction(mediaData, m); + } + + private MediaAddTransaction importMovFromFile(File file, byte[] data, ProgressManager t) throws IOException, + ImageProcessingException { + t.setIndeterminate(true); + byte[] mp4 = convertMovToMp4(data, t); + if (mp4 == null) { + return null; + } + String fname = file.getName(); + MediaData md = MediaLoader.loadBytes(null, mp4, true, t); + MediaMetadata metadata = MetadataLoader.fromBytes(data); + MediaState m = new MediaState(fname, metadata.getDescription(), metadata.getDate(), metadata.getTime(), + metadata.getLatitude(), metadata.getLongitude(), mp4.length); + m.setNameOnDisk(null); + return new MediaAddTransaction(md, m); + } + + private static byte[] convertMovToMp4(byte[] data, ProgressManager t) throws IOException, ImageProcessingException { + return FFmpeg.convertMedia(data, "mp4", t); + } + + private void exportMediaHandler() { + if (mediaList.getSelectedIndices().length == 0) { + return; + } + final File dest = promptToExportFiles(); + if (dest == null) { + return; + } + final List mediaStates = new ArrayList(mediaList.getSelectedValuesList()); + checkExportConflicts(dest, mediaStates); + if (mediaStates.isEmpty()) { + return; + } + ProgressTracker pt = new ProgressTracker(SwingUtilities.getWindowAncestor(this), "Export Progress", + "Exporting media...", (t) -> { + t.setIndeterminate(false); + t.setRange(0, mediaStates.size()); + try { + for (MediaState m : mediaStates) { + exportMedia(m, dest); + if (t.isCanceled()) { + break; + } + t.setProgress(t.getProgress() + 1); + } + } catch (IOException e) { + LOGGER.error("Could not export media!", e); + ErrorDialog ed = new ErrorDialog("Export Error", + "An error occured during export !Export did not fully complete!", e); + ed.setVisible(true); + } + }); + pt.start(); + } + + private static List listFileNames(final File file) { + final List names = new ArrayList(); + final File[] children = file.listFiles(); + if (children == null) { + return null; + } + for (File c : children) { + names.add(c.getName()); + } + return names; + } + + private void checkExportConflicts(final File dest, final List ml) { + final List aefn = listFileNames(dest); + int precedent = MediaNameConflictDialog.RESPONSE_CANCEL ; + for (Iterator i = ml.iterator(); i.hasNext();) { + MediaState m = i.next(); + if (!aefn.contains(m.getName())) { + continue; + } + if (precedent == MediaNameConflictDialog.RESPONSE_CANCEL) { + MediaNameConflictDialog diag = new MediaNameConflictDialog(SwingUtilities.getWindowAncestor(this), + "A file with the name '" + m.getName() + "' already exists!"); + diag.setVisible(true); + if (diag.getResponse() == MediaNameConflictDialog.RESPONSE_CANCEL) { + ml.clear(); + break; + } else if (diag.getApplyToAll()) { + precedent = diag.getResponse(); + // fallthrough + } else if (diag.getResponse() == MediaNameConflictDialog.RESPONSE_KEEP) { + i.remove(); + continue; + } + } + if (precedent == MediaNameConflictDialog.RESPONSE_KEEP) { + i.remove(); + } + } + } + + private File promptToExportFiles() { + final JFileChooser ch = new JFileChooser(); + ch.setMultiSelectionEnabled(false); + ch.setDialogTitle("Select Export Destination"); + ch.setApproveButtonText("Export"); + ch.setApproveButtonMnemonic(KeyEvent.VK_E); + ch.setDialogType(JFileChooser.SAVE_DIALOG); + ch.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + int resp = ch.showDialog(this, null); + if (resp == JFileChooser.APPROVE_OPTION) { + return ch.getSelectedFile(); + } + return null; + } + + private void deleteRecursively(File root) throws IOException { + if (root.isDirectory()) { + for (File c : root.listFiles()) { + deleteRecursively(c); + } + } + root.delete(); + } + + private void exportMedia(MediaState media, File dest) throws IOException { + File of = new File(dest.getCanonicalPath() + File.separator + media.getName()); + if (of.isDirectory()) { + deleteRecursively(dest); + } + MediaData md = cache.getMedia(album.getName(), media.getName(), null); + FileOutputStream out = new FileOutputStream(of); + out.write(md.loadRawBytes()); + out.close(); + } +} diff --git a/src/main/java/zander/ui/media/MediaNameConflictDialog.java b/src/main/java/zander/ui/media/MediaNameConflictDialog.java new file mode 100644 index 0000000..34c7ff8 --- /dev/null +++ b/src/main/java/zander/ui/media/MediaNameConflictDialog.java @@ -0,0 +1,88 @@ +package zander.ui.media; + +import java.awt.BorderLayout; +import java.awt.Window; +import java.awt.event.KeyEvent; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; + +import zander.ui.UIUtils; + +public class MediaNameConflictDialog extends JDialog { + private static final long serialVersionUID = -6002060884734865425L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_KEEP = 1; + public static final int RESPONSE_REPLACE = 2; + + private final JLabel messageLabel; + private final JCheckBox applyToAllCheck; + private final JPanel messagePanel; + private final JButton cancelButton; + private final JButton keepButton; + private final JButton replaceButton; + private final JPanel buttonPanel; + private final JPanel content; + + private int response = RESPONSE_CANCEL; + + public MediaNameConflictDialog(Window parent, String message) { + super(parent, "Name Conflict"); + messageLabel = new JLabel(message); + messageLabel.setAlignmentX(JComponent.CENTER_ALIGNMENT); + applyToAllCheck = new JCheckBox("Apply to all conflicts"); + applyToAllCheck.setAlignmentX(JComponent.CENTER_ALIGNMENT); + applyToAllCheck.setMnemonic(KeyEvent.VK_A); + messagePanel = new JPanel(); + messagePanel.setLayout(new BoxLayout(messagePanel, BoxLayout.Y_AXIS)); + messagePanel.add(messageLabel); + messagePanel.add(applyToAllCheck); + cancelButton = new JButton("Cancel Operation"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> dispose()); + keepButton = new JButton("Keep Original"); + keepButton.setMnemonic(KeyEvent.VK_K); + keepButton.addActionListener((e) -> { + response = RESPONSE_KEEP; + dispose(); + }); + replaceButton = new JButton("Replace"); + replaceButton.setMnemonic(KeyEvent.VK_R); + replaceButton.addActionListener((e) -> { + response = RESPONSE_REPLACE; + dispose(); + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(keepButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(replaceButton); + content = new JPanel(new BorderLayout()); + content.add(messagePanel, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + pack(); + setResizable(false); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setAlwaysOnTop(true); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + UIUtils.centerWindow(this, parent); + } + + public boolean getApplyToAll() { + return applyToAllCheck.isSelected(); + } + + public int getResponse() { + return response; + } + +} diff --git a/src/main/java/zander/ui/media/MediaPropertyPanel.java b/src/main/java/zander/ui/media/MediaPropertyPanel.java new file mode 100644 index 0000000..f112505 --- /dev/null +++ b/src/main/java/zander/ui/media/MediaPropertyPanel.java @@ -0,0 +1,528 @@ +package zander.ui.media; + +import java.awt.Window; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.ActionEvent; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.text.NumberFormat; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Objects; + +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import com.github.lgooddatepicker.components.DatePicker; +import com.github.lgooddatepicker.components.DatePickerSettings; +import com.github.lgooddatepicker.components.DatePickerSettings.DateArea; +import com.github.lgooddatepicker.components.TimePicker; +import com.github.lgooddatepicker.components.TimePickerSettings; +import com.github.lgooddatepicker.components.TimePickerSettings.TimeArea; + +import org.jxmapviewer.viewer.GeoPosition; + +import zander.library.LibraryMediaCache; +import zander.library.media.ImageMediaComponent; +import zander.library.media.MediaData; +import zander.library.state.AlbumState; +import zander.library.state.MediaState; +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; +import zander.ui.media.map.LocationSelectDialog; + +public class MediaPropertyPanel extends JPanel { + private static final long serialVersionUID = -360830663146686639L; + + private static final NumberFormat NUM_FORMAT = NumberFormat.getInstance(); + private static final long GB_SIZE = (long) Math.pow(1024, 3); + private static final long MB_SIZE = (long) Math.pow(1024, 2); + private static final long KB_SIZE = 1024; + + static { + NUM_FORMAT.setGroupingUsed(false); + NUM_FORMAT.setMinimumFractionDigits(0); + NUM_FORMAT.setMaximumFractionDigits(2); + } + + private final LibraryMediaCache cache; + private final AlbumState album; + + private final JLabel previewLabel; + private final JPanel previewPanel; + private final JLabel thumbLabel; + private final JLabel thumbValue; + private final JLabel sizeLabel; + private final JLabel sizeValue; + private final JLabel typeLabel; + private final JLabel typeValue; + private final JLabel nameLabel; + private final JTextField nameField; + private final JButton nameReset; + private final JLabel descLabel; + private final JTextArea descArea; + private final JScrollPane descScroll; + private final JPanel descPanel; + private final JButton descReset; + private final JLabel dateLabel; + private final DatePicker datePicker; + private final JButton dateReset; + private final JLabel timeLabel; + private final TimePicker timePicker; + private final JButton timeReset; + private final JLabel mapLabel; + private final JLabel mapValue; + private final JButton mapButton; + + private final ImageMediaComponent missingImageIcon; + + private MediaState currentMedia; + private boolean disableUpdates = false; + + public MediaPropertyPanel(LibraryMediaCache cache, AlbumState album) { + super(new GridBagLayout()); + this.cache = cache; + this.album = album; + missingImageIcon = getMissingImageComponent(); + previewLabel = new JLabel("Preview:"); + previewPanel = new JPanel(new BorderLayout()); + thumbLabel = new JLabel("Is Thumbnail:"); + thumbValue = new JLabel("No"); + sizeLabel = new JLabel("File Size:"); + sizeValue = new JLabel("N/A"); + typeLabel = new JLabel("File Type:"); + typeValue = new JLabel("unknown"); + nameLabel = new JLabel("Name:"); + nameField = new JTextField(); + nameField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + @Override + public void removeUpdate(DocumentEvent e) { update(); } + @Override + public void changedUpdate(DocumentEvent e) { update(); } + + private void update() { + nameReset.setEnabled(!Objects.equals(nameField.getText(), currentMedia.getOriginalName())); + } + }); + nameField.addActionListener((e) -> { + if (!disableUpdates) { + nameChanged(nameField.getText()); + } + }); + nameField.addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent e) { + if (!disableUpdates) { + nameChanged(nameField.getText()); + } + } + }); + nameReset = new JButton("↺"); + nameReset.addActionListener((e) -> { + nameField.setText(currentMedia.getOriginalName()); + nameChanged(nameField.getText()); + }); + descLabel = new JLabel("Description:"); + descArea = new JTextArea(); + descArea.setLineWrap(true); + descArea.setBorder(null); + descArea.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { update(); } + @Override + public void removeUpdate(DocumentEvent e) { update(); } + @Override + public void changedUpdate(DocumentEvent e) { update(); } + + private void update() { + if (!disableUpdates) { + descriptionChanged(descArea.getText()); + } + } + }); + descScroll = new JScrollPane(descArea); + descScroll.setBorder(null); + descPanel = new JPanel(new BorderLayout()); + descPanel.setBorder(UIManager.getBorder("TextField.border")); + descPanel.add(descScroll, BorderLayout.CENTER); + descReset = new JButton("↺"); + descReset.addActionListener((e) -> { + descArea.setText(currentMedia.getOriginalDescription()); + }); + dateLabel = new JLabel("Date:"); + datePicker = new DatePicker(getDatePickerSettings()); + datePicker.addDateChangeListener((e) -> { + if (!disableUpdates) { + dateChanged(datePicker.getDate()); + } + }); + dateReset = new JButton("↺"); + dateReset.addActionListener((e) -> { + datePicker.setDate(currentMedia.getOriginalDate()); + }); + timeLabel = new JLabel("Time:"); + timePicker = new TimePicker(getTimePickerSettings()); + timePicker.addTimeChangeListener((e) -> { + if (!disableUpdates) { + timeChanged(timePicker.getTime()); + } + }); + timeReset = new JButton("↺"); + timeReset.addActionListener((e) -> { + timePicker.setTime(currentMedia.getOriginalTime()); + }); + mapLabel = new JLabel("Location:"); + mapValue = new JLabel("N/A"); + mapButton = new JButton("\u2026"); + mapButton.addActionListener(this::mapButtonHandler); + buildUI(); + } + + private Color getDatePickerWeekHeaderColor() { + Color sel = UIManager.getColor("List.selectionBackground"); + if (sel == null) { + return Color.white; + } + return UIUtils.scaleColor(sel, 0.9f, 0.9f, 0.9f); + } + + private DatePickerSettings getDatePickerSettings() { + DatePickerSettings s = new DatePickerSettings(); + s.setAllowEmptyDates(true); + s.setAllowKeyboardEditing(true); + s.setColorBackgroundWeekdayLabels(getDatePickerWeekHeaderColor(), true); + s.setColor(DateArea.BackgroundCalendarPanelLabelsOnHover, UIManager.getColor("List.selectionBackground")); + s.setColor(DateArea.BackgroundClearLabel, UIManager.getColor("Panel.background")); + s.setColor(DateArea.BackgroundMonthAndYearMenuLabels, UIManager.getColor("Panel.background")); + s.setColor(DateArea.BackgroundMonthAndYearNavigationButtons, UIManager.getColor("Panel.background")); + s.setColor(DateArea.BackgroundOverallCalendarPanel, UIManager.getColor("Panel.background")); + s.setColor(DateArea.BackgroundTodayLabel, UIManager.getColor("Panel.background")); + s.setColor(DateArea.BackgroundTopLeftLabelAboveWeekNumbers, UIManager.getColor("Panel.background")); + s.setColor(DateArea.CalendarBackgroundNormalDates, UIManager.getColor("Panel.background")); + s.setColor(DateArea.CalendarBackgroundSelectedDate, UIManager.getColor("List.selectionBackground")); + s.setColor(DateArea.CalendarBorderSelectedDate, UIManager.getColor("Tree.selectionBorderColor")); + s.setColor(DateArea.CalendarDefaultBackgroundHighlightedDates, UIManager.getColor("Panel.background")); + s.setColor(DateArea.CalendarDefaultTextHighlightedDates, UIManager.getColor("List.selectionBackground")); + s.setColor(DateArea.CalendarTextNormalDates, UIManager.getColor("Label.foreground")); + s.setColor(DateArea.CalendarTextWeekNumbers, UIManager.getColor("Label.foreground")); + s.setColor(DateArea.CalendarTextWeekdays, UIManager.getColor("Label.foreground")); + s.setColor(DateArea.DatePickerTextValidDate, UIManager.getColor("Label.foreground")); + s.setColor(DateArea.TextCalendarPanelLabelsOnHover, UIManager.getColor("List.selectionForeground")); + s.setColor(DateArea.TextFieldBackgroundValidDate, UIManager.getColor("TextField.background")); + s.setColor(DateArea.TextMonthAndYearMenuLabels, UIManager.getColor("Label.foreground")); + s.setColor(DateArea.TextMonthAndYearNavigationButtons, UIManager.getColor("Label.foreground")); + s.setColor(DateArea.TextTodayLabel, UIManager.getColor("Label.foreground")); + return s; + } + + private TimePickerSettings getTimePickerSettings() { + TimePickerSettings s = new TimePickerSettings(); + s.setAllowEmptyTimes(true); + s.setAllowKeyboardEditing(true); + s.setColor(TimeArea.TextFieldBackgroundValidTime, UIManager.getColor("TextField.background")); + s.setColor(TimeArea.TimePickerTextValidTime, UIManager.getColor("Label.foreground")); + return s; + } + + private void buildUI() { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(1, 1, 1, 1); + gbc.weightx = 0.0; + gbc.weighty = 0.1; + gbc.gridwidth = 1; + gbc.gridheight = 1; + gbc.gridx = 0; + gbc.gridy = 0; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.fill = GridBagConstraints.NONE; + add(previewLabel, gbc); + gbc.gridx = 1; + gbc.weightx = 1.0; + gbc.weighty = 1.0; + gbc.fill = GridBagConstraints.BOTH; + gbc.anchor = GridBagConstraints.CENTER; + add(previewPanel, gbc); + gbc.weightx = 0.0; + gbc.weighty = 0.1; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.gridx = 0; + gbc.gridy = 1; + add(thumbLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + add(thumbValue, gbc); + gbc.anchor = GridBagConstraints.LINE_END; + gbc.gridx = 0; + gbc.gridy = 2; + add(sizeLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + add(sizeValue, gbc); + gbc.anchor = GridBagConstraints.LINE_END; + gbc.gridx = 0; + gbc.gridy = 3; + add(typeLabel, gbc); + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.LINE_START; + add(typeValue, gbc); + gbc.gridx = 0; + gbc.gridy = 4; + gbc.anchor = GridBagConstraints.LINE_END; + add(nameLabel, gbc); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.gridx = 1; + gbc.anchor = GridBagConstraints.CENTER; + add(nameField, gbc); + gbc.fill = GridBagConstraints.NONE; + gbc.gridx = 2; + gbc.anchor = GridBagConstraints.LINE_START; + add(nameReset, gbc); + gbc.gridx = 0; + gbc.gridy = 5; + gbc.anchor = GridBagConstraints.LINE_END; + add(descLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.BOTH; + gbc.anchor = GridBagConstraints.CENTER; + gbc.weighty = 0.3; + add(descPanel, gbc); + gbc.weighty = 0.1; + gbc.fill = GridBagConstraints.NONE; + gbc.gridx = 2; + gbc.anchor = GridBagConstraints.LINE_START; + gbc.weightx = 0.0; + add(descReset, gbc); + gbc.gridx = 0; + gbc.gridy = 6; + gbc.anchor = GridBagConstraints.LINE_END; + add(dateLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.CENTER; + add(datePicker, gbc); + gbc.fill = GridBagConstraints.NONE; + gbc.gridx = 2; + gbc.anchor = GridBagConstraints.LINE_START; + add(dateReset, gbc); + gbc.gridx = 0; + gbc.gridy = 7; + gbc.anchor = GridBagConstraints.LINE_END; + add(timeLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.CENTER; + add(timePicker, gbc); + gbc.fill = GridBagConstraints.NONE; + gbc.gridx = 2; + gbc.anchor = GridBagConstraints.LINE_START; + add(timeReset, gbc); + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.LINE_END; + gbc.gridx = 0; + gbc.gridy = 8; + add(mapLabel, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.CENTER; + add(mapValue, gbc); + gbc.gridx = 2; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.CENTER; + add(mapButton, gbc); + } + + public LibraryMediaCache getMediaCache() { + return cache; + } + + public MediaState getCurrentMedia() { + return currentMedia; + } + + private static String getFileSizeString(long size) { + double ds = (double) size; + if (size >= GB_SIZE) { + return NUM_FORMAT.format(ds / GB_SIZE) + " GB"; + } else if (size >= MB_SIZE) { + return NUM_FORMAT.format(ds / MB_SIZE) + " MB"; + } else if (size >= KB_SIZE) { + return NUM_FORMAT.format(ds / KB_SIZE) + " KB"; + } else { + return size + " B"; + } + } + + public void setCurrentMedia(MediaState media) { + disableUpdates = true; + if (currentMedia != null && !nameField.getText().equals(currentMedia.getName())) { + nameChanged(nameField.getText()); + } + this.currentMedia = media; + updateIsThumbnail(); + sizeValue.setText(getFileSizeString(media.getFileSize())); + nameField.setText(media.getName()); + descArea.setText(media.getDescription()); + previewPanel.removeAll(); + MediaData md = cache.getMedia(album.getName(), media.getName(), null); + if (md != null) { + typeValue.setText(md.getType()); + md.getViewerComponent().setEnabled(true); + previewPanel.add(md.getViewerComponent(), BorderLayout.CENTER); + } else { + typeValue.setText("unknown"); + previewPanel.add(missingImageIcon, BorderLayout.CENTER); + } + datePicker.setDate(media.getDate()); + timePicker.setTime(media.getTime()); + mapValue.setText(formatLocation(media)); + revalidate(); + repaint(); + updateResetButtons(); + disableUpdates = false; + } + + public void updateIsThumbnail() { + if (currentMedia != null) { + if (album.getThumbnailName().equals(currentMedia.getName())) { + thumbValue.setText("Yes"); + } else { + thumbValue.setText("No"); + } + } + } + + private static String formatLocation(MediaState m) { + if (m.getLatitude() == null || m.getLongitude() == null) { + return "N/A"; + } + return m.getLatitude() + ", " + m.getLongitude(); + } + + private void nameChanged(String name) { + String oldName = currentMedia.getName(); + if (!name.equals(oldName)) { + if (album.getMediaForName(name) != null) { + disableUpdates = true; + nameField.setText(oldName); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "Media with the name \"" + name + "\" already exists!", + "Name Conflict", JOptionPane.ERROR_MESSAGE); + } else if (name.isBlank()) { + disableUpdates = true; + nameField.setText(oldName); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "The name \"" + name + "\" is invalid!", "Invalid Name", + JOptionPane.ERROR_MESSAGE); + } else if (name.equals("data.json")) { + disableUpdates = true; + nameField.setText(oldName); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "The name \"" + name + "\" is reserved!", "Invalid Name", + JOptionPane.ERROR_MESSAGE); + } else if (name.contains("/")) { + disableUpdates = true; + nameField.setText(oldName); + disableUpdates = false; + JOptionPane.showMessageDialog(this, "The name \"" + name + "\" is because the charater '/' is not allowed!", + "Invalid Name", JOptionPane.ERROR_MESSAGE); + } else { + currentMedia.setName(name); + cache.moveMedia(album.getName(), oldName, name); + if (album.getThumbnailName().equals(oldName)) { + album.setThumbnailName(name); + } + SwingUtilities.getWindowAncestor(this).repaint(); + } + } + nameReset.setEnabled(!Objects.equals(nameField.getText(), currentMedia.getOriginalName())); + } + + private void descriptionChanged(String description) { + currentMedia.setDescription(description); + descReset.setEnabled(!resetStringEquals(descArea.getText(), currentMedia.getOriginalDescription())); + } + + private void dateChanged(LocalDate date) { + currentMedia.setDate(date); + dateReset.setEnabled(!Objects.equals(date, currentMedia.getOriginalDate())); + } + + private void timeChanged(LocalTime time) { + currentMedia.setTime(time); + timeReset.setEnabled(!Objects.equals(time, currentMedia.getOriginalTime())); + } + + private static ImageMediaComponent getMissingImageComponent() { + try { + BufferedImage bi = UIUtils.loadBufferedImage(ClassLoader.getSystemResource("missing-image-icon.svg"), -1, -1); + return new ImageMediaComponent(bi); + } catch (IOException e) { + ErrorDialog ed = new ErrorDialog("Internal Error", "A fatal error has occured!", e); + ed.setVisible(true); + System.exit(0); + return null; + } + } + + private void updateResetButtons() { + nameReset.setEnabled(!resetStringEquals(nameField.getText(), currentMedia.getOriginalName())); + descReset.setEnabled(!resetStringEquals(descArea.getText(), currentMedia.getOriginalDescription())); + dateReset.setEnabled(!Objects.equals(datePicker.getDate(), currentMedia.getOriginalDate())); + timeReset.setEnabled(!Objects.equals(timePicker.getTime(), currentMedia.getOriginalTime())); + } + + private static boolean resetStringEquals(String s1, String s2) { + if (s1 != null && s1.isEmpty()) { + s1 = null; + } + if (s2 != null && s2.isEmpty()) { + s2 = null; + } + return Objects.equals(s1, s2); + } + + private void mapButtonHandler(ActionEvent e) { + Window wa = SwingUtilities.getWindowAncestor(this); + LocationSelectDialog diag = new LocationSelectDialog(wa, + currentMedia.getLatitude(), currentMedia.getLongitude(), currentMedia.getOriginalLatitude(), + currentMedia.getOriginalLongitude()); + if (wa != null) { + diag.setSize((2 * wa.getWidth()) / 3, (2 * wa.getHeight()) / 3); + UIUtils.centerWindow(diag, wa); + } + diag.setVisible(true); + if (diag.getResponse() == LocationSelectDialog.RESPONSE_SELECT) { + GeoPosition pos = diag.getCurrentLocation(); + if (pos == null) { + currentMedia.setLatitude(null); + currentMedia.setLongitude(null); + } else { + currentMedia.setLatitude(pos.getLatitude()); + currentMedia.setLongitude(pos.getLongitude()); + } + mapValue.setText(formatLocation(currentMedia)); + } + } + + public void updateName() { + nameChanged(nameField.getText()); + } +} diff --git a/src/main/java/zander/ui/media/NameConflictListDialog.java b/src/main/java/zander/ui/media/NameConflictListDialog.java new file mode 100644 index 0000000..5e68232 --- /dev/null +++ b/src/main/java/zander/ui/media/NameConflictListDialog.java @@ -0,0 +1,66 @@ +package zander.ui.media; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.util.Set; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.SwingConstants; + +import zander.ui.UIUtils; + +public class NameConflictListDialog extends JDialog { + private static final long serialVersionUID = 1252139512341L; + + private final JLabel message; + private final JPanel names; + private final JScrollPane scroll; + private final JButton close; + private final JPanel buttons; + private final JPanel content; + + public NameConflictListDialog(Window parent, Set conflicts) { + super(parent, "Name Conflicts"); + message = new JLabel("The following names appear two or more times in this import!", SwingConstants.CENTER); + names = new JPanel(); + names.setLayout(new BoxLayout(names, BoxLayout.Y_AXIS)); + for (String name : conflicts) { + JLabel label = new JLabel(name, SwingConstants.LEFT); + label.setAlignmentX(JComponent.LEFT_ALIGNMENT); + names.add(label); + } + scroll = new JScrollPane(names); + close = new JButton("Close"); + close.addActionListener(e -> dispose()); + close.setMnemonic(KeyEvent.VK_C); + close.setAlignmentX(JComponent.RIGHT_ALIGNMENT); + buttons = new JPanel(); + buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS)); + buttons.add(Box.createHorizontalGlue()); + buttons.add(close); + content = new JPanel(new BorderLayout()); + content.add(message, BorderLayout.PAGE_START); + content.add(scroll, BorderLayout.CENTER); + content.add(buttons, BorderLayout.PAGE_END); + setContentPane(content); + Dimension size = new Dimension((int) (UIUtils.SCREEN_SIZE.getWidth() / 3), + (int) (UIUtils.SCREEN_SIZE.getHeight() / 2)); + setMinimumSize(size); + setSize(size); + setResizable(true); + setAlwaysOnTop(true); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + UIUtils.centerWindow(this, parent); + } + +} diff --git a/src/main/java/zander/ui/media/map/ListLayout.java b/src/main/java/zander/ui/media/map/ListLayout.java new file mode 100644 index 0000000..8aeac87 --- /dev/null +++ b/src/main/java/zander/ui/media/map/ListLayout.java @@ -0,0 +1,56 @@ +package zander.ui.media.map; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.LayoutManager; + +public class ListLayout implements LayoutManager { + @Override + public void addLayoutComponent(String name, Component comp) { + // unused + } + + @Override + public void removeLayoutComponent(Component comp) { + // unused + } + + @Override + public Dimension preferredLayoutSize(Container parent) { + Dimension size = new Dimension(); + for (Component c : parent.getComponents()) { + Dimension cs = c.getPreferredSize(); + size.height += cs.height; + if (cs.width > size.width) { + size.width = cs.width; + } + } + return size; + } + + @Override + public Dimension minimumLayoutSize(Container parent) { + Dimension size = new Dimension(); + for (Component c : parent.getComponents()) { + Dimension cs = c.getMinimumSize(); + size.height += cs.height; + if (cs.width > size.width) { + size.width = cs.width; + } + } + return size; + } + + @Override + public void layoutContainer(Container parent) { + int cw = parent.getWidth(); + int ly = 0; + for (Component c : parent.getComponents()) { + int h = c.getPreferredSize().height; + c.setSize(cw, h); + c.setLocation(0, ly); + ly += h; + } + } +} diff --git a/src/main/java/zander/ui/media/map/LocationEncoder.java b/src/main/java/zander/ui/media/map/LocationEncoder.java new file mode 100644 index 0000000..3de3784 --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationEncoder.java @@ -0,0 +1,14 @@ +package zander.ui.media.map; + +import java.io.IOException; +import java.util.List; + +public interface LocationEncoder { + + public String getLicenseString(); + + public List encode(String query) throws IOException; + + public void cleanOldCacheEntries(); + +} diff --git a/src/main/java/zander/ui/media/map/LocationEncoderCacheCleaner.java b/src/main/java/zander/ui/media/map/LocationEncoderCacheCleaner.java new file mode 100644 index 0000000..233a65c --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationEncoderCacheCleaner.java @@ -0,0 +1,70 @@ +package zander.ui.media.map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LocationEncoderCacheCleaner { + private static final Logger LOGGER = LoggerFactory.getLogger(LocationEncoderCacheCleaner.class); + + private final LocationEncoder encoder; + /** in miliiseconds */ + private final long frequency; + private final Thread cleanThread; + private boolean running = true; + private boolean paused = false; + private long lastClean = 0; + + public LocationEncoderCacheCleaner(LocationEncoder encoder, long milis) { + this.encoder = encoder; + frequency = milis; + cleanThread = new Thread(this::cleanLoop); + cleanThread.start(); + } + + public void dispose() { + running = false; + } + + public boolean isDisposed() { + return !running; + } + + public void pause() { + paused = true; + } + + public boolean isPaused() { + return paused; + } + + public void resume() { + paused = false; + } + + public long getFrequency() { + return frequency; + } + + public LocationEncoder getEncoder() { + return encoder; + } + + private void cleanLoop() { + LOGGER.info("Starting location cache clean loop with frequency of {}ms", frequency); + while (running) { + if (!paused && lastClean + frequency <= System.currentTimeMillis()) { + LOGGER.info("Cleaning location cache..."); + encoder.cleanOldCacheEntries(); + LOGGER.info("Done cleaning location cache"); + lastClean = System.currentTimeMillis(); + } + try { + Thread.sleep(5); + } catch (InterruptedException e) { + // ignore + } + } + LOGGER.info("Stopped location cache clean loop!"); + } + +} diff --git a/src/main/java/zander/ui/media/map/LocationEntry.java b/src/main/java/zander/ui/media/map/LocationEntry.java new file mode 100644 index 0000000..0b0785b --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationEntry.java @@ -0,0 +1,33 @@ +package zander.ui.media.map; + +import org.jxmapviewer.viewer.GeoPosition; + +public class LocationEntry { + + private final String name; + private final double lat; + private final double lon; + + public LocationEntry(String name, double lat, double lon) { + this.name = name; + this.lat = lat; + this.lon = lon; + } + + public String getName() { + return name; + } + + public double getLongitude() { + return lon; + } + + public double getLatitude() { + return lat; + } + + public GeoPosition getPosition() { + return new GeoPosition(lat, lon); + } + +} diff --git a/src/main/java/zander/ui/media/map/LocationEntryComponent.java b/src/main/java/zander/ui/media/map/LocationEntryComponent.java new file mode 100644 index 0000000..d3dd863 --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationEntryComponent.java @@ -0,0 +1,103 @@ +package zander.ui.media.map; + +import java.awt.Color; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; + +import javax.swing.BorderFactory; +import javax.swing.JPanel; +import javax.swing.JTextArea; + +public class LocationEntryComponent extends JPanel { + private static final long serialVersionUID = 123157372342348205L; + + private final MouseAdapter actionMouseAdapter = new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + fireActionEvent(); + } + }; + private final ArrayList actionListeners = new ArrayList(); + private final LocationEntry entry; + + private final JTextArea nameArea; + private final JTextArea coordArea; + + public LocationEntryComponent(LocationEntry entry) { + this.entry = entry; + nameArea = new JTextArea(entry.getName()); + nameArea.setEnabled(false); + nameArea.setDisabledTextColor(getForeground()); + coordArea = new JTextArea(roundDouble(entry.getLatitude(), 4) + + ", " + roundDouble(entry.getLongitude(), 4)); + coordArea.setEnabled(false); + coordArea.setDisabledTextColor(getForeground()); + setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, getForeground())); + setLayout(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(1, 1, 1, 1); + gbc.weightx = 1.0; + gbc.weighty = 1.0; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.LINE_START; + gbc.gridwidth = 1; + gbc.gridheight = 1; + gbc.gridx = 0; + gbc.gridy = 0; + add(nameArea, gbc); + gbc.gridy = 1; + add(coordArea, gbc); + addMouseListener(actionMouseAdapter); + nameArea.addMouseListener(actionMouseAdapter); + coordArea.addMouseListener(actionMouseAdapter); + } + + @Override + public void setBackground(Color bg) { + super.setBackground(bg); + if (nameArea != null) { + nameArea.setBackground(bg); + coordArea.setBackground(bg); + } + } + + @Override + public void setForeground(Color fg) { + super.setForeground(fg); + if (nameArea != null) { + nameArea.setDisabledTextColor(getForeground()); + coordArea.setDisabledTextColor(getForeground()); + nameArea.setForeground(fg); + coordArea.setForeground(fg); + } + } + + public LocationEntry getEntry() { + return entry; + } + + private static String roundDouble(double d, int places) { + return String.format("%." + places + "f", d); + } + + public void addActionListener(ActionListener l) { + actionListeners.add(l); + } + + public void removeActionListener(ActionListener l) { + actionListeners.remove(l); + } + + private void fireActionEvent() { + ActionEvent event = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, "selected"); + for (ActionListener l : actionListeners) { + l.actionPerformed(event); + } + } +} diff --git a/src/main/java/zander/ui/media/map/LocationEntryList.java b/src/main/java/zander/ui/media/map/LocationEntryList.java new file mode 100644 index 0000000..4496dc1 --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationEntryList.java @@ -0,0 +1,119 @@ +package zander.ui.media.map; + +import java.awt.Color; +import java.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.ArrayList; + +import javax.swing.JPanel; +import javax.swing.UIManager; + +public class LocationEntryList extends JPanel { + private static final long serialVersionUID = -5819587120501L; + + private final ArrayList locationSelectListeners = new ArrayList(); + private final ActionListener childListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (e.getSource() instanceof LocationEntryComponent) { + selected = (LocationEntryComponent) e.getSource(); + updateColors(); + fireLocationSelectEvent(selected.getEntry()); + } + }; + }; + + private Color selectionForeground; + private Color selectionBackground; + private LocationEntryComponent selected; + + public LocationEntryList() { + setLayout(new ListLayout()); + selectionForeground = UIManager.getColor("List.selectionForeground"); + selectionBackground = UIManager.getColor("List.selectionBackground"); + } + + public void setSelectionForeground(Color c) { + selectionForeground = c; + updateColors(); + } + + public Color getSelectionForeground() { + return selectionForeground; + } + + public void setSelectionBackground(Color c) { + selectionBackground = c; + updateColors(); + } + + public Color getSelectionBackground() { + return selectionBackground; + } + + @Override + public void setForeground(Color fg) { + super.setForeground(fg); + updateColors(); + } + + @Override + public void setBackground(Color bg) { + super.setBackground(bg); + updateColors(); + } + + public void add(LocationEntry e) { + LocationEntryComponent c = new LocationEntryComponent(e); + c.setForeground(getForeground()); + c.setBackground(getBackground()); + c.addActionListener(childListener); + add(c); + } + + public void addAll(Iterable entries) { + for (LocationEntry e : entries) { + add(e); + } + } + + private void updateColors() { + Color fg = getForeground(); + Color bg = getBackground(); + for (Component c : getComponents()) { + if (c == selected) { + c.setForeground(selectionForeground); + c.setBackground(selectionBackground); + } else{ + c.setForeground(fg); + c.setBackground(bg); + } + } + repaint(); + } + + private void fireLocationSelectEvent(LocationEntry entry) { + LocationSelectEvent e = new LocationSelectEvent(this, entry); + for (LocationSelectListener l : locationSelectListeners) { + l.locationSelected(e); + } + } + + public void addLocationSelectListener(LocationSelectListener l) { + locationSelectListeners.add(l); + } + + public void removeLocationSelectListener(LocationSelectListener l) { + locationSelectListeners.remove(l); + } + + public void clearSelection() { + if (selected != null) { + selected.setForeground(getForeground()); + selected.setBackground(getBackground()); + selected = null; + repaint(); + } + } +} diff --git a/src/main/java/zander/ui/media/map/LocationEntryListener.java b/src/main/java/zander/ui/media/map/LocationEntryListener.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/zander/ui/media/map/LocationSelectDialog.java b/src/main/java/zander/ui/media/map/LocationSelectDialog.java new file mode 100644 index 0000000..a3d21ed --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationSelectDialog.java @@ -0,0 +1,319 @@ +package zander.ui.media.map; + +import java.awt.BorderLayout; +import java.awt.Window; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.lang.reflect.InvocationTargetException; +import java.text.DecimalFormat; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.JSplitPane; +import javax.swing.JTextField; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingUtilities; +import javax.swing.event.ChangeListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.event.MouseInputListener; + +import org.jxmapviewer.JXMapViewer; +import org.jxmapviewer.OSMTileFactoryInfo; +import org.jxmapviewer.input.CenterMapListener; +import org.jxmapviewer.input.PanKeyListener; +import org.jxmapviewer.input.PanMouseInputListener; +import org.jxmapviewer.input.ZoomMouseWheelListenerCursor; +import org.jxmapviewer.viewer.DefaultTileFactory; +import org.jxmapviewer.viewer.GeoPosition; +import org.jxmapviewer.viewer.TileFactoryInfo; + +import zander.ui.UIUtils; +import zander.ui.docs.DocumentationViewer; + +public class LocationSelectDialog extends JDialog { + private static final long serialVersionUID = 817598179351293L; + + public static final int RESPONSE_CANCEL = 0; + public static final int RESPONSE_SELECT = 1; + + private static TileFactoryInfo mapTileInfo; + private static DefaultTileFactory mapTileFactory; + static { + final Runnable initAction = () -> { + mapTileInfo = new OSMTileFactoryInfo(); + mapTileFactory = new DefaultTileFactory(mapTileInfo); + mapTileFactory.setThreadPoolSize(8); + }; + try { + if (SwingUtilities.isEventDispatchThread()) { + initAction.run(); + } else { + SwingUtilities.invokeAndWait(initAction); + } + } catch (InvocationTargetException | InterruptedException e) { + throw new Error(e); + } + } + @SuppressWarnings("unused") + private static final LocationEncoderCacheCleaner locationEncoderCacheCleaner; + private static final LocationEncoder locationEncoder; + static { + locationEncoder = new OSMLocationEncoder(); + locationEncoderCacheCleaner = new LocationEncoderCacheCleaner(locationEncoder, TimeUnit.MINUTES.toMillis(5)); + } + + private final ActionListener searchActionListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (!searchField.getText().isBlank() && locationSelect.search(searchField.getText()) + && splitPane.getParent() == null) { + remove(mapComp); + splitPane.setLeftComponent(locationSelect); + splitPane.setRightComponent(mapComp); + add(splitPane, BorderLayout.CENTER); + revalidate(); + splitPane.setDividerLocation(0.3); + } + } + }; + private final GeoPosition origPos; + + private final JLabel searchLabel; + private final JTextField searchField; + private final JButton searchButton; + private final JPanel searchPanel; + private final JLabel locationLabel; + private final LocationSelectPanel locationSelect; + private final JPanel locationPanel; + private final MapSelectionPainter mapPainter; + private final JXMapViewer mapComp; + private final JSplitPane splitPane; + private final JButton cancelButton; + private final JLabel zoomLabel; + private final SpinnerNumberModel zoomModel; + private final JSpinner zoomField; + private final JLabel posLabel; + private final SpinnerNumberModel latModel; + private final JSpinner latField; + private final JLabel posSepLabel; + private final SpinnerNumberModel lonModel; + private final JSpinner lonField; + private final JButton resetButton; + private final JButton helpButton; + private final JButton clearButton; + private final JButton selectButton; + private final JPanel buttonPanel; + private final JPanel content; + + private int response = RESPONSE_CANCEL; + private GeoPosition currPos; + private boolean disableUpdates = false; + + public LocationSelectDialog(Window parent, Double lat, Double lon, Double origLat, Double origLon) { + super(parent, "Select Location"); + origPos = makeGeoPosition(origLat, origLon); + searchLabel = new JLabel("Search:"); + searchField = new JTextField(); + searchField.getDocument().addDocumentListener(new DocumentListener(){ + @Override + public void insertUpdate(DocumentEvent e) { update(); } + @Override + public void removeUpdate(DocumentEvent e) { update(); } + @Override + public void changedUpdate(DocumentEvent e) { update(); } + private void update() { + searchButton.setEnabled(!searchField.getText().isBlank()); + } + }); + searchField.addActionListener(searchActionListener); + searchButton = new JButton("Search"); + searchButton.setMnemonic(KeyEvent.VK_E); + searchButton.addActionListener(searchActionListener); + searchPanel = new JPanel(); + searchPanel.setLayout(new BoxLayout(searchPanel, BoxLayout.X_AXIS)); + searchPanel.add(searchLabel); + searchPanel.add(searchField); + searchPanel.add(searchButton); + locationLabel = new JLabel("Results:"); + locationSelect = new LocationSelectPanel(locationEncoder); + locationSelect.addLocationSelectListener((e) -> updateSelection(e.getEntry().getPosition(), false, true, true)); + locationPanel = new JPanel(new BorderLayout()); + locationPanel.setVisible(false); + locationPanel.add(locationLabel, BorderLayout.PAGE_START); + locationPanel.add(locationSelect, BorderLayout.CENTER); + mapPainter = new MapSelectionPainter(); + mapComp = new JXMapViewer(); + mapComp.setTileFactory(mapTileFactory); + mapComp.setOverlayPainter(mapPainter); + mapComp.addPropertyChangeListener("zoom", new PropertyChangeListener() { + @Override + public void propertyChange(PropertyChangeEvent evt) { + pushUpdate(() -> { + zoomModel.setValue(20 - mapComp.getZoom()); + }); + } + }); + MouseInputListener mapMia = new PanMouseInputListener(mapComp); + mapComp.addMouseListener(mapMia); + mapComp.addMouseMotionListener(mapMia); + mapComp.addMouseListener(new CenterMapListener(mapComp)); + mapComp.addMouseWheelListener(new ZoomMouseWheelListenerCursor(mapComp)); + mapComp.addKeyListener(new PanKeyListener(mapComp)); + mapComp.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + updateSelection(mapComp.convertPointToGeoPosition(e.getPoint()), true, false, true); + } + }); + splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + splitPane.setOneTouchExpandable(true); + splitPane.setLeftComponent(locationPanel); + splitPane.setRightComponent(mapComp); + cancelButton = new JButton("Cancel"); + cancelButton.setMnemonic(KeyEvent.VK_C); + cancelButton.addActionListener((e) -> dispose()); + zoomLabel = new JLabel("Zoom:"); + zoomModel = new SpinnerNumberModel(13, 1, 20, 1); + zoomField = new JSpinner(zoomModel); + zoomField.addChangeListener((e) -> { + pushUpdate(() -> { + mapComp.setZoom(20 - zoomModel.getNumber().intValue()); + }); + }); + posLabel = new JLabel("Selection:"); + latModel = new SpinnerNumberModel(0.0, -90.0, 90.0, 1.0); + latField = new JSpinner(latModel); + final JSpinner.NumberEditor latEditor = (JSpinner.NumberEditor) latField.getEditor(); + final DecimalFormat latFormat = latEditor.getFormat(); + latFormat.setMaximumFractionDigits(4); + latFormat.setMinimumFractionDigits(0); + posSepLabel = new JLabel(","); + lonModel = new SpinnerNumberModel(0.0, -180.0, 180.0, 1.0); + lonField = new JSpinner(lonModel); + final JSpinner.NumberEditor lonEditor = (JSpinner.NumberEditor) lonField.getEditor(); + final DecimalFormat lonFormat = lonEditor.getFormat(); + lonFormat.setMaximumFractionDigits(4); + lonFormat.setMinimumFractionDigits(0); + final ChangeListener posListener = (e) -> updateSelection(latModel.getNumber().doubleValue(), + lonModel.getNumber().doubleValue(), true, true, false); + latField.addChangeListener(posListener); + lonField.addChangeListener(posListener); + resetButton = new JButton("↺"); + helpButton = new JButton("?"); + helpButton.addActionListener((e) -> DocumentationViewer.show(this, "Media/Selecting Locations")); + clearButton = new JButton("Clear"); + clearButton.setMnemonic(KeyEvent.VK_L); + clearButton.addActionListener((e) -> { + int resp = JOptionPane.showConfirmDialog(this, "Are you sure you would like to clear the current selection?", + "Confirm Clear", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + if (resp == JOptionPane.YES_OPTION) { + currPos = null; + response = RESPONSE_SELECT; + dispose(); + } + }); + resetButton.addActionListener((e) -> updateSelection(origPos, true, true, true)); + selectButton = new JButton("Select"); + selectButton.setMnemonic(KeyEvent.VK_S); + selectButton.addActionListener((e) -> { + response = RESPONSE_SELECT; + dispose(); + }); + buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS)); + buttonPanel.add(cancelButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(zoomLabel); + buttonPanel.add(zoomField); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(posLabel); + buttonPanel.add(latField); + buttonPanel.add(posSepLabel); + buttonPanel.add(lonField); + buttonPanel.add(resetButton); + buttonPanel.add(Box.createHorizontalGlue()); + buttonPanel.add(helpButton); + buttonPanel.add(clearButton); + buttonPanel.add(selectButton); + content = new JPanel(new BorderLayout()); + content.add(searchPanel, BorderLayout.PAGE_START); + content.add(mapComp, BorderLayout.CENTER); + content.add(buttonPanel, BorderLayout.PAGE_END); + setContentPane(content); + setResizable(true); + setAlwaysOnTop(true); + setModalityType(JDialog.DEFAULT_MODALITY_TYPE); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + UIUtils.centerWindow(this, parent); + updateSelection(lat, lon, false, true, true); + mapComp.setZoom(7); + if (lat == null || lon == null) { + mapPainter.setCurrentPosition(null); + repaint(); + } + } + + public int getResponse() { + return response; + } + + public GeoPosition getCurrentLocation() { + return currPos; + } + + private void updateSelection(Double lat, Double lon, boolean clearListSel, boolean updateMap, boolean updateField) { + updateSelection(makeGeoPosition(lat, lon), clearListSel, updateMap, updateField); + } + + private void updateSelection(GeoPosition pos, boolean clearListSel, boolean updateMap, boolean updateField) { + currPos = pos; + updateResetButton(); + pushUpdate(() -> { + if (clearListSel) { + locationSelect.clearSelection(); + } + if (updateMap) { + mapComp.setAddressLocation(pos); + } + if (updateField) { + latModel.setValue(pos.getLatitude()); + lonModel.setValue(pos.getLongitude()); + } + }); + mapPainter.setCurrentPosition(pos); + repaint(); + } + + private void updateResetButton() { + resetButton.setEnabled(!Objects.equals(currPos, origPos)); + } + + private GeoPosition makeGeoPosition(Double lat, Double lon) { + if (lat == null || lon == null) { + return new GeoPosition(0.0, 0.0); + } + return new GeoPosition(lat, lon); + } + + private void pushUpdate(Runnable update) { + if (!disableUpdates) { + disableUpdates = true; + update.run(); + disableUpdates = false; + } + } +} diff --git a/src/main/java/zander/ui/media/map/LocationSelectEvent.java b/src/main/java/zander/ui/media/map/LocationSelectEvent.java new file mode 100644 index 0000000..b34941d --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationSelectEvent.java @@ -0,0 +1,22 @@ +package zander.ui.media.map; + +import java.util.EventObject; + +public class LocationSelectEvent extends EventObject { + + private LocationEntry entry; + + public LocationSelectEvent(Object source, LocationEntry entry) { + super(source); + this.entry = entry; + } + + public LocationEntry getEntry() { + return entry; + } + + public void setEntry(LocationEntry entry) { + this.entry = entry; + } + +} diff --git a/src/main/java/zander/ui/media/map/LocationSelectListener.java b/src/main/java/zander/ui/media/map/LocationSelectListener.java new file mode 100644 index 0000000..04f1352 --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationSelectListener.java @@ -0,0 +1,7 @@ +package zander.ui.media.map; + +import java.util.EventListener; + +public interface LocationSelectListener extends EventListener { + public void locationSelected(LocationSelectEvent e); +} diff --git a/src/main/java/zander/ui/media/map/LocationSelectPanel.java b/src/main/java/zander/ui/media/map/LocationSelectPanel.java new file mode 100644 index 0000000..0e6c1f9 --- /dev/null +++ b/src/main/java/zander/ui/media/map/LocationSelectPanel.java @@ -0,0 +1,78 @@ +package zander.ui.media.map; + +import java.awt.BorderLayout; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.SwingConstants; +import javax.swing.UIManager; + +import zander.ui.ErrorDialog; +import zander.ui.UIUtils; + +public class LocationSelectPanel extends JPanel { + private static final long serialVersionUID = -1824218931983L; + + private final ArrayList locationSelectListeners = new ArrayList(); + + private final LocationEncoder encoder; + private final LocationEntryList list; + private final JScrollPane scroll; + private final JLabel licenseLabel; + + public LocationSelectPanel(LocationEncoder encoder) { + setLayout(new BorderLayout()); + this.encoder = encoder; + list = new LocationEntryList(); + scroll = new JScrollPane(list); + licenseLabel = new JLabel(encoder.getLicenseString(), SwingConstants.CENTER); + licenseLabel.setFont(UIUtils.scaleFont(UIManager.getFont("Label.font"), 0.4f)); + add(scroll, BorderLayout.CENTER); + add(licenseLabel, BorderLayout.PAGE_END); + } + + public LocationEncoder getEncoder() { + return encoder; + } + + public void addActionListener(LocationSelectListener l) { + locationSelectListeners.add(l); + } + + public void removeActionListener(LocationSelectListener l) { + locationSelectListeners.remove(l); + } + + public boolean search(String query) { + try { + final List entries = encoder.encode(query); + if (entries != null) { + list.removeAll(); + list.addAll(entries); + revalidate(); + repaint(); + return true; + } + } catch (IOException e) { + ErrorDialog ed = new ErrorDialog("Search Error", "Search did not complete successfully.", e); + ed.setVisible(true); + } + return false; + } + + public void addLocationSelectListener(LocationSelectListener l) { + list.addLocationSelectListener(l); + } + + public void removeLocationSelectListener(LocationSelectListener l) { + list.removeLocationSelectListener(l); + } + + public void clearSelection() { + list.clearSelection(); + } +} diff --git a/src/main/java/zander/ui/media/map/MapSelectionPainter.java b/src/main/java/zander/ui/media/map/MapSelectionPainter.java new file mode 100644 index 0000000..3c31997 --- /dev/null +++ b/src/main/java/zander/ui/media/map/MapSelectionPainter.java @@ -0,0 +1,37 @@ +package zander.ui.media.map; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.geom.Point2D; + +import org.jxmapviewer.JXMapViewer; +import org.jxmapviewer.painter.Painter; +import org.jxmapviewer.viewer.GeoPosition; + +import zander.ui.UIUtils; + +public class MapSelectionPainter implements Painter { + private GeoPosition currentPosition; + + @Override + public void paint(Graphics2D g, JXMapViewer map, int width, int height) { + if (currentPosition != null) { + Point2D p = map.convertGeoPositionToPoint(currentPosition); + int size = (int) Math.round(UIUtils.getSmallerScreenDimension() / 80.0); + int x = (int) Math.round(p.getX() - (size / 2.0)); + int y = (int) Math.round(p.getY() - (size / 2.0)); + g.setColor(Color.black); + g.fillOval(x, y, size, size); + g.setColor(Color.white); + g.drawOval(x, y, size, size); + } + } + + public void setCurrentPosition(GeoPosition currentPosition) { + this.currentPosition = currentPosition; + } + + public GeoPosition getCurentPosition() { + return currentPosition; + } +} diff --git a/src/main/java/zander/ui/media/map/OSMLocationEncoder.java b/src/main/java/zander/ui/media/map/OSMLocationEncoder.java new file mode 100644 index 0000000..32be2b1 --- /dev/null +++ b/src/main/java/zander/ui/media/map/OSMLocationEncoder.java @@ -0,0 +1,138 @@ +package zander.ui.media.map; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class OSMLocationEncoder implements LocationEncoder { + private static final Logger LOGGER = LoggerFactory.getLogger(OSMLocationEncoder.class); + private static final JSONParser JSON_PARSER = new JSONParser(); + + private static final class LocationCacheEntry { + public final long time; + public final List entries; + + public LocationCacheEntry(List entries) { + this.entries = entries; + time = System.currentTimeMillis(); + } + + public boolean isValid() { + return time > System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1); + } + } + + private final Map locationCache = new ConcurrentHashMap(); + + @Override + public String getLicenseString() { + return "Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright"; + } + + @Override + public List encode(String query) throws IOException { + LOGGER.info("Attempting to encode query: '{}'", query); + if (query.isBlank()) { + return new ArrayList(); + } + final List cacheResult = getCacheEntryIfValid(query); + if (cacheResult != null) { + LOGGER.info("Retrived result from cache."); + return cacheResult; + } + final URL url = getURLForQuery(query); + final String rawJson = makeRequest(url); + Object jsonRootObject; + try { + jsonRootObject = JSON_PARSER.parse(rawJson); + } catch (ParseException e) { + LOGGER.error("Unparseable response from server!", e); + throw new IOException("Unparseable response!", e); + } + final List queriedEntries = parseJsonResponse(jsonRootObject); + locationCache.put(query, new LocationCacheEntry(queriedEntries)); + LOGGER.info("Query returned " + queriedEntries.size() + " results."); + return queriedEntries; + } + + @Override + public void cleanOldCacheEntries() { + for (var i = locationCache.entrySet().iterator(); i.hasNext();) { + Entry entry = i.next(); + if (!entry.getValue().isValid()) { + i.remove(); + } + } + } + + private List getCacheEntryIfValid(String query) { + LocationCacheEntry e = locationCache.get(query); + if (e != null && e.isValid()) { + return e.entries; + } + return null; + } + + private static List parseJsonResponse(Object rootObject) { + try { + final ArrayList entries = new ArrayList(); + JSONArray rootArray = (JSONArray) rootObject; + for (Object entryObject : rootArray) { + entries.add(parseJsonEntry((JSONObject) entryObject)); + } + return entries; + } catch (ClassCastException | NullPointerException | NumberFormatException e) { + LOGGER.error("Invalid response!", e); + return null; + } + } + + private static LocationEntry parseJsonEntry(JSONObject jsonEntry) { + String name = (String) jsonEntry.get("display_name"); + String latStr = (String) jsonEntry.get("lat"); + String lonStr = (String) jsonEntry.get("lon"); + double lat = Double.parseDouble(latStr); + double lon = Double.parseDouble(lonStr); + return new LocationEntry(name, lat, lon); + } + + private static String makeRequest(URL url) throws IOException { + LOGGER.info("Making request: '{}'", url.toExternalForm()); + URLConnection conn = url.openConnection(); + conn.setRequestProperty("User-Agent", "Zander/Curator - OSMLocationEncoder"); + conn.setDoOutput(false); + conn.connect(); + String response = ""; + try (InputStream in = conn.getInputStream()) { + response = new String(in.readAllBytes()); + } + return response; + } + + private static URL getURLForQuery(String query) { + try { + final String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + return new URL("https://nominatim.openstreetmap.org/search.php?format=jsonv2&q=" + encodedQuery); + } catch (MalformedURLException e) { + LOGGER.error("Could not generate url for query: '{}'", query, e); + throw new Error("Could not create URL!", e); + } + } +} diff --git a/src/main/java/zander/ui/transferhandlers/ListArrangeTransferHandler.java b/src/main/java/zander/ui/transferhandlers/ListArrangeTransferHandler.java new file mode 100644 index 0000000..c32881d --- /dev/null +++ b/src/main/java/zander/ui/transferhandlers/ListArrangeTransferHandler.java @@ -0,0 +1,139 @@ +package zander.ui.transferhandlers; + +import java.awt.Component; +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.Transferable; +import java.awt.datatransfer.UnsupportedFlavorException; +import java.awt.dnd.DragSource; +import java.io.IOException; +import java.util.Objects; + +import javax.swing.DefaultListModel; +import javax.swing.JComponent; +import javax.swing.JList; +import javax.swing.TransferHandler; + +/** From this StackOverflow post: {@link https://stackoverflow.com/questions/16586562/reordering-jlist-with-drag-and-drop} */ +public class ListArrangeTransferHandler extends TransferHandler { + private static final long serialVersionUID = 1L; + + private final DataFlavor objectArrayDataFlavor = new DataFlavor(Object[].class, "An object"); + + private int[] indices; + private int addIndex = -1; + private int addCount; + + private final Runnable before; + private final Runnable after; + + public ListArrangeTransferHandler() { + this(null, null); + } + + public ListArrangeTransferHandler(Runnable before, Runnable after) { + this.before = before; + this.after = after; + } + + @Override + protected Transferable createTransferable(JComponent c) { + if (before != null) { + before.run(); + } + JList source = (JList) c; + c.getRootPane().getGlassPane().setVisible(true); + indices = source.getSelectedIndices(); + Object[] transferObjects = source.getSelectedValuesList().toArray(); + return new Transferable() { + @Override + public DataFlavor[] getTransferDataFlavors() { + return new DataFlavor[] { objectArrayDataFlavor }; + } + + @Override + public boolean isDataFlavorSupported(DataFlavor flavor) { + return Objects.equals(objectArrayDataFlavor, flavor); + } + + @Override + public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException { + if (!isDataFlavorSupported(flavor)) { + throw new UnsupportedFlavorException(flavor); + } + return transferObjects; + } + }; + } + + @Override + public boolean canImport(TransferSupport info) { + return info.isDrop() && info.isDataFlavorSupported(objectArrayDataFlavor); + } + + @Override + public int getSourceActions(JComponent c) { + Component glassPane = c.getRootPane().getGlassPane(); + glassPane.setCursor(DragSource.DefaultMoveDrop); + return MOVE; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public boolean importData(TransferSupport info) { + TransferHandler.DropLocation tdl = info.getDropLocation(); + if (!canImport(info) || !(tdl instanceof JList.DropLocation)) { + return false; + } + JList.DropLocation dl = (JList.DropLocation) tdl; + JList target = (JList) info.getComponent(); + DefaultListModel listModel = (DefaultListModel) target.getModel(); + int max = listModel.getSize(); + int index = dl.getIndex(); + index = index < 0 ? max : index; + index = Math.min(index, max); + addIndex = index; + try { + Object[] values = (Object[]) info.getTransferable().getTransferData(objectArrayDataFlavor); + for (int i = 0; i < values.length; ++i) { + int idx = index++; + listModel.add(idx, values[i]); + target.addSelectionInterval(idx, idx); + } + addCount = values.length; + return true; + } catch (UnsupportedFlavorException | IOException e) { + throw new Error("Flavor was invalid or an IOException occured", e); + } + } + + @Override + protected void exportDone(JComponent c, Transferable data, int action) { + c.getRootPane().getGlassPane().setVisible(false); + cleanup(c, action == MOVE); + } + + @SuppressWarnings("rawtypes") + private void cleanup(JComponent c, boolean remove) { + if (remove && Objects.nonNull(c)) { + if (addCount > 0) { + for (int i = 0; i < indices.length; ++i) { + if (indices[i] >= addIndex) { + indices[i] += addCount; + } + } + } + JList source = (JList) c; + DefaultListModel model = (DefaultListModel) source.getModel(); + for (int i = indices.length - 1; i >= 0; --i) { + model.remove(indices[i]); + } + } + indices = null; + addCount = 0; + addIndex = -1; + if (after != null) { + after.run(); + } + } + +} diff --git a/src/main/resources/docs.xml b/src/main/resources/docs.xml new file mode 100644 index 0000000..31ee563 --- /dev/null +++ b/src/main/resources/docs.xml @@ -0,0 +1,21 @@ + + + + docs/library-select-window.html + docs/local-library-settings.html + docs/global-settings.html + + + docs/album-manage-window.html + docs/album-settings.html + docs/library-settings.html + + + docs/managing-media.html + docs/media-types.html + docs/media-properties.html + docs/selecting-locations.html + docs/import-external.html + + docs/gpl3.html + diff --git a/src/main/resources/docs/album-create-dialog.png b/src/main/resources/docs/album-create-dialog.png new file mode 100644 index 0000000..25294d9 Binary files /dev/null and b/src/main/resources/docs/album-create-dialog.png differ diff --git a/src/main/resources/docs/album-manage-window.html b/src/main/resources/docs/album-manage-window.html new file mode 100644 index 0000000..77c01be --- /dev/null +++ b/src/main/resources/docs/album-manage-window.html @@ -0,0 +1,35 @@ + + + +

Managing Alubms

+

Overview

+

+ From the album management window, you can open, edit the properties of, and create albums. + Albums are the first thing a user is prompted to select when they visit the Art Museum website. + An album hols media. Media is the images, videos, and audio clips uploaded to Art Museum. Each + album has a thumbnail, name, and title. In this section you will discuss more about albums and how + to manage them. +

+ +

An example of the album management window.

+

Buttons

+
    +
  • Back: return to the library selection screen. + NOTE: you will lose any unsaved changes to the current library.
  • +
  • Upload: upload any changes made to the current library to the FTP server. Also refered to as + saving the library.
  • +
  • Library Properties: control properties of the current library that affect the Art Muesum website. + See: Library Settings
  • +
  • New: crate a new album. See: Album Settings
  • +
  • Delete: delete an album.
  • +
  • Open: open an album. This allows you to manage the album's media. + See: Managing Media
  • +
  • Toggle Property Panel: toggle the album property panel. + See: Album Settings
  • +
+

Rearranging Albums

+

+ Albums can be rearranged by dragging and dropping them into the desired order. +

+ + diff --git a/src/main/resources/docs/album-property-window.png b/src/main/resources/docs/album-property-window.png new file mode 100644 index 0000000..af4ee0c Binary files /dev/null and b/src/main/resources/docs/album-property-window.png differ diff --git a/src/main/resources/docs/album-select-window.png b/src/main/resources/docs/album-select-window.png new file mode 100644 index 0000000..37348ac Binary files /dev/null and b/src/main/resources/docs/album-select-window.png differ diff --git a/src/main/resources/docs/album-settings.html b/src/main/resources/docs/album-settings.html new file mode 100644 index 0000000..e58a4a2 --- /dev/null +++ b/src/main/resources/docs/album-settings.html @@ -0,0 +1,34 @@ + + + +

Album Settings

+

Overview

+

+ Albums have three different settings. They are: thumbnail, name, and title. This section will explain + what each of these settings do and how to manage them. +

+ +

The panel on the right of the album management window allows you to modify album settings. Clicking the + thumbnail preview will open a visual thumbnail select dialog. + See: Managing Albums

+
+
+ +

The album creation dialog.

+

The Settings In Detail

+
    +
  • Thumbnail: this setting controls which media is used a the thumbnail for the album. + It can be any media present in the album. If an audio clip is selected to be a thumbnail, the thumbnail + will use an image of a music note to represent this.
  • +
  • Album title: the title of the album is the text displayed under it on the Art Museum website's album + select page. It may be the same as other albums. It may also be blank and may contain any character. It is + reccomended to only use printable characters in the album title.
  • +
  • Custom URL: the name of the album. This must be unique among albums, cannot be blank, + and cannot contain the '/' character. It is reccommended to only use printable characters in the + album name. It is used in the URL of the album on the Art Museum website.
  • +
  • Different Title and URL: if disabled, the URL will be taken from the title of the ablum. However, + because of the restrictions placed on URLs, it may be required to have a different title and URL. If this is + the case, this option should be enabled.
  • +
+ + diff --git a/src/main/resources/docs/global-settings-dialog.png b/src/main/resources/docs/global-settings-dialog.png new file mode 100644 index 0000000..744983f Binary files /dev/null and b/src/main/resources/docs/global-settings-dialog.png differ diff --git a/src/main/resources/docs/global-settings.html b/src/main/resources/docs/global-settings.html new file mode 100644 index 0000000..314a4d6 --- /dev/null +++ b/src/main/resources/docs/global-settings.html @@ -0,0 +1,37 @@ + + + +

Global Settings

+

Overview

+

+ This dialog controls the global settings of Curator. These settings do not affect any instances of the + Art Museum website. +

+ +

The global settings dialog.

+

Options

+
    +
  • Default Library Location: default location to store library caches in.
  • +
  • Password Store Backend: the backend to use to store FTP passwords. The next section + contains information about the different backends.
  • +
  • Theme: the theme to use with Curator. Dependent on installed Java Swing Look-and-Feels.
  • +
  • Default Library: the library to automatically open when curator is open. If this is set to + 'Do not automatially open any libraries', the library select dialog will open instead.
  • +
  • Default Upload Action: controls what acction to take after uploading is complere. Options are: + "Always ask", "Never open library", "Always open library". "Always ask" will cause a popup to ask what + option to take after uploading is complete. "Never open library" will simply return you to the library + select window. "Always open library" will open the remote library in your web browser. A URL MUST + be set in the remote library settings for this option to have any effect.
  • +
  • Ask Before Exiting: if enabled, Curator will ask before exiting or returning to the library + select screen.
  • +
+

Password Store Backends

+
    +
  • Java Preferences: the Java Preferences API. Works on almost all systems, however, is unencrypted. + his is probably fine unless extra security is required for the FTP server.
  • +
  • Pass: Pass is a password manager for Unix-like systems. It relies on the GnuPG software for + encryption and decryption of passwords. It is only available is Pass is installed and configured + on your system.
  • +
+ + diff --git a/src/main/resources/docs/gpl3.html b/src/main/resources/docs/gpl3.html new file mode 100644 index 0000000..c4cafeb --- /dev/null +++ b/src/main/resources/docs/gpl3.html @@ -0,0 +1,693 @@ + + + + GNU General Public License v3.0 - GNU Project - Free Software Foundation (FSF) + + + +

GNU GENERAL PUBLIC LICENSE

+

Version 3, 29 June 2007

+ +

Copyright © 2007 Free Software Foundation, Inc. + <https://fsf.org/>

+ 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.

+ +
    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    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 <https://www.gnu.org/licenses/>.
+
+ +

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:

+ +
    <program>  Copyright (C) <year>  <name of author>
+    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 +<https://www.gnu.org/licenses/>.

+ +

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 +<https://www.gnu.org/licenses/why-not-lgpl.html>.

+ + + \ No newline at end of file diff --git a/src/main/resources/docs/import-external.html b/src/main/resources/docs/import-external.html new file mode 100644 index 0000000..f88cc82 --- /dev/null +++ b/src/main/resources/docs/import-external.html @@ -0,0 +1,24 @@ + + + +

Importing From External Programs

+

Overview

+

+ Some external programs will be able to send media straight to Curator. Typically this will require + a plugin to be installed in the external program to allow it to interface with Curator. When importing media, + you will first need to select the library into which you would like to import. If you have a default library + (See: Global Settings) set, you will skip this step. + You will then need to select an album into which to import. You can select an already existing album, or, create + a new album. Once you have selected these two options, you will be given a chace to review your changes. You + can also modify other albums and media in the library like normal at this point. Finally, use the upload button to + save your changes to the Ary Museum website. +

+ +

The library select window when importing media.

+
+
+ +

The album select window when importing media. The 'New' button can be used to create a new album + instead of importing into an existing one.

+ + diff --git a/src/main/resources/docs/import-select-album-window.png b/src/main/resources/docs/import-select-album-window.png new file mode 100644 index 0000000..36cd0d4 Binary files /dev/null and b/src/main/resources/docs/import-select-album-window.png differ diff --git a/src/main/resources/docs/import-select-library-window.png b/src/main/resources/docs/import-select-library-window.png new file mode 100644 index 0000000..07e4a0c Binary files /dev/null and b/src/main/resources/docs/import-select-library-window.png differ diff --git a/src/main/resources/docs/info.html b/src/main/resources/docs/info.html new file mode 100644 index 0000000..ffea260 --- /dev/null +++ b/src/main/resources/docs/info.html @@ -0,0 +1,27 @@ + + + +

+ Curator is a program designed to manage one or more instances + of the Art Museum web site. It was created by Alexander (me) + for his father. The hope was that it would be a simple and attractive website + for posting family photos. I hope you enjoy it! ^_^ +

+

+ Curator is licensed under the GPL3 license. See the 'License' + entry in the help window or check the LICENSE file for more information. + Art Museum is licensed under the AGPL3. Check the following + websites for more infomation and version control repositories. +

+

+ GPL3: https://www.gnu.org/licenses/gpl-3.0.en.html +
+ AGPL3: https://www.gnu.org/licenses/agpl-3.0.en.html +

+

+ Curator Source: https://gitlab.com/Zander671/curator +
+ Art Museum Source: https://gitlab.com/Zander671/art-museum +

+ + diff --git a/src/main/resources/docs/library-create-dialog.png b/src/main/resources/docs/library-create-dialog.png new file mode 100644 index 0000000..d9a0909 Binary files /dev/null and b/src/main/resources/docs/library-create-dialog.png differ diff --git a/src/main/resources/docs/library-edit-dialog.png b/src/main/resources/docs/library-edit-dialog.png new file mode 100644 index 0000000..7a47b0e Binary files /dev/null and b/src/main/resources/docs/library-edit-dialog.png differ diff --git a/src/main/resources/docs/library-import-dialog.png b/src/main/resources/docs/library-import-dialog.png new file mode 100644 index 0000000..11addc7 Binary files /dev/null and b/src/main/resources/docs/library-import-dialog.png differ diff --git a/src/main/resources/docs/library-select-window.html b/src/main/resources/docs/library-select-window.html new file mode 100644 index 0000000..fbc3c31 --- /dev/null +++ b/src/main/resources/docs/library-select-window.html @@ -0,0 +1,33 @@ + + + +

Managing Libraries

+

Overview

+

+ When opening curator without a default library, this is the first window you will see. + From the library management window, you can open, create, import, and delete libraries. + A library is your colletion of media on any given instance of the Art Museum website. + Libraries are made up of albums, which, in turn, contain the media. Each instsance of Art Museum + houses one library. +

+ +

The library manage window.

+

Buttons in Detail

+

+ This section details the buttons found at the bottom of the library management window. +

+ + + diff --git a/src/main/resources/docs/library-select-window.png b/src/main/resources/docs/library-select-window.png new file mode 100644 index 0000000..bd7e66c Binary files /dev/null and b/src/main/resources/docs/library-select-window.png differ diff --git a/src/main/resources/docs/library-settings.html b/src/main/resources/docs/library-settings.html new file mode 100644 index 0000000..66daed7 --- /dev/null +++ b/src/main/resources/docs/library-settings.html @@ -0,0 +1,19 @@ + + + +

Library Settings

+

Overview

+

+ These settings are global to the entire library. Unlike the local library settings, they affect + the Art Museum website as well. +

+ +

The library settings dialog.

+

The Settings In Detail

+
    +
  • Name: this text appears at the top of the Art Muesum website. It is the title of the overall library.
  • +
  • License: this text appears at the bottom of the Art Museum website. It is the license + you would like to upload your media under.
  • +
+ + diff --git a/src/main/resources/docs/local-library-settings.html b/src/main/resources/docs/local-library-settings.html new file mode 100644 index 0000000..6be9f2d --- /dev/null +++ b/src/main/resources/docs/local-library-settings.html @@ -0,0 +1,45 @@ + + + +

Local Library Settings

+

Overview

+

+ These dialogs create, import or alter the settings of libraries. These settings are primarily + for use of Curator and will not affect the Art Museum website. +

+

+ Local vs Remote Library +
+ Remore libraries are instances of the Art Museum website. They are connected to by FTP. + Any changes made to them will be reflected in their respective Art Museum instance. +
+ Local libraries, or caches, are instances of libraries located on your computer. When you connect + to a remote library, a local library is automatically created and sync with its remote counterpart. + Every time you connect to a remote library, its local counterpart is updated in this way. +

+ +

The library creation and remote import dialog.

+
+
+ +

The library edit dialog.

+
+
+ +

The local library import dialog.

+

Options

+
    +
  • Name: library name in the select dialog. Does not affect website.
  • +
  • URL: url in the select dialog. Can be blank. Does not affect the website.
  • +
  • FTP: FTP server to connect to. Must be in the format of 'ftp://hostname:port/path/to/library'. + Protocol can also be FTPS by replacing 'ftp://' with 'ftps://'.
  • +
  • Username & Password: username and password to use to connect to FTP server.
  • +
  • Library Location (Source): location to save caches in. If 'User default library location' + is checked, it will be stored in the directory set in the global settings menu. + See: Global Settings
  • +
  • Clear Cahce: clear library cahce. This does NOT affect the remote library. It only + clears the local cache. Doing this will cause a full re-download of the remote library next time it is + oppened.
  • +
+ + diff --git a/src/main/resources/docs/location-select-dialog.png b/src/main/resources/docs/location-select-dialog.png new file mode 100644 index 0000000..7d8780e Binary files /dev/null and b/src/main/resources/docs/location-select-dialog.png differ diff --git a/src/main/resources/docs/managing-media.html b/src/main/resources/docs/managing-media.html new file mode 100644 index 0000000..6a23318 --- /dev/null +++ b/src/main/resources/docs/managing-media.html @@ -0,0 +1,36 @@ + + + +

Managing Media

+

Overview

+

+ The media manage window allows you to add, remove, and modify the properties of media. Media is the main + content of the Art Museum website. It is the images, video, and audio clips that you are trying to display + and share. As there are many different file formats that media can come in, a general guide can be found in + Media Types. +

+ +

An example of the media manage window. The media red.png is the thumbnail for this album.

+

The Buttons In Detail

+
    +
  • Upload: upload any changes made to the current library to the FTP server. Also refered to as + saving the library.
  • +
  • Set As Thumbnail: make the currently selected media the thumbnail for its album. The current thumbnail + is denoted by a small star. See: Album Settings
  • +
  • Delete: delete the slected media.
  • +
  • Export: export the currently selected media. If multiple items are selected, they will all + be exported to the same directory.
  • +
  • Add: add media from files. Any number of files and/or directories can be selected. ALL selected files + and any files located in any selected directories or their sub-directories will then be added. If there are two files + that have the same name, the addition will fail and an error message will be displayed. Any media files which has + a name (without the last '.' extension) that is a number, will be sorted and placed before other media. + See: Media Types
  • +
  • Toggle Property Panel: toggle the media property panel. See: + Media Properties
  • +
+

Rearranging Media

+

+ Media can be rearranged by dragging and dropping it into the desired order. +

+ + diff --git a/src/main/resources/docs/media-properties.html b/src/main/resources/docs/media-properties.html new file mode 100644 index 0000000..f3e9df8 --- /dev/null +++ b/src/main/resources/docs/media-properties.html @@ -0,0 +1,28 @@ + + + +

Media Properties

+

Overview

+

+ Each piece of media has various properties that can be set to tell visitors of the Art Museum website + more information about the image. When media is imported, if the media is of a compatible type + (See: Media Types), these values may be initialized using + metadata from the media file itself. This section will explain what each property means in detail. +

+ +

The right panel of the media manage window allows you to modify media properties.

+

The Properties In Detail

+
    +
  • Is Thumbnail: whether or not this media is the thumbnail for its album.
  • +
  • File Size: the size of the media file.
  • +
  • File Type: the type of data the file contains. This may be different from its name's extension. + See: Media Types
  • +
  • Name: the name of the file this media is contained in. Affects the name when this media is exported and + the name of the file when downloaded from the Art Museum website.
  • +
  • Date: the date on which the media was created.
  • +
  • Time: the time at which the media was created.
  • +
  • Location: the location at which the media was created. "N/A" means that no location is + currently selected. See: Selecting Location
  • +
+ + diff --git a/src/main/resources/docs/media-property-window.png b/src/main/resources/docs/media-property-window.png new file mode 100644 index 0000000..1d838fb Binary files /dev/null and b/src/main/resources/docs/media-property-window.png differ diff --git a/src/main/resources/docs/media-select-window.png b/src/main/resources/docs/media-select-window.png new file mode 100644 index 0000000..e137c98 Binary files /dev/null and b/src/main/resources/docs/media-select-window.png differ diff --git a/src/main/resources/docs/media-types.html b/src/main/resources/docs/media-types.html new file mode 100644 index 0000000..dba0fe1 --- /dev/null +++ b/src/main/resources/docs/media-types.html @@ -0,0 +1,76 @@ + + + +

Media Types

+

Overview

+

+ Curator supports most common, and some uncommon, media types. However, because every web browser is different, + even if media is supported in Curator, it may not be supported on the Art Museum website. +

+

Image Media

+

+ Unlike other formats (see below), image files are relatively simple. Thus, any valid file of one + of the following formats will be supported by any modern browser without issue. However, any other formats + are very unlikely to work. +
+ HTML5 Supported Image Types: +

    +
  • Animated Portable Network Graphics (.apng)
  • +
  • Graphics Interchange Format (.gif)
  • +
  • Microsoft Icon (.ico, .cur)
  • +
  • Joint Photographic Expert Group image (.jpeg, .jpg)
  • +
  • Portable Network Graphics (.png)
  • +
  • Scalable Vector Graphics (.svg)
  • +
+

+

Video Media

+

+ Unlike images, video files contain multple types of data. Because of this, common file types (such + as .mp4) are just containers. The acctaul video and audio data inside are then encoded using + standard encodings (such as h264, vp9, and Vorbis). While this can sound like a complex problem (and + it can be at times) most browsers will support any format that these containers support. However, if you or another + visitor of the Art Museum website is having trouble playing media, it may be a good idea to try re-exporting the files + from your media creation tool with different settings. +
+ HTML5 Supported Video Container Types: +

    +
  • Moving Pictures Experts Group video (.mp4)
  • +
  • WebM (.webm)
  • +
  • Xiph.Org Formats (.ogg, .ogv)
  • +
+ Curator will automatically convert the following video container types into an Mpeg4 container. Note that as the Mpeg4 + container type is a subtype of quicktime, the type field may still show 'video/quicktime'. +
+ Types converted By Curator +
    +
  • Apple QuickTime Video (.mov, .qt)
  • +
+

+

Audio Media

+

+ Audio files can suffer from many of the same problems as video files (see above), however, they generally + are a lot simpler as their is only one type of data contained within the audio container file. Thus, a browser will + generally be able to play audio data no matter what encoding it uses within a supported container. +
+ HTML5 Supported Audio Container Types: +

    +
  • Moving Pictures Experts Group audio (.mp3)
  • +
  • Xiph.Org Formats (.ogg)
  • +
  • Waveform Audio File Format (.wav)
  • +
+

+

Metadata

+

+ While most media types support metadata of some kind. Curator can only load metadata from a few types. +
+ Curator Supported Metadata Types +

    +
  • PNG: date, time, geolocation
  • +
  • JPEG: description, date, time, geolocation
  • +
  • Tiff: description, date, time, geolocation
  • +
  • Mpeg4: geolocation
  • +
  • QuickTime Video: description, date, time, geolocation
  • +
+

+ + diff --git a/src/main/resources/docs/remote-library-settings.png b/src/main/resources/docs/remote-library-settings.png new file mode 100644 index 0000000..ab6f9d0 Binary files /dev/null and b/src/main/resources/docs/remote-library-settings.png differ diff --git a/src/main/resources/docs/selecting-locations.html b/src/main/resources/docs/selecting-locations.html new file mode 100644 index 0000000..e77c4f2 --- /dev/null +++ b/src/main/resources/docs/selecting-locations.html @@ -0,0 +1,40 @@ + + + +

Selecting Locations

+

Overview

+

+ This dialog allows you to specify the location that a given piece of media was created at + (See: Media Properties). Locations can be selected by + enterering positional coordinates (longitude and latitude), selecing a point on an interactive map, searching the + name of a location, or any combination of the three. +

+ +

The location select dialog. The user has searched and selected the city of San Fransisco.

+

Clearing the Location

+

+ The location can be cleared by selecing the 'Clear' button at the bottom of the dialog. This will remove + the location information from the media. This is different from giving the media a position of 0°, 0°. +

+

Positional Coordinate Selection

+

+ To select a location via latitude and longitude, enter the desired coordinates in the boxes labled 'Selection'. + The first box represents the latitude and the second in the longitude. Negative and positive numbers are used in + place of the standard '°E', '°N', '°S', and '°W'. +

+

The Interactive Map

+

+ The map can be interacted with using the mouse. Clicking on a location will select that location. Clicking and + dragging the map will move the currently viewed area, but will not change the selection. Using the scroll wheel + will zoom in and out. The zoom can also be changed using the 'Zoom' field under the map. The zoom is represented + by an integer value from 1 to 20, with 20 being the most zoomed in. +

+

The Location Search Field

+

+ Entering the name of a location, such as a home address, or a point of interest, such as a city, will center the map + on that location and then select it. This process, known as reverse geoencoding, is done by OpenStreetMap's Nominatim + API. More information can be found at + https://wiki.openstreetmap.org/wiki/Nominatim. +

+ + diff --git a/src/main/resources/eye-closed-icon.svg b/src/main/resources/eye-closed-icon.svg new file mode 100644 index 0000000..7a72208 --- /dev/null +++ b/src/main/resources/eye-closed-icon.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + diff --git a/src/main/resources/eye-open-icon.svg b/src/main/resources/eye-open-icon.svg new file mode 100644 index 0000000..49ba201 --- /dev/null +++ b/src/main/resources/eye-open-icon.svg @@ -0,0 +1,49 @@ + + + + + + + + + + diff --git a/src/main/resources/logo.svg b/src/main/resources/logo.svg new file mode 100644 index 0000000..ea618f9 --- /dev/null +++ b/src/main/resources/logo.svg @@ -0,0 +1,76 @@ + + + + + + + + image/svg+xml + + + + + + + + C + + diff --git a/src/main/resources/menu-icon.svg b/src/main/resources/menu-icon.svg new file mode 100644 index 0000000..1f49fc2 --- /dev/null +++ b/src/main/resources/menu-icon.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + diff --git a/src/main/resources/missing-image-icon.svg b/src/main/resources/missing-image-icon.svg new file mode 100644 index 0000000..797db5b --- /dev/null +++ b/src/main/resources/missing-image-icon.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + ! + + This image couldnot be loaded! + + diff --git a/src/main/resources/music-icon.svg b/src/main/resources/music-icon.svg new file mode 100644 index 0000000..6ddbd2d --- /dev/null +++ b/src/main/resources/music-icon.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/pause.svg b/src/main/resources/pause.svg new file mode 100644 index 0000000..4843c31 --- /dev/null +++ b/src/main/resources/pause.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + diff --git a/src/main/resources/play.svg b/src/main/resources/play.svg new file mode 100644 index 0000000..17c78e3 --- /dev/null +++ b/src/main/resources/play.svg @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/src/main/resources/reset-arrow.svg b/src/main/resources/reset-arrow.svg new file mode 100644 index 0000000..f3e660d --- /dev/null +++ b/src/main/resources/reset-arrow.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + diff --git a/src/main/resources/star.svg b/src/main/resources/star.svg new file mode 100644 index 0000000..e67f37e --- /dev/null +++ b/src/main/resources/star.svg @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/src/main/resources/video-icon.png b/src/main/resources/video-icon.png new file mode 100644 index 0000000..e37fe67 Binary files /dev/null and b/src/main/resources/video-icon.png differ diff --git a/src/main/resources/video-icon.svg b/src/main/resources/video-icon.svg new file mode 100644 index 0000000..a6ca9c2 --- /dev/null +++ b/src/main/resources/video-icon.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + This video's thumbnail could not be loaded! + +