commit cc1523034b555812d3ee71710ba167b3c2a481fe Author: Lilian Jónsdóttir Date: Tue Jun 18 15:21:03 2024 -0700 initial commit, it does the thing diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e37e32e --- /dev/null +++ b/LICENSE @@ -0,0 +1,613 @@ +GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 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 Affero General Public License is a free, copyleft license for software +and other kinds of works, specifically designed to ensure cooperation with +the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are 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. + +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. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free software +are heartened and encouraged by the resulting cooperation. However, in the +case of software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and letting +the public access it on a server without ever releasing its source code to +the public. + +The GNU Affero General Public License is designed specifically to ensure that, +in such cases, the modified source code becomes available to the community. +It requires the operator of a network server to provide the source code of +the modified version running there to the users of that server. Therefore, +public use of a modified version, on a publicly accessible server, gives the +public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different license, +not a version of the Affero GPL, but Affero has released a new version of +the Affero GPL which permits relicensing under this license. + +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 Affero 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 s ue 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. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, +your modified version must prominently offer all users interacting with it +remotely through a computer network (if your version supports such interaction) +an opportunity to receive the Corresponding Source of your version by providing +access to the Corresponding Source from a network server at no charge, through +some standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any work covered +by version 3 of the GNU General Public License that is incorporated pursuant +to the following paragraph. + +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 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 work with which it is combined will remain +governed by version 3 of the GNU General Public License. + + 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero 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 Affero 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 Affero 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 Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. However, +no additional obligations are imposed on any author or copyright holder as +a result of your choosing to follow a later version. + + 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER +EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM +PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + + 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM +AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, +INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO +USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED +INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE +PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot +be given local legal effect according to their terms, reviewing courts shall +apply local law that most closely approximates an absolute waiver of all civil +liability in connection with the Program, unless a warranty or assumption +of liability accompanies a copy of the Program in return for a fee. END OF +TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + + + +Copyright (C) + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero 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 Affero General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License along +with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could display +a "Source" link that leads users to an archive of the code. There are many +ways you could offer source, and different solutions will be better for different +programs; see section 13 for the specific requirements. + +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 AGPL, see . diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3dd41e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module git.burning.moe/celediel/gt + +go 1.22.3 + +require ( + github.com/adrg/xdg v0.4.0 + github.com/charmbracelet/lipgloss v0.11.0 + github.com/charmbracelet/log v0.4.0 + github.com/dustin/go-humanize v1.0.1 + github.com/ijt/go-anytime v1.9.2 + github.com/urfave/cli/v2 v2.27.2 + gitlab.com/tymonx/go-formatter v1.5.1 + golang.org/x/term v0.21.0 + gopkg.in/ini.v1 v1.67.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0ea46dd --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= +github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= +github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/ijt/go-anytime v1.9.2 h1:DmYgVwUiFPNR+n6c1T5P070tlGATRZG4aYNJs6XDUfU= +github.com/ijt/go-anytime v1.9.2/go.mod h1:egBT6FhVjNlXNHUN2wTPi6ILCNKXeeXFy04pWJjw/LI= +github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d h1:LFOmpWrSbtolg0YqYC9hQjj5WSLtRGb6aZ3JAugLfgg= +github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d/go.mod h1:112TOyA+aruNSUBlyBWlKBdLVYTdhjiO2CKD0j/URSU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI= +github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= +github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= +github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +gitlab.com/tymonx/go-formatter v1.5.1 h1:gmn5rJqR6LlI1DkpBmiCo0MZ3ges581A14GZcXlGe60= +gitlab.com/tymonx/go-formatter v1.5.1/go.mod h1:z1E064wx+cgg5ChY+1E+hrJf8uKY//kF1Q1As+w5WRQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/files/files.go b/internal/files/files.go new file mode 100644 index 0000000..be3d180 --- /dev/null +++ b/internal/files/files.go @@ -0,0 +1,176 @@ +// Package files finds and displays files on disk +package files + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "time" + + "git.burning.moe/celediel/gt/internal/filter" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/charmbracelet/log" + "github.com/dustin/go-humanize" +) + +type File struct { + name, path string + filesize int64 + modified time.Time +} + +type Files []File + +func (f File) Name() string { return f.name } +func (f File) Path() string { return f.path } +func (f File) Modified() time.Time { return f.modified } +func (f File) Filesize() int64 { return f.filesize } + +func (fls Files) Table(width int) string { + // sort newest on top + slices.SortStableFunc(fls, SortByModifiedReverse) + + data := [][]string{} + for _, file := range fls { + t := humanize.Time(file.modified) + b := humanize.Bytes(uint64(file.filesize)) + data = append(data, []string{ + file.name, + file.path, + t, + b, + }) + } + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))). + Width(width). + Headers("filename", "path", "modified", "size"). + Rows(data...) + + return fmt.Sprint(t) +} + +func (fls Files) Show(width int) { + fmt.Println(fls.Table(width)) +} + +func Find(dir string, recursive bool, f *filter.Filter) (files Files, err error) { + if dir == "." || dir == "" { + var d string + if d, err = os.Getwd(); err != nil { + return + } else { + dir = d + } + } + + log.Debugf("gonna find files in %s matching %s", dir, f) + + if recursive { + files = append(files, walk_dir(dir, f)...) + } else { + files = append(files, read_dir(dir, f)...) + } + + return +} + +func walk_dir(dir string, f *filter.Filter) (files Files) { + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + p, e := filepath.Abs(path) + if e != nil { + return err + } + info, _ := d.Info() + if f.Match(d.Name(), info.ModTime()) { + log.Debugf("found matching file: %s %s", p, info.ModTime()) + i, _ := os.Stat(p) + files = append(files, File{path: filepath.Dir(p), name: d.Name(), filesize: i.Size(), modified: i.ModTime()}) + } else { + log.Debugf("ignoring file %s (%s)", p, info.ModTime()) + } + return nil + }) + if err != nil { + return []File{} + } + return +} + +func read_dir(dir string, f *filter.Filter) (files Files) { + fs, err := os.ReadDir(dir) + if err != nil { + return []File{} + } + for _, file := range fs { + name := file.Name() + + if name == dir { + continue + } + + info, err := file.Info() + if err != nil { + return []File{} + } + + path := filepath.Dir(filepath.Join(dir, name)) + + if f.Match(name, info.ModTime()) { + log.Debugf("found matching file: %s %s", path, info.ModTime()) + files = append(files, File{ + name: name, + path: path, + modified: info.ModTime(), + filesize: info.Size(), + }) + } else { + log.Debugf("ignoring file %s (%s)", path, info.ModTime()) + } + } + return +} + +func SortByModified(a, b File) int { + if a.modified.After(b.modified) { + return 1 + } else if a.modified.Before(b.modified) { + return -1 + } else { + return 0 + } +} + +func SortByModifiedReverse(a, b File) int { + if a.modified.Before(b.modified) { + return 1 + } else if a.modified.After(b.modified) { + return -1 + } else { + return 0 + } +} + +func SortBySize(a, b File) int { + if a.filesize > b.filesize { + return 1 + } else if a.filesize < b.filesize { + return -1 + } else { + return 0 + } +} + +func SortBySizeReverse(a, b File) int { + if a.filesize < b.filesize { + return 1 + } else if a.filesize > b.filesize { + return -1 + } else { + return 0 + } +} diff --git a/internal/filter/filter.go b/internal/filter/filter.go new file mode 100644 index 0000000..379ebb1 --- /dev/null +++ b/internal/filter/filter.go @@ -0,0 +1,137 @@ +// Package filter filters files based on specific critera +package filter + +import ( + "fmt" + "path/filepath" + "regexp" + "slices" + "time" + + "github.com/ijt/go-anytime" +) + +type Filter struct { + on, before, after time.Time + glob, pattern string + filenames []string + matcher *regexp.Regexp +} + +func (f *Filter) On() time.Time { return f.on } +func (f *Filter) After() time.Time { return f.after } +func (f *Filter) Before() time.Time { return f.before } +func (f *Filter) Glob() string { return f.glob } +func (f *Filter) Pattern() string { return f.pattern } +func (f *Filter) FileNames() []string { return f.filenames } + +func (f *Filter) Match(filename string, modified time.Time) bool { + // on or before/after, not both + if !f.on.IsZero() { + if !same_day(f.on, modified) { + return false + } + } else { + if !f.after.IsZero() && f.after.After(modified) { + return false + } + if !f.before.IsZero() && f.before.Before(modified) { + return false + } + } + if f.has_regex() && !f.matcher.MatchString(filename) { + return false + } + if f.glob != "" { + if match, err := filepath.Match(f.glob, filename); err != nil || !match { + return false + } + } + if len(f.filenames) > 0 && !slices.Contains(f.filenames, filename) { + return false + } + // okay it was good + return true +} + +func (f *Filter) SetPattern(pattern string) error { + var err error + f.pattern = pattern + f.matcher, err = regexp.Compile(f.pattern) + return err +} + +func (f *Filter) Blank() bool { + t := time.Time{} + return !f.has_regex() && + f.glob == "" && + f.after.Equal(t) && + f.before.Equal(t) && + f.on.Equal(t) && + len(f.filenames) == 0 +} + +func (f *Filter) String() string { + var m string + if f.matcher != nil { + m = f.matcher.String() + } + return fmt.Sprintf("on:'%s' before:'%s' after:'%s' glob:'%s' regex:'%s' filenames:'%v'", + f.on, f.before, f.after, + f.glob, m, f.filenames, + ) +} + +func (f *Filter) has_regex() bool { + if f.matcher == nil { + return false + } + return f.matcher.String() != "" +} + +func New(o, b, a, g, p string, names ...string) (*Filter, error) { + // o b a g p + var ( + err error + now = time.Now() + ) + + f := &Filter{ + glob: g, + filenames: append([]string{}, names...), + } + + if o != "" { + on, err := anytime.Parse(o, now) + if err != nil { + return &Filter{}, err + } + f.on = on + } + + if a != "" { + after, err := anytime.Parse(a, now) + if err != nil { + return &Filter{}, err + } + f.after = after + } + + if b != "" { + before, err := anytime.Parse(b, now) + if err != nil { + return &Filter{}, err + } + f.before = before + } + + err = f.SetPattern(p) + + return f, err +} + +func same_day(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go new file mode 100644 index 0000000..8753a61 --- /dev/null +++ b/internal/filter/filter_test.go @@ -0,0 +1,378 @@ +package filter + +import ( + "fmt" + "testing" + "time" +) + +var ( + now = time.Now() + yesterday = now.AddDate(0, 0, -1) + ereyesterday = now.AddDate(0, 0, -2) + oneweekago = now.AddDate(0, 0, -7) + twoweeksago = now.AddDate(0, 0, -14) + onemonthago = now.AddDate(0, -1, 0) + twomonthsago = now.AddDate(0, -2, 0) + fourmonthsago = now.AddDate(0, -4, 0) + oneyearago = now.AddDate(-1, 0, 0) + twoyearsago = now.AddDate(-2, 0, 0) + fouryearsago = now.AddDate(-4, 0, 0) +) + +type testholder struct { + pattern, glob string + before, after, on string + filenames []string + good, bad []singletest +} + +func (t testholder) String() string { + return fmt.Sprintf("pattern:'%s' glob:'%s' filenames:'%v' before:'%s' after:'%s' on:'%s'", t.pattern, t.glob, t.filenames, t.before, t.after, t.on) +} + +type singletest struct { + filename string + modified time.Time +} + +func (s singletest) String() string { + return fmt.Sprintf("filename:'%s' modified:'%s'", s.filename, s.modified) +} + +func testmatch(t *testing.T, testers []testholder) { + const testnamefmt string = "file %s modified on %s" + var ( + f *Filter + err error + ) + for _, tester := range testers { + f, err = New(tester.on, tester.before, tester.after, tester.glob, tester.pattern, tester.filenames...) + if err != nil { + t.Fatal(err) + } + + for _, tst := range tester.good { + t.Run(fmt.Sprintf(testnamefmt+"_good", tst.filename, tst.modified), func(t *testing.T) { + if !f.Match(tst.filename, tst.modified) { + t.Fatalf("(filename:%s modified:%s) didn't match (%s) but should have", tst.filename, tst.modified, tester) + } + }) + } + + for _, tst := range tester.bad { + t.Run(fmt.Sprintf(testnamefmt+"_bad", tst.filename, tst.modified), func(t *testing.T) { + if f.Match(tst.filename, tst.modified) { + t.Fatalf("(filename:%s modified:%s) matched (%s) but shouldn't have", tst.filename, tst.modified, tester) + } + }) + } + } +} + +func blankfilename(times ...time.Time) []singletest { + out := make([]singletest, 0, len(times)) + for _, time := range times { + out = append(out, singletest{filename: "blank.txt", modified: time}) + } + return out +} + +func blanktime(filenames ...string) []singletest { + out := make([]singletest, 0, len(filenames)) + for _, filename := range filenames { + out = append(out, singletest{filename: filename, modified: time.Time{}}) + } + return out +} + +func TestFilterOn(t *testing.T) { + testmatch(t, []testholder{ + { + on: "2024-02-14", + good: blankfilename(time.Date(2024, 2, 14, 12, 0, 0, 0, time.Local)), + bad: blankfilename(now, now.Add(time.Hour*72), now.Add(-time.Hour*18)), + }, + { + on: "yesterday", + good: blankfilename(yesterday), + bad: blankfilename(now, oneweekago, onemonthago, oneyearago, twoweeksago, twomonthsago, twoyearsago), + }, + { + on: "one week ago", + good: blankfilename(oneweekago), + bad: blankfilename(now), + }, + { + on: "one month ago", + good: blankfilename(onemonthago), + bad: blankfilename(now), + }, + { + on: "two months ago", + good: blankfilename(twomonthsago), + bad: blankfilename(now), + }, + { + on: "four months ago", + good: blankfilename(fourmonthsago), + bad: blankfilename(now), + }, + { + on: "one year ago", + good: blankfilename(oneyearago), + bad: blankfilename(now), + }, + { + on: "four years ago", + good: blankfilename(fouryearsago), + bad: blankfilename(now), + }, + }) +} + +func TestFilterAfter(t *testing.T) { + testmatch(t, []testholder{ + { + after: "2020-02-14", + good: blankfilename(time.Date(2024, 3, 14, 12, 0, 0, 0, time.Local), now, yesterday), + bad: blankfilename(time.Date(2018, 2, 14, 12, 0, 0, 0, time.Local)), + }, + { + after: "yesterday", + good: blankfilename(yesterday, yesterday.AddDate(1, 0, 0), now, now.AddDate(0, 3, 0)), + bad: blankfilename(yesterday.AddDate(-1, 0, 0), yesterday.AddDate(0, 0, -1), ereyesterday), + }, + { + after: "one week ago", + good: blankfilename(now), + bad: blankfilename(oneweekago.AddDate(0, 0, -1)), + }, + { + after: "one month ago", + good: blankfilename(now, oneweekago, twoweeksago), + bad: blankfilename(onemonthago, twomonthsago, fourmonthsago, oneyearago), + }, + { + after: "two months ago", + good: blankfilename(now, onemonthago, oneweekago), + bad: blankfilename(twomonthsago, oneyearago, fourmonthsago), + }, + { + after: "four months ago", + good: blankfilename(now, oneweekago, onemonthago, twoweeksago, twomonthsago, onemonthago), + bad: blankfilename(fourmonthsago, oneyearago), + }, + { + after: "one year ago", + good: blankfilename(now, onemonthago, twomonthsago, fourmonthsago), + bad: blankfilename(oneyearago, fouryearsago, twoyearsago), + }, + { + after: "four years ago", + good: blankfilename(now, twoyearsago, onemonthago, fourmonthsago), + bad: blankfilename(fouryearsago, fouryearsago.AddDate(-1, 0, 0)), + }, + }) +} + +func TestFilterBefore(t *testing.T) { + testmatch(t, []testholder{ + { + before: "2024-02-14", + good: blankfilename(time.Date(2020, 2, 14, 12, 0, 0, 0, time.Local), time.Date(1989, 8, 13, 18, 53, 0, 0, time.Local)), + bad: blankfilename(now, now.AddDate(0, 0, 10), now.AddDate(0, -2, 0)), + }, + { + before: "yesterday", + good: blankfilename(onemonthago, oneweekago, oneyearago), + bad: blankfilename(now, now.AddDate(0, 0, 1)), + }, + { + before: "one week ago", + good: blankfilename(onemonthago, oneyearago, twoweeksago), + bad: blankfilename(yesterday, now), + }, + { + before: "one month ago", + good: blankfilename(oneyearago, twomonthsago), + bad: blankfilename(oneweekago, yesterday, now), + }, + { + before: "two months ago", + good: blankfilename(fourmonthsago, oneyearago), + bad: blankfilename(onemonthago, oneweekago, yesterday, now), + }, + { + before: "four months ago", + good: blankfilename(oneyearago, twoyearsago, fouryearsago), + bad: blankfilename(twomonthsago, onemonthago, oneweekago, yesterday, now), + }, + { + before: "one year ago", + good: blankfilename(twoyearsago, fouryearsago), + bad: blankfilename(fourmonthsago, twomonthsago, onemonthago, oneweekago, yesterday, now), + }, + { + before: "four years ago", + good: blankfilename(fouryearsago.AddDate(-1, 0, 0), fouryearsago.AddDate(-4, 0, 0)), + bad: blankfilename(oneyearago, fourmonthsago, twomonthsago, onemonthago, oneweekago, yesterday, now), + }, + }) +} + +func TestFilterMatch(t *testing.T) { + testmatch(t, []testholder{ + { + pattern: "[Tt]est", + good: blanktime("test", "Test"), + bad: blanktime("TEST", "tEst", "tEST", "TEst"), + }, + { + pattern: "^h.*o$", + good: blanktime("hello", "hippo", "how about some pasta with alfredo"), + bad: blanktime("hi", "test", "hellO", "Hello", "oh hello there"), + }, + }) +} + +func TestFilterGlob(t *testing.T) { + testmatch(t, []testholder{ + { + glob: "*.txt", + good: blanktime("test.txt", "alsotest.txt"), + bad: blanktime("test.md", "test.go", "test.tar.gz", "testxt", "test.text"), + }, + { + glob: "*.tar.*", + good: blanktime("test.tar.gz", "test.tar.xz", "test.tar.zst", "test.tar.bz2"), + bad: blanktime("test.tar", "test.txt", "test.targz", "test.tgz"), + }, + { + glob: "pot*o", + good: blanktime("potato", "potdonkeyo", "potesto"), + bad: blanktime("salad", "test", "alsotest"), + }, + { + glob: "t?st", + good: blanktime("test", "tast", "tfst", "tnst"), + bad: blanktime("best", "fast", "most", "past"), + }, + }) +} + +func TestFilterFilenames(t *testing.T) { + testmatch(t, []testholder{ + { + filenames: []string{"test.txt", "alsotest.txt"}, + good: blanktime("test.txt", "alsotest.txt"), + bad: blanktime("test.md", "test.go", "test.tar.gz", "testxt", "test.text"), + }, + { + filenames: []string{"test.md", "test.txt"}, + good: blanktime("test.txt", "test.md"), + bad: blanktime("alsotest.txt", "test.go", "test.tar.gz", "testxt", "test.text"), + }, + { + filenames: []string{"hello.world"}, + good: blanktime("hello.world"), + bad: blanktime("test.md", "test.go", "test.tar.gz", "testxt", "test.text", "helloworld", "Hello.world"), + }, + }) +} + +func TestFilterMultipleParameters(t *testing.T) { + testmatch(t, []testholder{ + { + pattern: "[Tt]est", + before: "yesterday", + good: []singletest{ + {filename: "test", modified: oneweekago}, + {filename: "test", modified: twoweeksago}, + {filename: "Test", modified: onemonthago}, + {filename: "Test", modified: fourmonthsago}, + }, + bad: []singletest{ + {filename: "test", modified: now}, + {filename: "salad", modified: oneweekago}, + {filename: "holyshit", modified: onemonthago}, + }, + }, + { + glob: "*.tar.*", + before: "yesterday", + after: "one month ago", + good: []singletest{ + {filename: "test.tar.xz", modified: oneweekago}, + {filename: "test.tar.gz", modified: twoweeksago}, + {filename: "test.tar.zst", modified: twoweeksago.AddDate(0, 0, 2)}, + {filename: "test.tar.bz2", modified: twoweeksago.AddDate(0, 0, -4)}, + }, + bad: []singletest{ + {filename: "test.tar.gz", modified: oneyearago}, + {filename: "test.targz", modified: oneweekago}, + {filename: "test.jpg", modified: ereyesterday}, + }, + }, + { + on: "today", + after: "two weeks ago", + before: "one week ago", + good: blankfilename(now, time.Date(now.Year(), now.Month(), now.Day(), 18, 42, 0, 0, time.Local), time.Date(now.Year(), now.Month(), now.Day(), 8, 17, 33, 0, time.Local)), + bad: blankfilename(yesterday, oneweekago, onemonthago, oneyearago), + }, + }) +} + +func TestFilterBlank(t *testing.T) { + var f *Filter + t.Run("new", func(t *testing.T) { + f, _ = New("", "", "", "", "") + if !f.Blank() { + t.Fatalf("filter isn't blank? %s", f) + } + }) + + t.Run("blank", func(t *testing.T) { + f = &Filter{} + if !f.Blank() { + t.Fatalf("filter isn't blank? %s", f) + } + }) +} + +func TestFilterNotBlank(t *testing.T) { + var ( + f *Filter + testers = []testholder{ + { + pattern: "[Ttest]", + }, + { + glob: "*test*", + }, + { + before: "yesterday", + after: "one week ago", + }, + { + on: "2024-06-06", + }, + { + filenames: []string{"hello"}, + }, + { + filenames: []string{""}, + }, + } + ) + + for _, tester := range testers { + t.Run("notblank"+tester.String(), func(t *testing.T) { + f, _ = New(tester.on, tester.before, tester.after, tester.glob, tester.pattern, tester.filenames...) + if f.Blank() { + t.Fatalf("filter is blank?? %s", f) + } + }) + } +} diff --git a/internal/trash/trash.go b/internal/trash/trash.go new file mode 100644 index 0000000..92a03ca --- /dev/null +++ b/internal/trash/trash.go @@ -0,0 +1,236 @@ +// Package trash finds and displays files located in the trash, and moves +// files into the trash, creating cooresponding .trashinfo files +package trash + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "git.burning.moe/celediel/gt/internal/filter" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/charmbracelet/log" + "github.com/dustin/go-humanize" + "gitlab.com/tymonx/go-formatter/formatter" + "gopkg.in/ini.v1" +) + +const ( + trash_info_ext string = ".trashinfo" + trash_info_sec string = "Trash Info" + trash_info_path string = "Path" + trash_info_date string = "DeletionDate" + trash_info_date_fmt string = "2006-01-02T15:04:05" + trash_info_template string = `[Trash Info] +Path={path} +DeletionDate={date}` +) + +type Info struct { + name, ogpath string + path, trashinfo string + trashed time.Time + filesize int64 +} + +type Infos []Info + +func (i Info) Name() string { return i.name } +func (i Info) Path() string { return i.path } +func (i Info) OGPath() string { return i.ogpath } +func (i Info) TrashInfo() string { return i.trashinfo } +func (i Info) Trashed() time.Time { return i.trashed } +func (i Info) Filesize() int64 { return i.filesize } + +func (is Infos) Table(width int) string { + + // sort newest on top + slices.SortStableFunc(is, SortByTrashedReverse) + out := [][]string{} + for _, file := range is { + t := humanize.Time(file.trashed) + b := humanize.Bytes(uint64(file.filesize)) + out = append(out, []string{ + file.name, + filepath.Dir(file.ogpath), + t, + b, + }) + } + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))). + Width(width). + Headers("filename", "original path", "deleted", "size"). + Rows(out...) + + return fmt.Sprint(t) +} + +func (is Infos) Show(width int) { + fmt.Println(is.Table(width)) +} + +func FindFiles(trashdir string, f *filter.Filter) (files Infos, outerr error) { + outerr = filepath.WalkDir(trashdir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + log.Debugf("what happened?? what is %s?", err) + return err + } + + // ignore self, directories, and non trashinfo files + if path == trashdir || d.IsDir() || filepath.Ext(path) != trash_info_ext { + return nil + } + + // trashinfo is just an ini file, so + c, err := ini.Load(path) + if err != nil { + return err + } + if s := c.Section(trash_info_sec); s != nil { + basepath := s.Key(trash_info_path).String() + filename := filepath.Base(basepath) + // maybe this is kind of a HACK + trashedpath := strings.Replace(strings.Replace(path, "info", "files", 1), trash_info_ext, "", 1) + info, _ := os.Stat(trashedpath) + + s := s.Key(trash_info_date).Value() + date, err := time.ParseInLocation(trash_info_date_fmt, s, time.Local) + if err != nil { + return err + } + + if f.Match(filename, date) { + log.Debugf("%s: deleted on %s", filename, date.Format(trash_info_date_fmt)) + files = append(files, Info{ + name: filename, + path: trashedpath, + ogpath: basepath, + trashinfo: path, + trashed: date, + filesize: info.Size(), + }) + } else { + log.Debugf("(ignored) %s: deleted on %s", filename, date.Format(trash_info_date_fmt)) + } + + } + return nil + }) + if outerr != nil { + return []Info{}, outerr + } + return +} + +func Restore(files []Info) (restored int, err error) { + for _, file := range files { + log.Infof("restoring %s back to %s\n", file.name, file.ogpath) + if err = os.Rename(file.path, file.ogpath); err != nil { + return restored, err + } + if err = os.Remove(file.trashinfo); err != nil { + return restored, err + } + restored++ + } + fmt.Printf("restored %d files\n", restored) + return restored, err +} + +func Remove(files []Info) (removed int, err error) { + for _, file := range files { + log.Infof("removing %s permanently forever!!!", file.name) + if err = os.Remove(file.path); err != nil { + return removed, err + } + if err = os.Remove(file.trashinfo); err != nil { + return removed, err + } + removed++ + } + return removed, err +} + +func TrashFile(trashDir, name string) error { + outdir := filepath.Join(trashDir, "files") + trashout := filepath.Join(trashDir, "info") + + filename := filepath.Base(name) + trashinfo_filename := filepath.Join(trashout, filename+trash_info_ext) + + out_path := filepath.Join(outdir, filename) + if err := os.Rename(name, out_path); err != nil { + return err + } + + trash_info, err := formatter.Format(trash_info_template, formatter.Named{ + "path": name, + "date": time.Now().Format(trash_info_date_fmt), + }) + if err != nil { + return err + } + + if err := os.WriteFile(trashinfo_filename, []byte(trash_info), fs.FileMode(0600)); err != nil { + return err + } + return nil +} + +func TrashFiles(trashDir string, files ...string) (trashed int, err error) { + for _, file := range files { + if err = TrashFile(trashDir, file); err != nil { + return trashed, err + } + trashed++ + } + return trashed, err +} + +func SortByTrashed(a, b Info) int { + if a.trashed.After(b.trashed) { + return 1 + } else if a.trashed.Before(b.trashed) { + return -1 + } else { + return 0 + } +} + +func SortByTrashedReverse(a, b Info) int { + if a.trashed.Before(b.trashed) { + return 1 + } else if a.trashed.After(b.trashed) { + return -1 + } else { + return 0 + } +} + +func SortBySize(a, b Info) int { + if a.filesize > b.filesize { + return 1 + } else if a.filesize < b.filesize { + return -1 + } else { + return 0 + } +} + +func SortBySizeReverse(a, b Info) int { + if a.filesize < b.filesize { + return 1 + } else if a.filesize > b.filesize { + return -1 + } else { + return 0 + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..c85ec88 --- /dev/null +++ b/justfile @@ -0,0 +1,43 @@ +binary := "gt" +build_dir := "bin" +cmd := "." +output := "." / build_dir / binary + +# do the thing +default: test check install + +# build binary +build: + go build -o {{output}} {{cmd}} + +# build windows binary +build-windows: + GOOS=windows GOARCH=amd64 go build -o {{output}}.exe {{cmd}} + +# run from source +run: + go run {{cmd}} + +# build 'n run +run-binary: build + exec {{output}} + +# run with args +run-args args: + go run {{cmd}} {{args}} + +# install binary into $GOPATH +install: + go install {{cmd}} + +# clean up after yourself +clean: + rm {{output}} + +# run go tests +test: + gotestsum + +# run linter +check: + staticcheck ./... diff --git a/main.go b/main.go new file mode 100644 index 0000000..e0f8ff7 --- /dev/null +++ b/main.go @@ -0,0 +1,296 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "git.burning.moe/celediel/gt/internal/files" + "git.burning.moe/celediel/gt/internal/filter" + "git.burning.moe/celediel/gt/internal/trash" + + "github.com/adrg/xdg" + "github.com/charmbracelet/log" + "github.com/urfave/cli/v2" + "golang.org/x/term" +) + +const ( + appname string = "gt" + appdesc string = "xdg trash cli" + appversion string = "v0.0.1" +) + +var ( + loglvl string + f *filter.Filter + o, b, a, g, p string + workdir string + recursive bool + termwidth int + + trashDir = filepath.Join(xdg.DataHome, "Trash") + + before_all = func(ctx *cli.Context) (err error) { + // setup log + log.SetReportTimestamp(true) + log.SetTimeFormat(time.TimeOnly) + if l, e := log.ParseLevel(loglvl); e == nil { + log.SetLevel(l) + // Some extra info for debug level + if log.GetLevel() == log.DebugLevel { + log.SetReportCaller(true) + } + } + + w, _, e := term.GetSize(int(os.Stdout.Fd())) + if e != nil { + w = 80 + } + termwidth = w + + return + } + + before_commands = func(ctx *cli.Context) (err error) { + // setup filter + if f == nil { + f, err = filter.New(o, b, a, g, p, ctx.Args().Slice()...) + } + log.Debugf("filter: %s", f.String()) + return + } + + after = func(ctx *cli.Context) error { + return nil + } + + do_trash = &cli.Command{ + Name: "trash", + Aliases: []string{"tr"}, + Usage: "trash a file or files", + Flags: slices.Concat(trash_flags, filter_flags), + Before: before_commands, + Action: func(ctx *cli.Context) error { + fls, err := files.Find(workdir, recursive, f) + if err != nil { + return err + } + if len(fls) == 0 { + fmt.Println("no files to trash") + return nil + } + + fls.Show(termwidth) + if confirm(fmt.Sprintf("trash these %d files?", len(fls))) { + tfs := make([]string, 0, len(fls)) + for _, file := range fls { + log.Debugf("gonna trash %s", file.Path()) + tfs = append(tfs, file.Path()) + } + + trashed, err := trash.TrashFiles(trashDir, tfs...) + if err != nil { + return err + } + log.Printf("trashed %d files", trashed) + } else { + log.Info("not gonna do it") + return nil + } + return nil + }, + } + + do_list = &cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "list trashed files", + Flags: slices.Concat(filter_flags), + Before: before_commands, + Action: func(ctx *cli.Context) error { + log.Debugf("searching in directory %s for files", trashDir) + + // look for files + files, err := trash.FindFiles(trashDir, f) + + var msg string + if f.Blank() { + msg = "trash is empty" + } else { + msg = "no files to show" + } + + if len(files) == 0 { + fmt.Println(msg) + return nil + } else if err != nil { + return err + } + + // display them + files.Show(termwidth) + + return nil + }, + } + + do_restore = &cli.Command{ + Name: "restore", + Aliases: []string{"re"}, + Usage: "restore a trashed file or files", + Flags: slices.Concat(filter_flags), + Before: before_commands, + Action: func(ctx *cli.Context) error { + log.Debugf("searching in directory %s for files", trashDir) + + // look for files + files, err := trash.FindFiles(trashDir, f) + if len(files) == 0 { + fmt.Println("no files to restore") + return nil + } else if err != nil { + return err + } + + files.Show(termwidth) + if confirm(fmt.Sprintf("restore these %d files?", len(files))) { + log.Info("doing the thing") + restored, err := trash.Restore(files) + if err != nil { + return fmt.Errorf("restored %d files before error %s", restored, err) + } + log.Printf("restored %d files\n", restored) + } else { + log.Info("not gonna do it") + } + + return nil + }, + } + + do_clean = &cli.Command{ + Name: "clean", + Aliases: []string{"cl"}, + Usage: "clean files from trash", + Flags: slices.Concat(filter_flags), + Before: before_commands, + Action: func(ctx *cli.Context) error { + files, err := trash.FindFiles(trashDir, f) + if len(files) == 0 { + fmt.Println("no files to clean") + return nil + } else if err != nil { + return err + } + + files.Show(termwidth) + if confirm(fmt.Sprintf("remove these %d files permanently from the trash?", len(files))) && + confirm(fmt.Sprintf("really remove all %d of these files permanently from the trash forever??", len(files))) { + log.Info("gonna remove some files forever") + removed, err := trash.Remove(files) + if err != nil { + return fmt.Errorf("removed %d files before error %s", removed, err) + } + log.Printf("removed %d files\n", removed) + } else { + log.Printf("left %d files alone", len(files)) + } + return nil + }, + } + + global_flags = []cli.Flag{ + &cli.StringFlag{ + Name: "log", + Usage: "Log level", + Value: "warn", + Aliases: []string{"l"}, + Destination: &loglvl, + }, + } + + filter_flags = []cli.Flag{ + &cli.StringFlag{ + Name: "match", + Usage: "operate on files matching regex `PATTERN`", + Aliases: []string{"m"}, + Destination: &p, + }, + &cli.StringFlag{ + Name: "glob", + Usage: "operate on files matching `GLOB`", + Aliases: []string{"g"}, + Destination: &g, + }, + &cli.StringFlag{ + Name: "on", + Usage: "operate on files modified on `DATE`", + Aliases: []string{"o"}, + Destination: &o, + }, + &cli.StringFlag{ + Name: "after", + Usage: "operate on files modified before `DATE`", + Aliases: []string{"a"}, + Destination: &a, + }, + &cli.StringFlag{ + Name: "before", + Usage: "operate on files modified after `DATE`", + Aliases: []string{"b"}, + Destination: &b, + }, + } + + trash_flags = []cli.Flag{ + &cli.BoolFlag{ + Name: "recursive", + Usage: "trash files recursively", + Aliases: []string{"r"}, + Destination: &recursive, + Value: false, + DisableDefaultText: true, + }, + &cli.PathFlag{ + Name: "work-dir", + Usage: "trash files in this `DIRECTORY`", + Aliases: []string{"w"}, + Destination: &workdir, + }, + } +) + +func main() { + app := &cli.App{ + Name: appname, + Usage: appdesc, + Version: appversion, + Before: before_all, + After: after, + Commands: []*cli.Command{do_trash, do_list, do_restore, do_clean}, + Flags: global_flags, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +func confirm(s string) bool { + r := bufio.NewReader(os.Stdin) + fmt.Printf("%s [y/n]: ", s) + got, err := r.ReadString('\n') + if err != nil { + log.Fatal(err) + } + if len(got) < 2 { + return false + } else { + return strings.ToLower(strings.TrimSpace(got))[0] == 'y' + } +}