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 @@
+
+
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 extends Directory> dc, int tag) {
+ final Collection extends Directory> 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 extends Directory> dc, int tag) {
+ final Collection extends Directory> 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 extends AlbumState> 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 extends MediaState> 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
+
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.
+