diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/COPYING @@ -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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..54877f8 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +Pygenda +======= +Pygenda is a calendar/agenda application written in Python3/GTK3 and +designed for "PDA" devices such as Planet Computers' Gemini. The user +interface is inspired by patterns in the Agenda programs on the Psion +Series 3 and Series 5 range of PDAs. + +**WARNING: This is in-development code, released as a preview for +developers. It will probably corrupt your data.** + +There are currently **lots of missing/incomplete features** as well as +**bugs**. For a list of known issues, see: docs/known_issues.md + +Source code +----------- +Source is available at: https://github.com/semiprime/pygenda + +License +------- +Pygenda 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, version 3. + +Pygenda 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 Pygenda (see COPYING file). If not, see . + +Run/install +----------- +(See note about dependencies below.) + +To run without installing, cd to the root pygenda directory, and do: + + python3 -m pygenda + +(If you want to run this way using a graphical launcher, set the +working directory in the launcher settings, and use the above command.) + +Better: install the python module with (for example)... + + ./setup.py install --user + +(You can uninstall the module with `pip3 uninstall pygenda`.) + +NOTE: Gemian (Debian port for Gemini PDA) doesn't install the Python module +dependencies. I recommend installing these dependencies manually - see +below. The reason for this appears to be old pip/setuptools on Gemian. +You can upgrade these using `pip3 install --upgrade [pip|setuptools]`, +but that won't fix the install because pip3 tries to get a version of +PyGObject that requires a more-recent-than-installed version of glib. + +Then you can run from anywhere with: + + python3 -m pygenda + +There are a few command-line options, which you can view using: + + python3 -m pygenda --help + +For more complete settings, see "Configuration", below. + +Dependencies +------------ +Python3. Version >=3.5 (because Gemini's "Gemian" Linux provides Python 3.5). + +* Install on Debian/Gemian: `sudo apt install python3` + +GTK+3 + +* Install on Debian: `sudo apt install gtk+3` + +Python3 modules: PyGObject3 (for gi), icalendar, python-dateutil + +* Install on Debian: `sudo apt install python3-gi python3-icalendar python3-dateutil` +* Or install them using pip3: `pip3 install pygobject icalendar python-dateutil` + +That should be enough to start Pygenda, but if you want to use a +CalDAV server (recommended for real use) there are some extra +dependencies. See setup details in: docs/CalDAV.md + +Configuration +------------- +Configuration settings go in file: `~/.config/pygenda/pygenda.ini` + +Custom CSS goes in: `~/.config/pygenda/pygenda.css` + +More information: docs/config-examples/README.md + +Quick config on Gemini/other handhelds +-------------------------------------- +If you're running Pygenda on a Gemini or similar PDA, the default font +sizes will probably not be appropriate for the screen size. To fix +this, use the custom CSS provided in docs/config-examples/gemini.css. +The easiest way to do this is to import the gemini.css file from your +own ~/.config/pygenda/pygenda.css file, by adding the line: + + @import "PATH_TO_GEMINI_CSS_FILE"; + +You can then add your own custom CSS after this. (This way, if you +git pull an update to the Pygenda source, then you'll automatically +get any new css rules included in the new version.) + +The "startup/maximized" and "startup/fullscreen" options are also +useful for devices with small screens. See "Configuation" above. + +Usage +----- +See: docs/Usage.md + +Calendar data storage - a CalDAV server is recommended +------------------------------------------------------ +Calendar data can be stored as an ICS file, or via a CalDAV server. +The ICS file is the default, because it works without configuration, +but a CalDAV server is recommended for real use. + +For CalDAV configuration, see: docs/CalDAV.md + +The default ICS file is created in `~/.config/pygenda/pygenda.ics` +but you can change this from the command line or config file. + +Alternatives +------------ +If you want to compare the "competition", the Gemian people also have +an in-development agenda-like app designed for the Gemini/Cosmo. +Details at https://gemian.thinkglobally.org/#Calendar diff --git a/csrc/Makefile b/csrc/Makefile new file mode 100644 index 0000000..05515c5 --- /dev/null +++ b/csrc/Makefile @@ -0,0 +1,33 @@ +NAME = pygenda_clipboard + +SRC = $(NAME).c +TARGET = lib$(NAME).so + +INCLUDE_DIR = /usr/include + +MACHINE = $(shell uname -m) + +ifeq ($(MACHINE), x86_64) + INCLUDE_PLATFORM_DIR = /usr/lib64 +else ifeq ($(MACHINE), aarch64) + INCLUDE_PLATFORM_DIR = /usr/lib/aarch64-linux-gnu +else + INCLUDE_PLATFORM_DIR = /usr/lib +endif + +CC_INCLUDE_DIRS = -I$(INCLUDE_DIR)/gtk-3.0 -I$(INCLUDE_DIR)/glib-2.0 -I$(INCLUDE_PLATFORM_DIR)/glib-2.0/include -I$(INCLUDE_DIR)/pango-1.0 -I$(INCLUDE_DIR)/harfbuzz -I$(INCLUDE_DIR)/cairo -I$(INCLUDE_DIR)/gdk-pixbuf-2.0 -I$(INCLUDE_DIR)/atk-1.0 + +# Targets... + +$(TARGET): $(SRC) + gcc $(CC_INCLUDE_DIRS) -shared -o $(TARGET) -fPIC $(SRC) + strip $(TARGET) + +cp: $(TARGET) + cp $(TARGET) ../pygenda/ + +uninstall: + rm -rf ../pygenda/$(TARGET) + +clean: + rm -rf $(TARGET) diff --git a/csrc/pygenda_clipboard.c b/csrc/pygenda_clipboard.c new file mode 100644 index 0000000..7621b5c --- /dev/null +++ b/csrc/pygenda_clipboard.c @@ -0,0 +1,88 @@ +// pygenda_clipboard.c +// +// Small C library to interface Pygenda with GTK clipboard. +// Allows agenda entries to be copied to the clipboard. +// +// Copyright (C) 2022 Matthew Lewis +// +// This file is part of Pygenda. +// +// Pygenda 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, version 3. +// +// Pygenda 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 Pygenda. If not, see . +// + +#include +#include +#include + +// Constants + +// Enumerated types of clipboard selection data we can store: +#define DATA_TXT_PLAIN (0) +#define DATA_TXT_CALENDAR (1) +// Number of data types +#define LEN_SELECTION_DATA (2) + +// Map requested types (strings) to our enumerated types +GtkTargetEntry target_array[] = { + {"text/plain;charset=utf-8", 0, DATA_TXT_PLAIN}, + {"UTF8_STRING", 0, DATA_TXT_PLAIN}, + {"TEXT", 0, DATA_TXT_PLAIN}, + {"STRING", 0, DATA_TXT_PLAIN}, + {"text/calendar", 0, DATA_TXT_CALENDAR} +}; + +// Macro for length of target_array[] +#define LEN_TARGET_ARRAY (sizeof(target_array)/sizeof(GtkTargetEntry)) + +// We store pointers to our data here: +// Assume this are zero initialised +char *selectionStr[LEN_SELECTION_DATA]; + +// Callback - called when data is requested +// type_idx is set to enumerated value type +void cb_get_fn(GtkClipboard* clipboard, GtkSelectionData* selection_data, guint type_idx, gpointer ptr) { + switch(type_idx) { + case DATA_TXT_PLAIN: + gtk_selection_data_set_text(selection_data, selectionStr[DATA_TXT_PLAIN], -1); + break; + case DATA_TXT_CALENDAR: + gtk_selection_data_set(selection_data, gdk_atom_intern("text/calendar", FALSE),8,selectionStr[type_idx], strlen(selectionStr[type_idx])); + break; + } +} + +// Callback - called when data is no longer needed (e.g. something +// else is copied) +void cb_clear_fn(GtkClipboard* clipboard, gpointer ptr) { + int i=0; + for ( ; i Session Settings -> +Autostart, and add the command "`python3 -m radicale`". diff --git a/docs/Development.md b/docs/Development.md new file mode 100644 index 0000000..4ffc89e --- /dev/null +++ b/docs/Development.md @@ -0,0 +1,102 @@ +Pygenda Development +=================== +Miscellaneous hints/notes for development. + +Installing in Develop mode +-------------------------- +In Python, you can install modules in Develop mode with: + + ./setup.py develop [--user] + +Rather than copying the files over, this just creates a link to the +source directory, so any changes you make in the source are instantly +available in the "installed" version. + +(Note that if you install in Develop mode, you'll need to build the +clipboard library manually: cd csrc; make; make cp.) + +Building clipboard library +-------------------------- +This is a small C library required for cutting/copying entries. Built +automatically with `./setup.py install`. Tested on Gemini, but probably +needs fixing for other Linux distributions/Windows/MacOS. To build and +copy to the correct location: + + cd csrc + make + make cp + +Translating strings +------------------- +Create a locale/pygenda.pot template from .py and .glade source +files. In the pygenda subdirectory: + + xgettext --package-name Pygenda --copyright-holder "Matthew Lewis" -k_ -kN_ -o locale/pygenda.pot *.py *.glade + +Use .pot to make .po for each language. + +Existing languages: + + cd locale + msgmerge -U fr/LC_MESSAGES/pygenda.po pygenda.pot + +To add new languages: + + cd locale + msginit -i pygenda.pot -o de/LC_MESSAGES/pygenda.po -l de_DE + +Check/edit the generated .po files, maybe adding translations. + +Process each .po to make .mo: + + cd fr/LC_MESSAGES + msgfmt pygenda.po -o pygenda.mo + +Checklist for releases +---------------------- +* Check new code is appropriately commented and annotated +* Run `mypy .` in source directory, check any new messages +* If any new dependencies are required, add them to setup.py +* Check setup.py install works & the installed module runs correctly +* Check copy/cut/paste works, including e.g. time of entry, multi-day entries +* Regenerate .po and .mo localisation files (see above) +* Check at least one non-English language +* Check any ics files in validator, eg https://icalendar.org/validator.html +* Check repeats in test02_repeats.ics displayed correctly +* Check darkmode & backgrounds CSS still work +* Check start_week_day!=Monday still works (all views) +* Increase version number + +Checking repeats +---------------- +Repeated entry display can be checked by hand (plan to automate later) by +enabling checking in repeats_in_range() method (file pygenda_calendar.py) +and skipping through the test02_repeats.ics file. (Try Year View.) + +Debugging +--------- +* On Gemini: + + sudo apt install libgtk-3-dev + gsettings set org.gtk.Settings.Debug enable-inspector-keybinding true + + Launch Pygenda & press ctrl+shift+d + +* Other Linux (tested on Slackware): + + GTK_DEBUG=interactive python3 -m pygenda + +Test setup +---------- +Can test setup.py by using a virtual Python environment: + python3 -m venv venv_dir + source venv_dir/bin/activate + cd PYGENDA_SRC_DIR + ./setup.py install + # Check no install errors + cd ~ + # Check pygenda runs with file + python3 -m pygenda -f test.ics + # (Optionally install caldav and check pygenda can use it) + deactivate + rm -r venv_dir diff --git a/docs/Usage.md b/docs/Usage.md new file mode 100644 index 0000000..a04d61c --- /dev/null +++ b/docs/Usage.md @@ -0,0 +1,34 @@ +Pygenda usage +============= +Usage is intended to be intuitive, but a few things are worth noting: + +* New entries can be created by starting to type a description. + +* Pressing Enter on an existing entry edits that entry. + +* **Primary navigation** is with the cursor keys (e.g. in Year View, the + cursor keys move the cursor around the year grid; in dialogs, cursor + keys move between widgets). + **Secondary navigation** is with shift+cursor keys (e.g. in Year View, + shift+up/down moves the second cursor between entries on the same + day; in dialogs, shift+up/down possibly modifies the widget content, + maybe by increasing/decreasing the value). + +* In addition, date/time fields, spin buttons (GUI elements to enter a + number), and comboboxes (choose option from dropdown list) can be + increased with + or >, and decreased with - or <. + +* Space toggles between Today and wherever the cursor was last. + +* If you want the Tab key to move within elements in date/time/duration + widgets then set the global/tab_elts_datetime config option to True. + +* Pressing y/m/d keys in date widget moves the cursor to year/month/day + fields respectively. Similarly, pressing h/m keys in time or duration + widget moves to hour/minute fields. (These shortcut keys are localised, + for example in French they are a/m/j/h/m.) + +* The icons in Year View are configurable via CSS (although it can be + a bit fiddly). The default CSS shows a star for yearly events, a + purple circle for other repeating events, and a yellow disc for other + events. diff --git a/docs/config-examples/README.md b/docs/config-examples/README.md new file mode 100644 index 0000000..9d6cbe7 --- /dev/null +++ b/docs/config-examples/README.md @@ -0,0 +1,14 @@ +Pygenda config-examples +======================= + +This directory contains some example configuration files. + +See defaults.ini for a (hopefully) complete list of settings. + +Either use them as provided by soft-linking to them from the +~/.config/pygenda/ directory as pygenda.css or pygenda.ini, copy to +that directory and edit to your own taste, or for css files @import +the files and make your own additions. + +You can also check the default css for ideas: css/pygenda.css from +the source directory. diff --git a/docs/config-examples/backgrounds.css b/docs/config-examples/backgrounds.css new file mode 100644 index 0000000..fcd8797 --- /dev/null +++ b/docs/config-examples/backgrounds.css @@ -0,0 +1,81 @@ +/* CSS examples for View backgrounds + * Not to be used as-is, just a selection of ideas that you can adapt. + */ + +/* Note: these haven't been tested when used with darkmode.css - some + modifications probably needed. */ + +/*** 1. Plain colors for each view ***/ +/* For nicer colors, find someone with a better graphic skills than me :-) */ + +#view_week { + background-color:#e9bbd2; +} + +#view_year { + background-color:#a2e6a2; +} + +/* Depending on your desktop, you may also want to set the scrollbar + background. I find this useful in XFCE, but not on the Gemini. + (For some reason, "transparent" doesn't seem to work here.) */ + +#view_week scrollbar trough { + background-color:#d5abc0; +} + +#view_year scrollbar trough { + background-color:#94d294; +} + + +/*** 2. Textured background ***/ + +#view_year, #view_week { + background-image:url("my_texture.jpg"); /* Provide image filename here */ + background-repeat:repeat; +} + + +/*** 3. Centered logo ***/ + +#view_year, #view_week { + background-image:url("my_logo.svg"); /* Provide image filename here */ + background-size:auto 90%; + background-position:center; + background-repeat:no-repeat; +} + +/* Typically, you'd use a faint version of the logo so the UI is + * readable on top of it. However, for complex layering you can + * also add alpha-channel variable transparency to the upper + * elements. Example below... + */ + +#weekview_titlearea, .weekview_day { + background-color:rgba(255,255,255,0.93); +} + +#weekview_fold { + background-image:linear-gradient(90deg, rgba(255,255,255,0.93) 0%, rgba(136,136,136,0.96) 35%, rgba(0,0,0,1) 50%, rgba(136,136,136,0.96) 65%, rgba(255,255,255,0.93) 100%); +} + +#yearview_labelyear, #yearview_gridcorner, .yearview_day_label, .yearview_month_label, .yearview_emptycell, .yearview_daycell, #yearview_labeldate, #yearview_datecontent { + background-color:rgba(255,255,255,0.93); +} + +.yearview_day_sat, .yearview_day_sun { + background-color:rgba(170,170,170,0.93); +} + +.yearview_pastday { + background-color:rgba(230,230,230,0.85); +} + +.yearview_pastday.yearview_day_sat, .yearview_pastday.yearview_day_sun { + background-color:rgba(187,187,187,0.93); +} + +.yearview_today { + background-color:rgba(0,187,187,0.93); +} diff --git a/docs/config-examples/darkmode.css b/docs/config-examples/darkmode.css new file mode 100644 index 0000000..6be502b --- /dev/null +++ b/docs/config-examples/darkmode.css @@ -0,0 +1,144 @@ +/* Colour changes for "dark mode" (just a demo, minimally tested) */ +/* NOTE: If you copy this to a different location, you might also need to + change the URLs in the "background-image" elements (or copy the files). */ + +/* Global */ + +#view_loading { + background-color:black; + color:#999; +} + +scrollbar trough { + background-color:#444; +} + + +/* Week View */ + +#view_week { + background-color:black; + color:#eee; +} + +#weekview_titlearea { + color:#eee; + border-color:#eee; +} + +#weekview_fold { + background-image:linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(160,160,160,0.9) 35%, rgba(230,230,230,1) 50%, rgba(160,160,160,0.9) 65%, rgba(0,0,0,0) 100%); +} + +.weekview_day { + border-color:#888; +} + +.weekview_labelday { + background-color:#333; + color:#eee; +} + +.weekview_today .weekview_labelday { + background-color:#088; + color:black; +} + +.weekview_pastday .weekview_labelday { + color:#aaa; +} + +.weekview_daytext, .weekview_marker { + color:#eee; +} + +.weekview_cursor { + background-color:#b00; +} + + +/* Year View */ + +#view_year { + background-color:black; + color:#eee; +} + +#yearview_labelyear { + background-color:black; +} + +.yearview_day_label, .yearview_month_label { + background-color:black; +} + +.yearview_emptycell { + background-color:#111; + border-right-color:#222; + border-bottom-color:#222; +} + +.yearview_daycell { + background-color:black; + border-color:#eee; +} + +.yearview_leftofdaycell { + border-right-color:#eee; +} + +.yearview_abovedaycell { + border-bottom-color:#eee; +} + +.yearview_day_sat, .yearview_day_sun { + background-color:#555; +} + +.yearview_cursor { + background-image:linear-gradient(90deg, #eee 9%, transparent 9%, transparent 91%, #eee 91%), linear-gradient(#eee 8%, transparent 8%, transparent 92%, #eee 92%); +} + +.yearview_today { + background-color:#088; +} + +.yearview_pastday { + background-color:#0b0b0b; +} + +.yearview_pastday.yearview_day_sat, .yearview_pastday.yearview_day_sun { + background-color:#444; +} + +.yearview_entry_cursor { + background-color:#b00; +} + +.yearview_entry_single.yearview_cursor, .yearview_entry_repeated_day.yearview_cursor, .yearview_entry_repeated_hour.yearview_cursor, .yearview_entry_repeated_minute.yearview_cursor, .yearview_entry_repeated_second.yearview_cursor { + background-image:url("../../pygenda/css/disc.svg"), linear-gradient(90deg, #eee 9%, transparent 9%, transparent 91%, #eee 91%), linear-gradient(#eee 8%, transparent 8%, transparent 92%, #eee 92%); +} + +.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_week.yearview_cursor { + background-image:url("../../pygenda/css/loop.svg"), linear-gradient(90deg, #eee 9%, transparent 9%, transparent 91%, #eee 91%), linear-gradient(#eee 8%, transparent 8%, transparent 92%, #eee 92%); +} + +.yearview_entry_repeated_year.yearview_cursor { + background-image:url("../../pygenda/css/star.svg"), linear-gradient(90deg, #eee 9%, transparent 9%, transparent 91%, #eee 91%), linear-gradient(#eee 8%, transparent 8%, transparent 92%, #eee 92%); +} + +.yearview_entry_single.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_single.yearview_entry_repeated_week.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_week.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_week.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_week.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_week.yearview_cursor { + background-image:url("../../pygenda/css/disc+loop.svg"), linear-gradient(90deg, #eee 9%, transparent 9%, transparent 91%, #eee 91%), linear-gradient(#eee 8%, transparent 8%, transparent 92%, #eee 92%); +} + +.yearview_entry_single.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_year.yearview_cursor { + background-image:url("../../pygenda/css/disc+star.svg"), linear-gradient(90deg, #eee 9%, transparent 9%, transparent 91%, #eee 91%), linear-gradient(#eee 8%, transparent 8%, transparent 92%, #eee 92%); +} + +.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor { + background-image:url("../../pygenda/css/loop+star.svg"), linear-gradient(90deg, #eee 9%, transparent 9%, transparent 91%, #eee 91%), linear-gradient(#eee 8%, transparent 8%, transparent 92%, #eee 92%); +} + +.yearview_entry_single.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_single.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor { + background-image:url("../../pygenda/css/disc+loop+star.svg"), linear-gradient(90deg, #eee 9%, transparent 9%, transparent 91%, #eee 91%), linear-gradient(#eee 8%, transparent 8%, transparent 92%, #eee 92%); +} diff --git a/docs/config-examples/defaults.ini b/docs/config-examples/defaults.ini new file mode 100644 index 0000000..14c96b4 --- /dev/null +++ b/docs/config-examples/defaults.ini @@ -0,0 +1,86 @@ +# Sample pygenda.ini file listing default values +# Should be saved as [config_dir]/pygenda/pygenda.ini +# (On Linux, [config_dir] is probably ~/.config) + +[calendar] +# ics_file = filename (ics format iCalendar file) +# Default: [config_dir]/pygenda/pygenda.ics + +# caldav_server = string (url, e.g. http://localhost:5232/ for Radicale server) +# caldav_username = string +# caldav_password = string +# caldav_calendar = string +# Defaults: all None (so ics file is used) +# Note: Using a calDAV server adds a dependency to pygenda: caldav. + + +[global] +# 24hr = Boolean +# Default: False (use 12-hour clock for display and input dialogs) + +# hide_titlebar_when_maximized = Boolean +# Default: False + +# language = language string (e.g. en_GB, fr_FR) +# Default: Use computer locale + +# date_sep = string +# Default: Use computer locale + +# date_ord = string +# Indicates order of date elements (YMD, MDY, DMY) +# Default: Use computer locale + +# time_sep = string +# Default: Use computer locale + +# date_fmt_text = string +# Formatting string for date in text format, should include year. +# Example, typical US format: %A %B %-d, %Y -> Monday December 31, 2001 +# Default: Use computer locale + +# date_fmt_text_noyear = string +# Formatting string for date in text format, without year. +# Used in Year View to display cursor date. +# Default: '' - indicates it should be constructed from date_fmt_text. + +# date_fmt_textabb' = string +# Formatting string for date in abbreviated text format, including year. +# Example, typical US format: %a %b %-d, %Y -> Mon Dec 31, 2001 +# Default: '' - indicates it should be constructed from date_fmt_text. + +# date_fmt_textabb_noyear = string +# Formatting string for date in abbreviated text format, without year. +# Default: '' - indicates it should be constructed from date_fmt_textabb. + +# start_week_day = int (0..6) +# Default: 0 (Monday) + +# tab_elts_datetime = Boolean +# This indicates if pressing tab moves between elements in +# date/time entry widgets. +# Default: False + + +[startup] +# maximize = Boolean +# Default: False + +# fullscreen = Boolean +# Default: False + +# view = string +# Default: week + +# softbutton_display = string +# Controls display of the softbutton bar (New Entry, View, etc.) +# 'left' => place on left; 'hide' => don't display; else => right +# Default: '' (=> right) + + +[weekview] +# pageleft_datepos = 'left' or 'right' +# Default: 'left' + +# pageright_datepos = 'left' or 'right' +# Default: 'right' diff --git a/docs/config-examples/gemini.css b/docs/config-examples/gemini.css new file mode 100644 index 0000000..e02549b --- /dev/null +++ b/docs/config-examples/gemini.css @@ -0,0 +1,125 @@ +/* Remove transition animations - they make Gemini feel slow */ +* { + transition-duration:0s; + transition-delay:0s; +} + + +/* GUI size increases for Gemini's small screen */ + +menubar, button { + font-size:15pt; +} + +dialog { + font-size:15pt; +} + +scrollbar.vertical, scrollbar.vertical slider, scrollbar button { + min-width:14px; +} + +scrollbar.horizontal, scrollbar.horizontal slider, scrollbar button { + min-height:14px; +} + +scrollbar trough { + background-color:#f0f0f0; +} + +scrollbar slider { + background-color:#c0c0c0; + border-radius:0; +} + + +/* Week View */ + +#weekview_titlearea { + border-width:3px; +} + +#weekview_labelmonth { + font-size:21pt; +} + +#weekview_labelweek { + font-size:13pt; +} + +.weekview_labelday { + font-size:18pt; + min-width:2.95em; +} + +.weekview_daytext { + font-size:15pt; +} + + +/* Year View */ + +#yearview_labelyear { + font-size:18pt; + padding:2px; +} + +#yearview_labeldate { + font-size:15pt; + padding:2px; +} + +#yearview_datecontent { + font-size:15pt; +} + + +/* Dialogs */ + +entry, spinbutton, date_entry, time_entry, duration_entry { + min-height:1.6em; +} + +/* Make buttons bigger (for easier touchscreen use) */ +buttonbox > button { + padding:5px; +} + +buttonbox > button:focus { + padding:4px; +} + +checkbutton { + min-height:32px; +} + +check { + min-height:24px; + min-width:24px; +} + +radio, check { + border-style:solid; + border-width:0px; + padding:2px; +} + +radio { + border-radius:8px; +} + +check { + border-radius:4px; +} + +radio:focus, check:focus { + border-color:#398ee7; + border-width:2px; + padding:0px; +} + +.llabel { + min-height:32px; + padding-right:0.3ex; + padding-left:0.3ex; +} diff --git a/docs/config-examples/gemini.ini b/docs/config-examples/gemini.ini new file mode 100644 index 0000000..ab9544c --- /dev/null +++ b/docs/config-examples/gemini.ini @@ -0,0 +1,3 @@ +[startup] +maximize=True +fullscreen=False diff --git a/docs/known_issues.md b/docs/known_issues.md new file mode 100644 index 0000000..1202db8 --- /dev/null +++ b/docs/known_issues.md @@ -0,0 +1,209 @@ +Known Issues +============ +A list of known bugs in & to-dos for Pygenda. + +This is incomplete in-development code, released as a preview for +developers. As such there are many major additions still to do, as +well as numerous bugs. This is a (certainly incomplete) list to help +keep track of these issues. + +Note also, that the code is released as-is and there is no guarantee +of any sort. + +Major +----- +* Missing views: day, list, to-do, busy + +* Alarms are not implemented (note: a separate server should handle + the actual alarm notifications) + +* To-dos are not implemented + +* Many entry elements are not implemented (details, attendees etc.) + +* Repeated entries UI missing many elements (more complete repeat by + BYMONTHDAY, BYSETPOS, Monday & Wednesday every week, extra dates, + hourly/minutely/secondly repeats). + See: https://icalendar.org/iCalendar-RFC-5545/3-3-10-recurrence-rule.html + Doesn't handle repeat by RDATE at all. + See: https://icalendar.org/iCalendar-RFC-5545/3-8-5-2-recurrence-date-times.html + +* Editing exception dates not very clean. E.g. can add "exceptions" + that are not part of the repeat run, and they are saved without + problem, and not highlighted in UI as problematic in any way; + dates can be added that are not visible because box doesn't scroll + to newly added date; entries can be made invisible (thus uneditable) + by making all occurrences exceptions; keyboard navigation in menu + is not intuitive. + +* Multi-day entries (i.e. that span multiple days) only displayed on + first day. (Need to set a config, e.g. "next_day_crossover" for week + view, year view. If time goes beyond that, then also show in next + day.) + +* Cutting repeating entries is not implemented. (Not sure how to do UI - + probably need to bring up a dialog "Cut just this occurrence, or all + repeats?". That then raises question about behaviour when copying + repeating entries, where currently just one occurrence is copied. See + also deleting repeated entries - currently all repeats are deleted.) + +* Creating/updating new entries in large calendars is unusably slow. + (In tests, with .ics file, file writing is slow; with CalDAV server + re-sorting the lists is slow.) + +* No "Find" function + +* No "Import" function + +* Only one calendar source can be used at a time. (Maybe allow multiple + CalDAV calendars, each with own style element, so can be coloured + differently by CSS. No support for multiple .ics files?) + +Medium +------ +* No "Zoom" function (is this even needed with a decent modern display?) + +* When deleting a repeated entry, all are deleted. Should offer option + of deleting this one (better, also offer delete all from, all before...) + +* No file manipulation: open, import, save-as, new file... + +* No time-zone support (should at least be part of time widget) + (Options: "Travels with me" (default), zone xxx (list local first)) + +* No GUI for configuration settings/preferences + +* Only French & English translations are provided (and the French needs + checking by a native speaker). + +* Setting number of days for "all day" entries could be friendlier + (e.g. show/set end date) + +* Timed events can't last more than 24 hours (need to augment duration widget) + +* No support for encryption/password protected entries + +* Currently, if using CalDAV server & the connection fails (e.g. server + halted), Pygenda just exits. Unclear what the best course is. To review + later. (In "delete event" scenario, an error message & event remains + probably good behaviour.) + +* Copying/cutting entries doesn't work on all platforms. This functionality + relies on a small C library, due to limits of Python GObject module. + Builds on my devices and on Gemini. Needs looking at for other Linux + distributions and Windows/MacOS. + +* Soft buttons need work. (Customisable, choose good defaults, add icons?) + +* Date/time widgets are not easy to click (tap) on since most of the area + does not respond to mouse clicks + +* On Psion Agenda, could specify letter to use for display in Year view. + Maybe add similar functionality by allowing user to add a Category + https://icalendar.org/iCalendar-RFC-5545/3-8-1-2-categories.html + This would add a "category_xxx" style to element, and css can be + used to style these as wanted (user-supplied css). + +* Potential optimisations when drawing Year View, which might make it + quicker when going from year to year. (See comments in source for + thoughts.) + +* Some repeat types are slow to calculate (fallback types, e.g. by day + in month). + Currently uses dateutil.rrule to calculate most complex repeats, + see: https://dateutil.readthedocs.io/en/stable/rrule.html + Check if using recurring-ical-events module could improve speed?? + See: https://pypi.org/project/recurring-ical-events/ + +* Starting date of repeating entries is not always obvious to the user. + For example, date 30th March, repeat on last day of month -> starts + 31st March, but change to 3rd-to-last day of month and it starts 28th + April. (Maybe this will be clearer when cursor moves to edited entry?) + +* If calendar data is updated externally while Pygenda is runing (e.g. + another instance of Pygenda, or some other app, updates database) + the changes are not detected/displayed. + +* In Entry dialog, repeats until/occurrences. Until date can be wrong if + occurrences very high; occurrences can be wrong if date in far future. + +* "Today" marked in views is not updated if day changes (e.g. midnight + crossover, switch on device in new day, device time(zone) changed). + +Minor +----- +(Note: minor bugs can still be irritating!) + +* After creating/editing an entry, cursor not always on that entry + +* Would be nice to be able to edit dates with a calendar interface + (Like pressing tab in S5) + +* Week view: no touchscreen way to go forward/back a week (use swipe??) + +* Ctrl+Left-Shift+X/C/V/N don't work on Gemini (UI eats keypress?) + Workaround: Use Right-Shift. + +* Anniversary entry year (e.g. "20 years") not displayed (they're + just annual repeats). (Maybe add category "Anniversary"?) + +* Entry dialog needs some indication if tab contents are non-default + (e.g. if there are repeats, alarms - maybe show a tick in the tab handle) + +* No user feedback for some actions (e.g. copy entry). Should flash or + recolour cursor or something. + +* Menu shortcuts set in glade are not translated (e.g. aller à = ctrl+g) + +* Menu items are not disabled when not relevant (e.g. cut/copy when cursor + is not on an entry). (Maybe "fullscreen" could toggle on/off too?) + +* When changing from Week View to Year View, if cursor is on an event + that is out of view, then the view should scroll to show that event. + (Tried fixing by making cls.scroll_to_row() call at end of + \_show_entry_cursor() into an idle_add. Worked first time, but not + subsequent times.) + +* If using a .ics file and multiple instances edit the same file, data + can be lost (note: using .ics file is not recommended => minor) + +* In comboboxes, when in "popped out" state, +/-/ keys don't work + +* In code, various places marked with '!!' indicating known bugs or + temporary/placeholder implementations. + +Cosmetic +-------- +* If the first entry edited after starting Pygenda has multiple exception + dates, then it makes room for these in the Entry dialog Repeats tab. + This means that the Entry dialog is made wide, and stays that way + until Pygenda is restarted. + +* Year View: Pixel missing in grid lines at top-left corner + +* On Gemini, shows "no access" icons in instead of "minus" icons (e.g. in + spin buttons of entry dialog). + +* Year view is slow to redraw on Gemini, so get black rectangles when + e.g. closing dialogs. + +* Startup spinner is a bit glitchy (visible for large calendars). + +* In Entry dialog, Repeats tab, "Repeat until" label doesn't seem to align + quite correctly with the field content. + +Testing +------- +* Need to test that iCal files/data can be read by other applications and + Pygenda can read files/data from other applications. + +* Need some sort of testing after big updates. Either automated or protocol. + +* Need automated testing of different repeat types & edge cases + +* Need to test with corrupt iCal files. (Including when files become + corrupt while the program is running.) Use a fuzzer? + ? https://pypi.org/project/pythonfuzz/ + +* Need more complete checking from type annotations, so `mypy .` gives + useful results. diff --git a/pygenda/__main__.py b/pygenda/__main__.py new file mode 100755 index 0000000..4f81735 --- /dev/null +++ b/pygenda/__main__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# pygenda/__main__.py +# Main entry point for Pygenda. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +# Import pygenda components +from .pygenda_gui import GUI +from .pygenda_calendar import Calendar + + +if __name__=="__main__": + GUI.init() + GUI.main() diff --git a/pygenda/css/disc+loop+star.svg b/pygenda/css/disc+loop+star.svg new file mode 100644 index 0000000..28fcdb5 --- /dev/null +++ b/pygenda/css/disc+loop+star.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/pygenda/css/disc+loop.svg b/pygenda/css/disc+loop.svg new file mode 100644 index 0000000..b5a2f61 --- /dev/null +++ b/pygenda/css/disc+loop.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pygenda/css/disc+star.svg b/pygenda/css/disc+star.svg new file mode 100644 index 0000000..57a00df --- /dev/null +++ b/pygenda/css/disc+star.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pygenda/css/disc.svg b/pygenda/css/disc.svg new file mode 100644 index 0000000..dc440cc --- /dev/null +++ b/pygenda/css/disc.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/pygenda/css/loop+star.svg b/pygenda/css/loop+star.svg new file mode 100644 index 0000000..e88696c --- /dev/null +++ b/pygenda/css/loop+star.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pygenda/css/loop.svg b/pygenda/css/loop.svg new file mode 100644 index 0000000..24dddb3 --- /dev/null +++ b/pygenda/css/loop.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/pygenda/css/pygenda.css b/pygenda/css/pygenda.css new file mode 100644 index 0000000..f5bfbd7 --- /dev/null +++ b/pygenda/css/pygenda.css @@ -0,0 +1,406 @@ +/***************************** + pygenda.css + Copyright (C) 2022 Matthew Lewis + + This file is part of Pygenda. + + Pygenda 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, version 3. + + Pygenda 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 Pygenda. If not, see . +******************************/ + +/* Global */ + +scrollbar { + border:0; +} + +.softbutton { + min-width:5em; +} + +.view, #view_loading { + min-width:600px; + min-height:300px; +} + +#view_loading spinner { + min-width:32px; +} + + +/* Week View */ + +#view_week { + background-color:white; +} + +#weekview_titlearea { + padding:3px; + color:black; + border-style:none none solid none; + border-color:black; + border-width:2px; +} + +#weekview_labelmonth { + font-family:'DejaVu Sans Condensed'; + font-size:12pt; + font-weight:bold; +} + +#weekview_labelweek { + font-family:'DejaVu Sans'; + font-size:9pt; +} + +#weekview_fold { + min-width:12px; + background-color:transparent; + background-image:linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(136,136,136,0.8) 35%, rgba(0,0,0,1) 50%, rgba(136,136,136,0.8) 65%, rgba(255,255,255,0) 100%); +} + +.weekview_day { + border-style:none none solid none; + border-color:#888; + border-width:1px; +} + +.weekview_day2, .weekview_day6 { + border-style:none; +} + +.weekview_labelday { + background-color:rgba(190,190,190,0.8); + color:black; + font-family:'DejaVu Sans Condensed'; + font-size:10pt; + font-weight:bold; + min-width:4em; +} + +.weekview_today .weekview_labelday { + background-color:rgba(0,170,170,0.8); +} + +.weekview_pastday .weekview_labelday { + color:#555; +} + +.weekview_daytext { + padding:4px; + color:black; + font-family:'DejaVu Sans'; + font-size:9pt; + font-weight:normal; +} + +.weekview_marker { + color:black; + padding-left:1.5px; + padding-right:1.5px; + margin-right:0.2em; +} + +.weekview_cursor { + color:white; + background-color:black; + border-radius:4px; +} + + +/* Year View */ + +#view_year { + color:black; + background-color:white; +} + +#yearview_labelyear { + font-family:'DejaVu Sans'; + font-size:14pt; + font-weight:bold; +} + +.yearview_day_label, .yearview_month_label { + font-weight:bold; +} + +.yearview_month_label { + padding-right:1px; +} + +.yearview_emptycell { + background-color:rgba(210,210,210,0.4); + border-right:solid #ddd 1px; + border-bottom:solid #ddd 1px; +} + +.yearview_daycell { + font-family:'DejaVu Sans Condensed'; + font-size:8pt; + background-color:rgba(255,255,255,0.7); + border-right:solid black 1px; + border-bottom:solid black 1px; + padding:1px; +} + +.yearview_leftofdaycell { + border-right:solid black 1px; +} + +.yearview_month_label.yearview_leftofdaycell { + padding-right:0; +} + +.yearview_abovedaycell { + border-bottom:solid black 1px; +} + +.yearview_day_sat, .yearview_day_sun { + background-color:rgba(160,160,160,0.7); +} + +.yearview_cursor { + background-image:linear-gradient(90deg, #000 9%, transparent 9%, transparent 91%, #000 91%), linear-gradient(#000 8%, transparent 8%, transparent 92%, #000 92%); +} + +.yearview_today { + background-color:#0aa; +} + +.yearview_pastday { + background-color:rgba(235,235,235,0.5); +} + +.yearview_pastday.yearview_day_sat, .yearview_pastday.yearview_day_sun { + background-color:rgba(160,160,160,0.5); +} + +#yearview_labeldate { + font-family:'DejaVu Sans Condensed'; + font-size:11pt; + font-weight:bold; + padding-right:0.8ex; +} + +#yearview_datecontent { + font-size:11pt; + font-family:'DejaVu Sans'; +} + +#yearview_datecontent_scroller { + border:0; +} + +.yearview_marker { + padding-left:1.5px; + padding-right:1.5px; + margin-right:0.2em; +} + +.yearview_entry_cursor { + color:rgba(255,255,255,0.93); + background-color:black; + border-radius:4px; +} + +.yearview_entry_single, .yearview_entry_repeated_day, .yearview_entry_repeated_hour, .yearview_entry_repeated_minute, .yearview_entry_repeated_second { + background-image:url("disc.svg"); + background-size:100% auto; + background-position:center; + background-repeat:no-repeat; +} + +.yearview_entry_single.yearview_cursor, .yearview_entry_repeated_day.yearview_cursor, .yearview_entry_repeated_hour.yearview_cursor, .yearview_entry_repeated_minute.yearview_cursor, .yearview_entry_repeated_second.yearview_cursor { + background-image:url("disc.svg"), linear-gradient(90deg, #000 9%, transparent 9%, transparent 91%, #000 91%), linear-gradient(#000 8%, transparent 8%, transparent 92%, #000 92%); +} + +.yearview_entry_repeated_month, .yearview_entry_repeated_week { + background-image:url("loop.svg"); + background-size:100% auto; + background-position:center; + background-repeat:no-repeat; +} + +.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_week.yearview_cursor { + background-image:url("loop.svg"), linear-gradient(90deg, #000 9%, transparent 9%, transparent 91%, #000 91%), linear-gradient(#000 8%, transparent 8%, transparent 92%, #000 92%); +} + +.yearview_entry_repeated_year { + background-image:url("star.svg"); + background-size:100% auto; + background-position:center; + background-repeat:no-repeat; +} + +.yearview_entry_repeated_year.yearview_cursor { + background-image:url("star.svg"), linear-gradient(90deg, #000 9%, transparent 9%, transparent 91%, #000 91%), linear-gradient(#000 8%, transparent 8%, transparent 92%, #000 92%); +} + +.yearview_entry_single.yearview_entry_repeated_month, .yearview_entry_single.yearview_entry_repeated_week, .yearview_entry_repeated_day.yearview_entry_repeated_month, .yearview_entry_repeated_day.yearview_entry_repeated_week, .yearview_entry_repeated_hour.yearview_entry_repeated_month, .yearview_entry_repeated_hour.yearview_entry_repeated_week, .yearview_entry_repeated_minute.yearview_entry_repeated_month, .yearview_entry_repeated_minute.yearview_entry_repeated_week, .yearview_entry_repeated_second.yearview_entry_repeated_month, .yearview_entry_repeated_second.yearview_entry_repeated_week { + background-image:url("disc+loop.svg"); + background-size:100% auto; + background-position:center; + background-repeat:no-repeat; +} + +.yearview_entry_single.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_single.yearview_entry_repeated_week.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_week.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_week.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_week.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_month.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_week.yearview_cursor { + background-image:url("disc+loop.svg"), linear-gradient(90deg, #000 9%, transparent 9%, transparent 91%, #000 91%), linear-gradient(#000 8%, transparent 8%, transparent 92%, #000 92%); +} + +.yearview_entry_single.yearview_entry_repeated_year, .yearview_entry_repeated_day.yearview_entry_repeated_year, .yearview_entry_repeated_hour.yearview_entry_repeated_year, .yearview_entry_repeated_minute.yearview_entry_repeated_year, .yearview_entry_repeated_second.yearview_entry_repeated_year { + background-image:url("disc+star.svg"); + background-size:100% auto; + background-position:center; + background-repeat:no-repeat; +} + +.yearview_entry_single.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_year.yearview_cursor { + background-image:url("disc+star.svg"), linear-gradient(90deg, #000 9%, transparent 9%, transparent 91%, #000 91%), linear-gradient(#000 8%, transparent 8%, transparent 92%, #000 92%); +} + +.yearview_entry_repeated_month.yearview_entry_repeated_year, .yearview_entry_repeated_week.yearview_entry_repeated_year { + background-image:url("loop+star.svg"); + background-size:100% auto; + background-position:center; + background-repeat:no-repeat; +} + +.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor { + background-image:url("loop+star.svg"), linear-gradient(90deg, #000 9%, transparent 9%, transparent 91%, #000 91%), linear-gradient(#000 8%, transparent 8%, transparent 92%, #000 92%); +} + +.yearview_entry_single.yearview_entry_repeated_month.yearview_entry_repeated_year, .yearview_entry_single.yearview_entry_repeated_week.yearview_entry_repeated_year, .yearview_entry_repeated_day.yearview_entry_repeated_month.yearview_entry_repeated_year, .yearview_entry_repeated_day.yearview_entry_repeated_week.yearview_entry_repeated_year, .yearview_entry_repeated_hour.yearview_entry_repeated_month.yearview_entry_repeated_year, .yearview_entry_repeated_hour.yearview_entry_repeated_week.yearview_entry_repeated_year, .yearview_entry_repeated_minute.yearview_entry_repeated_month.yearview_entry_repeated_year, .yearview_entry_repeated_minute.yearview_entry_repeated_week.yearview_entry_repeated_year, .yearview_entry_repeated_second.yearview_entry_repeated_month.yearview_entry_repeated_year, .yearview_entry_repeated_second.yearview_entry_repeated_week.yearview_entry_repeated_year { + background-image:url("disc+loop+star.svg"); + background-size:100% auto; + background-position:center; + background-repeat:no-repeat; +} + +.yearview_entry_single.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_single.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_day.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_hour.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_minute.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_month.yearview_entry_repeated_year.yearview_cursor, .yearview_entry_repeated_second.yearview_entry_repeated_week.yearview_entry_repeated_year.yearview_cursor { + background-image:url("disc+loop+star.svg"), linear-gradient(90deg, #000 9%, transparent 9%, transparent 91%, #000 91%), linear-gradient(#000 8%, transparent 8%, transparent 92%, #000 92%); +} + + +/* Entries */ + +.cancelled { + color:gray; + text-decoration:line-through; +} + +.tentative { + color:gray; +} + +.confirmed { + font-weight:bold; + /* font-style:italic; */ + /* text-decoration:underline; */ +} + + +/* Dialogs */ + +entry, spinbutton, date_entry, time_entry, duration_entry { + min-height:1.8em; +} + +date_entry, time_entry, duration_entry { + background-color:white; + border:solid #888 1px; + border-radius:3px; +} + +date_entry > *, time_entry > *, duration_entry > * { + background:transparent; + border:none; +} + +date_entry > label, time_entry > label, duration_entry > label { + padding-bottom:0.4ex; +} + +checkbutton { + min-height:30px; +} + +checkbutton check { + min-height:17px; + min-width:17px; +} + +/* Make focus more visible (useful for keyboard navigation) */ +radio, check, buttonbox > button, .dialogbutton, combobox button, spinbutton, switch, date_entry, time_entry, duration_entry { + border-width:1px; + padding:1px; +} + +radio:focus, check:focus, buttonbox > button:focus, .dialogbutton:focus, combobox button:focus, spinbutton:focus, switch:focus, notebook:focus tab:checked, .focus { + border-color:#398ee7; + border-width:2px; + padding:0px; +} + +/* We removed padding from dialog button; to compensate add to inner label */ +.dialogbutton > label { + padding-right:0.6em; + padding-left:0.6em; +} + +/* Remove button transitions - can cause needless redraws */ +buttonbox > button, .dialogbutton { + transition-duration:0s; + transition-delay:0s; +} + +.textentry { + border-width:1px; + padding:1px 5px; +} + +.textentry:focus { + border-color:#398ee7; + border-width:2px; + padding:0px 4px; +} + +/* Tabs need a bit more formatting */ +tab { + padding-left:11px; + padding-right:11px; +} + +/* This selects the tab with focus */ +notebook:focus tab:checked { + border-style:solid; + padding-left:10px; + padding-right:10px; +} + +.llabel { + min-height:30px; + padding-right:0.4ex; + padding-left:0.4ex; +} + +/* This is to prevent visibility of transition when dialog is redisplayed */ +entry { + transition-duration:0s; + transition-delay:0s; +} + +.dialog_error { + background-color:#e33; + transition-duration:0.15s; + transition-delay:0s; +} diff --git a/pygenda/css/star.svg b/pygenda/css/star.svg new file mode 100644 index 0000000..708f4e9 --- /dev/null +++ b/pygenda/css/star.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/pygenda/locale/en_US/LC_MESSAGES/pygenda.mo b/pygenda/locale/en_US/LC_MESSAGES/pygenda.mo new file mode 100644 index 0000000..ff634f6 Binary files /dev/null and b/pygenda/locale/en_US/LC_MESSAGES/pygenda.mo differ diff --git a/pygenda/locale/en_US/LC_MESSAGES/pygenda.po b/pygenda/locale/en_US/LC_MESSAGES/pygenda.po new file mode 100644 index 0000000..da333db --- /dev/null +++ b/pygenda/locale/en_US/LC_MESSAGES/pygenda.po @@ -0,0 +1,39 @@ +# US English translations for Pygenda package. +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . +# +# Matthew Lewis , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: Pygenda\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-01-28 22:44+0100\n" +"PO-Revision-Date: 2022-01-28 22:44+0100\n" +"Last-Translator: Matthew Lewis \n" +"Language: en_US\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: pygenda.glade:274 +msgid "Cance_lled" +msgstr "Cance_led" + +#: pygenda.glade:1652 +msgid "Cancelled" +msgstr "Canceled" diff --git a/pygenda/locale/fr/LC_MESSAGES/pygenda.mo b/pygenda/locale/fr/LC_MESSAGES/pygenda.mo new file mode 100644 index 0000000..1d11377 Binary files /dev/null and b/pygenda/locale/fr/LC_MESSAGES/pygenda.mo differ diff --git a/pygenda/locale/fr/LC_MESSAGES/pygenda.po b/pygenda/locale/fr/LC_MESSAGES/pygenda.po new file mode 100644 index 0000000..89dfdc6 --- /dev/null +++ b/pygenda/locale/fr/LC_MESSAGES/pygenda.po @@ -0,0 +1,417 @@ +# French translations for Pygenda package +# Traductions françaises du paquet Pygenda. +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . +# +# Matthew Lewis , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: Pygenda\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-01-28 22:44+0100\n" +"PO-Revision-Date: 2022-01-28 22:44+0100\n" +"Last-Translator: Matthew Lewis \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: pygenda_gui.py:672 +msgid "Delete Entry" +msgstr "Supprimer l’entrée" + +#: pygenda_gui.py:678 +msgid "" +"Delete all repeats:\n" +"\"{:s}\"?" +msgstr "" +"Supprimer toutes les entrées\n" +"« {:s} » ?" + +#: pygenda_gui.py:680 +msgid "" +"Delete entry:\n" +"\"{:s}\"?" +msgstr "" +"Supprimer l’entrée\n" +"« {:s} » ?" + +#: pygenda_gui.py:699 pygenda.glade:502 +msgid "Go To" +msgstr "Aller à" + +#: pygenda_gui.py:711 +msgid "Go" +msgstr "Y aller" + +#: pygenda_gui.py:747 +msgid "" +"A calendar/agenda application written in Python/GTK3. The UI is inspired by " +"the Agenda programs on the Psion Series 3 and Series 5 PDAs.\n" +"WARNING: This is in-development code, released as a preview for developers. " +"It will probably corrupt your data." +msgstr "" +"Une application agenda écrite en Python/GTK3. L’interface utilisateur " +"s’inspire des applications Agenda des PDAs Series 3 et 5 de Psion.\n" +"ATTENTION : Cette application est en cours de développement et elle est " +"publiée en avant-première pour les développeurs/euses. Elle corrompra " +"probablement vos données." + +#: pygenda_gui.py:1310 pygenda.glade:452 +msgid "New Entry" +msgstr "Nouvelle\nentrée" + +#: pygenda_gui.py:1327 +msgid "Edit Entry" +msgstr "Modifier l’entrée" + +#: pygenda_gui.py:1546 pygenda.glade:1135 pygenda.glade:1467 pygenda.glade:1650 +msgid "None" +msgstr "Rien" + +#: pygenda_gui.py:1780 +msgid "Exception dates" +msgstr "Dates omises" + +#: pygenda_gui.py:1797 +msgid "Add date" +msgstr "Ajouter date" + +#: pygenda_gui.py:1818 +msgid "Remove date(s)" +msgstr "Retirer date(s)" + +#: pygenda_view_week.py:58 +msgid "_Week View" +msgstr "Mode _Hebdomadaire" + +#: pygenda_view_week.py:63 +msgid "week_view_accel" +msgstr "h" + +#: pygenda_view_week.py:164 +msgid "Week 53/Week 1" +msgstr "Semaine 53/Semaine 1" + +#: pygenda_view_week.py:166 +msgid "Week {}" +msgstr "Semaine {}" + +#: pygenda_view_year.py:59 +msgid "_Year View" +msgstr "Mode _Annuelle" + +#: pygenda_view_year.py:64 +msgid "year_view_accel" +msgstr "a" + +#: pygenda_widgets.py:64 +msgid "ymdhm" +msgstr "amjhm" + +#: pygenda.glade:64 +msgid "_File" +msgstr "_Fichier" + +#: pygenda.glade:125 +msgid "_Edit" +msgstr "É_dition" + +#: pygenda.glade:172 +msgid "E_ntry" +msgstr "E_ntrée" + +#: pygenda.glade:182 +msgid "_New Entry…" +msgstr "_Nouvelle entrée…" + +#: pygenda.glade:198 +msgid "Edit _Time…" +msgstr "Modifier _heure…" + +#: pygenda.glade:208 +msgid "Edit _Repeats…" +msgstr "Modifier _répétitions…" + +#: pygenda.glade:218 +msgid "Edit _Alarm…" +msgstr "Modifier _alarme…" + +#: pygenda.glade:228 +msgid "Edit _Details…" +msgstr "Modifier _détails…" + +#: pygenda.glade:244 +msgid "Set _Status" +msgstr "_Statut" + +#: pygenda.glade:254 +msgid "_None" +msgstr "_Rien" + +#: pygenda.glade:264 +msgid "_Confirmed" +msgstr "_Confirmé" + +#: pygenda.glade:274 +msgid "Cance_lled" +msgstr "_Annulé" + +#: pygenda.glade:284 +msgid "_Tentative" +msgstr "_Provisoire" + +#: pygenda.glade:314 +msgid "_View" +msgstr "_Afficharge" + +#: pygenda.glade:324 +msgid "_Go To" +msgstr "_Aller à" + +#: pygenda.glade:334 +msgid "_View Mode" +msgstr "_Mode" + +#: pygenda.glade:344 +msgid "Switch _View" +msgstr "Changer mode" + +#: pygenda.glade:403 +msgid "_Help" +msgstr "Aid_e" + +#: pygenda.glade:477 +msgid "View" +msgstr "Mode" + +#: pygenda.glade:528 +msgid "Zoom" +msgstr "Zoom" + +#: pygenda.glade:787 +msgid "Entry" +msgstr "Entrée" + +#: pygenda.glade:848 +msgid "Description:" +msgstr "Description:" + +#: pygenda.glade:879 +msgid "Date:" +msgstr "Date:" + +#: pygenda.glade:903 +msgid "Timed entry:" +msgstr "Entrée avec heure:" + +#: pygenda.glade:919 +msgid "No" +msgstr "Non" + +#: pygenda.glade:934 +msgid "Yes" +msgstr "Oui" + +#: pygenda.glade:949 +msgid "All day" +msgstr "Journée" + +#: pygenda.glade:983 +msgid "Start time:" +msgstr "Commencer à:" + +#: pygenda.glade:999 +msgid "Duration:" +msgstr "Durée:" + +#: pygenda.glade:1015 +msgid "End time:" +msgstr "Heure de fin:" + +#: pygenda.glade:1061 +msgid "Days:" +msgstr "Jours:" + +#: pygenda.glade:1102 +msgid "Time" +msgstr "Heure" + +#: pygenda.glade:1117 +msgid "Repeats:" +msgstr "Répétition:" + +#: pygenda.glade:1136 +msgid "Yearly" +msgstr "Annuelle" + +#: pygenda.glade:1137 +msgid "Monthly" +msgstr "Mensuelle" + +#: pygenda.glade:1138 +msgid "Monthly by day" +msgstr "Mensuelle par jour" + +#: pygenda.glade:1139 +msgid "Monthly by weekday" +msgstr "Mensuelle par jour de la semaine" + +#: pygenda.glade:1140 +msgid "Weekly" +msgstr "Hebdomadaire" + +#: pygenda.glade:1141 +msgid "Daily" +msgstr "Quotidienne" + +#: pygenda.glade:1157 +msgid "Repeat on:" +msgstr "Répéter le:" + +#: pygenda.glade:1186 +msgid "Last day" +msgstr "Dernier jour" + +#: pygenda.glade:1187 +msgid "2nd last day" +msgstr "2e au dernier jour" + +#: pygenda.glade:1188 +msgid "3rd last day" +msgstr "3e au dernier jour" + +#: pygenda.glade:1189 +msgid "4th last day" +msgstr "4e au dernier jour" + +#: pygenda.glade:1190 +msgid "5th last day" +msgstr "5e au dernier jour" + +#: pygenda.glade:1191 +msgid "6th last day" +msgstr "6e au dernier jour" + +#: pygenda.glade:1192 +msgid "7th last day" +msgstr "7e au dernier jour" + +#: pygenda.glade:1218 +msgid "1st" +msgstr "1er" + +#: pygenda.glade:1219 +msgid "2nd" +msgstr "2e" + +#: pygenda.glade:1220 +msgid "3rd" +msgstr "3e" + +#: pygenda.glade:1221 +msgid "4th" +msgstr "4e" + +#: pygenda.glade:1222 +msgid "5th" +msgstr "5e" + +#: pygenda.glade:1223 +msgid "Last" +msgstr "Dernier" + +#: pygenda.glade:1224 +msgid "2nd last" +msgstr "2e au dernier" + +#: pygenda.glade:1225 +msgid "3rd last" +msgstr "3e au dernier" + +#: pygenda.glade:1226 +msgid "4th last" +msgstr "4e au dernier" + +#: pygenda.glade:1227 +msgid "5th last" +msgstr "5e au dernier" + +#: pygenda.glade:1276 +msgid "Period:" +msgstr "Période:" + +#: pygenda.glade:1292 +msgid "Repeat Forever:" +msgstr "Répéter à jamais:" + +#: pygenda.glade:1318 +msgid "Occurrences:" +msgstr "Occurrences:" + +#: pygenda.glade:1334 +msgid "Until:" +msgstr "Jusqu’à:" + +#: pygenda.glade:1359 +msgid "Exceptions:" +msgstr "Dates omises:" + +#: pygenda.glade:1519 +msgid "Repeats" +msgstr "Répétitions" + +#: pygenda.glade:1536 +msgid "Alarm set:" +msgstr "Alarme réglée:" + +#: pygenda.glade:1585 +msgid "Alarm" +msgstr "Alarme" + +#: pygenda.glade:1602 +msgid "Location:" +msgstr "Lieu:" + +#: pygenda.glade:1632 +msgid "Status:" +msgstr "Statut:" + +#: pygenda.glade:1651 +msgid "Tentative" +msgstr "Provisoire" + +#: pygenda.glade:1652 +msgid "Cancelled" +msgstr "Annulé" + +#: pygenda.glade:1653 +msgid "Confirmed" +msgstr "Confirmé" + +#: pygenda.glade:1667 +msgid "" +"Details will go here:\n" +"long description, attendees…" +msgstr "" +"Les détails seront ici:\n" +"description détaillée, partipants…" + +#: pygenda.glade:1686 +msgid "Details" +msgstr "Détails" diff --git a/pygenda/mypy.ini b/pygenda/mypy.ini new file mode 100644 index 0000000..7c20e41 --- /dev/null +++ b/pygenda/mypy.ini @@ -0,0 +1,46 @@ +# Mypy config file for Pygenda +# Mypy is a static type checker for Python +# See: https://mypy.readthedocs.io/en/stable/config_file.html +# To run, just do 'mypy .' in this directory + +[mypy] +python_version = 3.5 +warn_unused_configs = True +ignore_missing_imports = True +ignore_errors = False +warn_return_any = True +warn_unreachable = True +disallow_untyped_defs = False + +[mypy-__main__] +ignore_errors = False + +[mypy-pygenda_calendar] +ignore_errors = True + +[mypy-pygenda_config] +ignore_errors = True + +[mypy-pygenda_entryinfo] +ignore_errors = False + +[mypy-pygenda_gui] +ignore_errors = True + +[mypy-pygenda_util] +ignore_errors = True + +[mypy-pygenda_version] +ignore_errors = False + +[mypy-pygenda_view] +ignore_errors = True + +[mypy-pygenda_view_week] +ignore_errors = True + +[mypy-pygenda_view_year] +ignore_errors = True + +[mypy-pygenda_widgets] +ignore_errors = True diff --git a/pygenda/pygenda.glade b/pygenda/pygenda.glade new file mode 100644 index 0000000..d143334 --- /dev/null +++ b/pygenda/pygenda.glade @@ -0,0 +1,1714 @@ + + + + + + 1 + 999 + 1 + 1 + 10 + + + 1 + 9999 + 1 + 1 + 10 + + + 1 + 9999 + 1 + 1 + 10 + + + False + Pygenda + center-always + + + + True + False + vertical + + + True + False + False + start + + + True + False + _File + True + + + True + False + + + gtk-new + True + False + False + True + True + + + + + gtk-open + True + False + False + True + True + + + + + gtk-save-as + True + False + False + True + True + + + + + True + False + + + + + gtk-quit + True + False + True + True + + + + + + + + + + + True + False + _Edit + True + + + True + False + + + gtk-cut + True + False + True + True + + + + + + + gtk-copy + True + False + True + True + + + + + + + gtk-paste + True + False + True + True + + + + + + + + + + + True + False + E_ntry + True + + + True + False + + + True + False + _New Entry… + True + + + + + + + True + False + + + + + True + False + Edit _Time… + True + + + + + + + True + False + Edit _Repeats… + True + + + + + + + True + False + Edit _Alarm… + True + + + + + + + True + False + Edit _Details… + True + + + + + + + True + False + + + + + True + False + Set _Status + True + + + True + False + + + True + False + _None + True + + + + + + + True + False + _Confirmed + True + + + + + + + True + False + Cance_lled + True + + + + + + + True + False + _Tentative + True + + + + + + + + + + + gtk-delete + True + False + True + True + + + + + + + + + + + + True + False + _View + True + + + True + False + + + True + False + _Go To + True + + + + + + + True + False + _View Mode + True + + + True + False + + + True + False + Switch _View + True + + + + + + + True + False + + + + + + + + + gtk-zoom-in + True + False + False + True + True + + + + + + + gtk-zoom-out + True + False + False + True + True + + + + + + + gtk-fullscreen + True + False + True + True + + + + + + + + + + + True + False + _Help + True + + + True + False + + + gtk-about + True + False + True + True + + + + + + + + + + False + True + 0 + + + + + True + False + + + True + False + False + vertical + True + + + True + False + False + False + + + + True + False + New Entry + center + + + + + + True + True + 0 + + + + + True + False + False + False + + + + True + False + View + center + + + + + + True + True + 1 + + + + + True + False + False + False + + + + True + False + Go To + center + + + + + + True + True + 2 + + + + + True + False + False + False + False + + + + True + False + Zoom + center + + + + + + True + True + 3 + + + + + False + True + 1 + + + + + True + True + 1 + + + + + + + view_week + True + True + + + True + False + vertical + True + + + weekview_titlearea + True + False + vertical + + + weekview_labelmonth + True + False + False + 0 + 0 + + + True + True + 0 + + + + + weekview_labelweek + True + False + False + 1 + 1 + + + False + False + 1 + + + + + False + True + 0 + + + + + True + True + 0 + + + + + weekview_fold + True + False + vertical + + + False + True + 1 + + + + + True + False + vertical + + + True + True + end + 0 + + + + + view_year + True + True + + + yearview_labelyear + True + False + 0 + + + 0 + 0 + 2 + + + + + yearview_gridcorner + True + False + + + 0 + 1 + + + + + True + False + True + True + + + 1 + 1 + + + + + True + False + True + vertical + True + + + 0 + 2 + + + + + True + False + False + + + + yearview_daygrid + True + False + True + True + True + True + + + + + 1 + 2 + + + + + True + False + + + yearview_labeldate + True + False + True + 1 + 0 + + + False + True + 0 + + + + + True + False + + + yearview_datecontent_scroller + True + True + True + never + in + False + + + True + False + + + yearview_datecontent + True + False + vertical + + + + + + + + + False + True + 1 + + + + + 0 + 3 + 2 + + + + + False + Entry + True + True + dialog + window_main + + + False + vertical + 2 + + + False + end + + + gtk-cancel + True + True + True + True + + + True + True + 0 + + + + + gtk-ok + True + True + True + True + True + True + + + True + True + 1 + + + + + False + False + 0 + + + + + + True + False + + + True + False + end + Description: + + + + 0 + 0 + + + + + True + True + True + True + True + + + + 1 + 0 + + + + + True + False + end + Date: + + + + 0 + 1 + + + + + True + True + + + + True + False + + + True + False + end + Timed entry: + + + + 0 + 0 + + + + + True + False + + + No + True + True + False + True + True + + + False + True + 0 + + + + + Yes + True + True + False + True + radiobutton_timed_no + + + False + True + 1 + + + + + All day + True + True + False + True + radiobutton_timed_no + + + False + True + 2 + + + + + 1 + 0 + + + + + True + False + + + True + False + vertical + True + + + True + False + end + Start time: + + + + False + True + 0 + + + + + True + False + end + Duration: + + + + False + True + 1 + + + + + True + False + end + End time: + + + + False + True + 2 + + + + + + + 0 + 1 + + + + + True + False + + + True + False + vertical + True + + + + + 1 + 1 + + + + + True + False + + + True + False + end + Days: + + + + + + 0 + 2 + + + + + True + False + + + True + True + start + True + number + allday_days_adj + 1 + True + 1 + + + + + 1 + 2 + + + + + + + True + False + Time + + + False + + + + + + True + False + + + True + False + Repeats: + 1 + + + + 0 + 0 + + + + + True + False + start + 0 + + None + Yearly + Monthly + Monthly by day + Monthly by weekday + Weekly + Daily + + + + 1 + 0 + + + + + True + False + + + True + False + Repeat on: + 1 + + + + + + 0 + 1 + + + + + True + False + vertical + + + True + False + + + True + False + start + 0 + + Last day + 2nd last day + 3rd last day + 4th last day + 5th last day + 6th last day + 7th last day + + + + + + False + True + 0 + + + + + True + False + + + True + False + + + True + False + start + 0 + + 1st + 2nd + 3rd + 4th + 5th + Last + 2nd last + 3rd last + 4th last + 5th last + + + + False + True + 0 + + + + + True + False + start + + + False + True + 1 + + + + + + + False + True + 1 + + + + + 1 + 1 + + + + + True + False + + + True + False + vertical + + + True + False + Period: + 1 + + + + True + True + 0 + + + + + True + False + Repeat Forever: + 1 + + + + True + True + 1 + + + + + True + False + + + True + False + vertical + True + + + True + False + Occurrences: + 1 + + + + True + True + 0 + + + + + True + False + Until: + 1 + + + + True + True + 1 + + + + + + + True + True + 2 + + + + + True + False + Exceptions: + 1 + + + + True + True + 3 + + + + + + + 0 + 2 + + + + + True + False + True + + + True + False + vertical + + + True + True + start + True + number + repeat_interval_adj + 1 + True + 1 + + + False + True + 0 + + + + + True + True + False + start + True + True + + + False + True + 1 + + + + + True + False + + + True + False + vertical + True + + + True + True + start + True + number + repeat_occurrences_adj + 1 + True + 1 + + + False + True + 0 + + + + + + + True + True + 2 + + + + + True + False + + + True + False + None + end + True + 0 + + + True + True + 0 + + + + + +/− + True + True + True + + + + + False + True + 1 + + + + + False + True + 3 + + + + + + + 1 + 2 + + + + + 1 + + + + + True + False + Repeats + + + 1 + False + + + + + + True + False + + + True + False + end + Alarm set: + + + + 0 + 0 + + + + + True + True + start + center + + + 1 + 0 + + + + + True + False + True + True + Note: Editing alarms is not currently supported. +Changing this switch will have no effect. +Also, a separate alarm server will be needed for +alarms to be sounded/activate. + center + + + 0 + 1 + 2 + + + + + 2 + + + + + True + False + Alarm + + + 2 + False + + + + + + True + False + + + True + False + end + Location: + + + + 0 + 0 + + + + + True + True + True + True + True + + + + 1 + 0 + + + + + True + False + Status: + 1 + + + + 0 + 1 + + + + + True + False + start + 0 + + None + Tentative + Cancelled + Confirmed + + + + 1 + 1 + + + + + 170 + True + False + True + Details will go here: +long description, attendees… + center + + + 0 + 2 + 2 + + + + + 3 + + + + + True + False + Details + + + 3 + False + + + + + 0 + 2 + 2 + + + + + False + True + 1 + + + + + + button-dialog-entry-cancel + button-dialog-entry-ok + + + diff --git a/pygenda/pygenda_calendar.py b/pygenda/pygenda_calendar.py new file mode 100644 index 0000000..f347456 --- /dev/null +++ b/pygenda/pygenda_calendar.py @@ -0,0 +1,958 @@ +# -*- coding: utf-8 -*- +# +# pygenda_calendar.py +# Connects to agenda data provider - either an ics file or CalDAV server. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +from icalendar import Calendar as iCalendar, Event as iEvent, vRecur +from datetime import timedelta, datetime as dt_datetime, time as dt_time, date as dt_date, timezone +from dateutil.relativedelta import relativedelta +from dateutil.rrule import rrulestr +from sys import stderr +from uuid import uuid1 +from pathlib import Path +from functools import reduce +from os import stat as os_stat, chmod as os_chmod, rename as os_rename, path as os_path +import stat +from time import monotonic as time_monotonic +import tempfile +from typing import Optional + +# Pygenda components +from .pygenda_config import Config +from .pygenda_util import dt_lt, dt_lte, datetime_to_date +from .pygenda_entryinfo import EntryInfo + + +# Interface base class to connect to different data sources. +# Used by Calendar class (below). +class CalendarConnector: + def add_entry(self, event:iEvent) -> None: + # Add a new entry component to the calendar data and store it. + print('Warning: Add entry not implemented', file=stderr) + + def update_entry(self, event:iEvent) -> None: + # Update an entry component in the calendar data and store it. + print('Warning: Update not implemented', file=stderr) + + def delete_entry(self, event:iEvent) -> None: + # Delete entry component to the calendar data and remove from store. + print('Warning: Delete not implemented', file=stderr) + + +# Singleton class for calendar data access/manipulation +class Calendar: + _entry_norep_list_sorted = None + _entry_rep_list = None + + @classmethod + def init(cls) -> None: + # Calendar connector initialisation. + # Can take a long time, since it loads/sorts calendar data. + # Best called in background after GUI is started, or startup can be slow. + Config.set_defaults('calendar',{ + 'ics_file': None, + 'caldav_server': None, + 'caldav_username': None, + 'caldav_password': None, + 'caldav_calendar': None + }) + + # Read config values to get type of data source connector + filename = Config.get('calendar','ics_file') + caldav_server = Config.get('calendar','caldav_server') + if caldav_server is not None: + # Use either server or file, not both (at least for now) + assert(filename is None) + user = Config.get('calendar','caldav_username') + passwd = Config.get('calendar','caldav_password') + calname = Config.get('calendar','caldav_calendar') + cls.calConnector = CalendarConnectorCalDAV(caldav_server,user,passwd,calname) + else: + # If no server given, use an ics file. + # Use either the provided filename or a default name. + if filename is None: + filename = '{}/{}'.format(Config.conf_dirname,Config.DEFAULT_ICS_FILENAME) + else: + # Expand '~' (so it can be used in config file) + filename = os_path.expanduser(filename) + # Create a connector for that file + cls.calConnector = CalendarConnectorICSfile(filename) + + if cls.calConnector.cal.is_broken: + print('Warning: Non-conformant ical file', file=stderr) + + + @staticmethod + def gen_uid() -> str: + # Generate a UID for iCal elements (required element) + uid = 'Pygenda-{}'.format(uuid1()) + return uid + + + @classmethod + def new_entry(cls, e_inf:EntryInfo) -> None: + # Add a new iCal entry with content from event info object + ev = iEvent() + ev.add('UID', Calendar.gen_uid()) # Required + # DateTime utcnow() function doesn't include TZ, so use now(tz.utc) + utcnow = dt_datetime.now(timezone.utc) + ev.add('DTSTAMP', utcnow) # Required + ev.add('CREATED', utcnow) # Optional + ev.add('SUMMARY', e_inf.desc) + ev.add('DTSTART', e_inf.start_dt) + cls._event_add_end_dur_from_info(ev,e_inf) + + # Repeats + if e_inf.rep_type is not None and e_inf.rep_inter>0: + cls._event_add_repeat_from_info(ev,e_inf) + cls._entry_rep_list = None # Clear rep cache as modified + else: + cls._entry_norep_list_sorted = None # Clear norep cache as modified + + cls._event_set_status_from_info(ev, e_inf) + cls._event_set_location_from_info(ev, e_inf) + + cls.calConnector.add_entry(ev) # Write to store + + + @classmethod + def new_entry_from_example(cls, exev:iEvent, dt_start:dt_date=None) -> None: + # Add a new iCal entry to store given an iEvent as a "template". + # Replace UID, timestamp etc. to make it a new event. + # Potentially override exev's date/time with a new one. + # Use to implement pasting events into new days/timeslots. + ev = iEvent() + ev.add('UID', Calendar.gen_uid()) # Required + utcnow = dt_datetime.now(timezone.utc) + ev.add('DTSTAMP', utcnow) # Required + # Since it has a new UID, we consider it a new entry + ev.add('CREATED', utcnow) # Optional + summ = exev['SUMMARY'] if 'SUMMARY' in exev else None + if not summ: + summ = 'New entry' # fallback summary + ev.add('SUMMARY', summ) + ex_dt_start = exev['DTSTART'].dt if 'DTSTART' in exev else None + if dt_start: + if ex_dt_start: + new_dt_start = ex_dt_start.replace(year=dt_start.year,month=dt_start.month,day=dt_start.day) + else: + new_dt_start = dt_start + elif ex_dt_start: + new_dt_start = ex_dt_start + else: + raise ValueError('Entry has no date/time') + ev.add('DTSTART', new_dt_start) + if 'DURATION' in exev: + ev.add('DURATION', exev['DURATION']) + elif ex_dt_start and 'DTEND' in exev: + ex_dt_end = exev['DTEND'].dt + if isinstance(ex_dt_start,dt_datetime) and (ex_dt_start.tzinfo is None or ex_dt_end.tzinfo is None): + # If one tzinfo is None, make both None, so subtraction work + ex_dt_start = ex_dt_start.replace(tzinfo=None) + ex_dt_end = ex_dt_end.replace(tzinfo=None) + dur = ex_dt_end - ex_dt_start + ev.add('DTEND', new_dt_start + dur) + if 'LOCATION' in exev: + ev.add('LOCATION', exev['LOCATION']) + cls.calConnector.add_entry(ev) # Write to store + cls._entry_norep_list_sorted = None # Clear norep cache as modified + + + @classmethod + def update_entry(cls, ev:iEvent, e_inf:EntryInfo) -> None: + # Update event using details from EntryInfo e_inf. + clear_rep = False + clear_norep = False + + if 'UID' not in ev: + ev.add('UID', Calendar.gen_uid()) # Should be present + # DateTime utcnow() function doesn't include TZ, so use now(tz.utc) + utcnow = dt_datetime.now(timezone.utc) + try: + ev['DTSTAMP'].dt = utcnow + except KeyError: + # Entry had no DTSTAMP (note: DTSTAMP required by icalendar spec) + ev.add('DTSTAMP', utcnow) + try: + ev['LAST-MODIFIED'].dt = utcnow + except KeyError: + # Entry had no LAST-MODIFIED - add one + ev.add('LAST-MODIFIED', utcnow) + try: + ev['SUMMARY'] = e_inf.desc + except KeyError: + # Entry had no SUMMARY + ev.add('SUMMARY', e_inf.desc) + + # DTSTART - delete & re-add so type (DATE vs. DATE-TIME) is correct + # (Also, Q: if comparing DTSTARTs with different TZs, how does != work?) + if 'DTSTART' in ev: + del(ev['DTSTART']) + ev.add('DTSTART', e_inf.start_dt) + + # Duration or Endtime - first delete existing + if 'DURATION' in ev: + del(ev['DURATION']) + if 'DTEND' in ev: + del(ev['DTEND']) + # Then add new end time/duration (if needed) + cls._event_add_end_dur_from_info(ev,e_inf) + + # Repeats (including exception dates) + if 'RRULE' in ev: + del(ev['RRULE']) + clear_rep = True + else: + clear_norep = True + if 'EXDATE' in ev: + del(ev['EXDATE']) + if e_inf.rep_type is not None and e_inf.rep_inter>0: + cls._event_add_repeat_from_info(ev,e_inf) + clear_rep = True + else: + clear_norep = True + + # Other properties: status (cancelled, tentative, etc.), location + cls._event_set_status_from_info(ev, e_inf) + cls._event_set_location_from_info(ev, e_inf) + + # This needs optimising - some cases cause too much cache flushing !! + if clear_norep: + cls._entry_norep_list_sorted = None + if clear_rep: + cls._entry_rep_list = None + + cls.calConnector.update_entry(ev) # Write to store + + + @staticmethod + def _event_add_end_dur_from_info(ev:iEvent, e_inf:EntryInfo) -> None: + # Adds end time or duration to an event from EntryInfo. + # How it does this depends on whether event is timed or not + + # If entry is timed, check for an end-time/duration & add if present + # Note: don't add both an end-time & duration - at most one + if isinstance(e_inf.start_dt, dt_datetime): + if e_inf.end_dt is not None: + if isinstance(e_inf.end_dt, dt_datetime): + if e_inf.end_dt > e_inf.start_dt:#need end_dt after start_dt + ev.add('DTEND', e_inf.end_dt) + elif isinstance(e_inf.end_dt, dt_time): + end_dttm = dt_datetime.combine(e_inf.start_dt.date(),e_inf.end_dt) + if end_dttm != e_inf.start_dt: # require end time to be after start time + if end_dttm < e_inf.start_dt: + end_dttm += timedelta(days=1) + ev.add('DTEND', end_dttm) + elif e_inf.duration is not None and isinstance(e_inf.duration, timedelta): + if e_inf.duration.total_seconds()>0: # require duration > 0 + ev.add('DURATION', e_inf.duration) + elif isinstance(e_inf.start_dt, dt_date) and isinstance(e_inf.end_dt, dt_date): + # start & end are both dates (not times) => this is a day entry + if e_inf.end_dt > e_inf.start_dt: # sanity check + ev.add('DTEND', e_inf.end_dt) + + + @staticmethod + def _event_add_repeat_from_info(ev:iEvent, e_inf:EntryInfo) -> None: + # Adds FREQ and EXDATE fields to event. + # Assume these fields are empty (so either it's a + # new entry or the delete happens elsewhere). + rr_options = {'FREQ':[e_inf.rep_type]} + if e_inf.rep_inter != 1: + rr_options['INTERVAL'] = [e_inf.rep_inter] + if e_inf.rep_until is not None: + if isinstance(e_inf.start_dt,dt_datetime) and not isinstance(e_inf.rep_until,dt_datetime): + # If start is a datetime, until needs to be a datetime + dt_until = dt_datetime.combine(e_inf.rep_until,e_inf.start_dt.time()) + else: + dt_until = e_inf.rep_until + rr_options['UNTIL'] = [dt_until] + elif e_inf.rep_count is not None: + rr_options['COUNT'] = [e_inf.rep_count] + if e_inf.rep_byday is not None: + rr_options['BYDAY'] = [e_inf.rep_byday] + if e_inf.rep_bymonthday is not None: + rr_options['BYMONTHDAY'] = [e_inf.rep_bymonthday] + ev.add('RRULE', rr_options) + + # Add exception date/times + if e_inf.rep_exceptions: + e_prm = {'VALUE':'DATE'} # ? Bug in icalendar 4.0.9 means we need to add VALUE parameter + # Adding multiple EXDATE fields seems to be most compatible + for rex in e_inf.rep_exceptions: + ev.add('EXDATE', rex, parameters=e_prm) + + # For repeats, spec says DTSTART should correspond to RRULE + # https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html + # So check DTSTART is consistent with RRULE, and possibly adjust. + # This ignores exceptions, since they are an extra layer. + fst_occ = first_occ(ev['RRULE'].to_ical().decode('utf-8'),e_inf.start_dt) + if fst_occ!=e_inf.start_dt: + print('Notice: rewriting DTSTART to be consistent with RRULE', file=stderr) + del(ev['DTSTART']) + ev.add('DTSTART', fst_occ) + # May also need to change end date + if 'DTEND' in ev: + d = fst_occ - e_inf.start_dt + end = ev['DTEND'].dt + d + del(ev['DTEND']) + ev.add('DTEND', end) + + + @staticmethod + def _event_set_status_from_info(ev:iEvent, e_inf:EntryInfo) -> None: + # Set event status (cancelled, tentative etc.) from e_inf. + # Only allow known values. + if 'STATUS' in ev: + del(ev['STATUS']) + if e_inf.status in ('TENTATIVE','CONFIRMED','CANCELLED'): + ev.add('STATUS', e_inf.status) + + + @staticmethod + def _event_set_location_from_info(ev:iEvent, e_inf:EntryInfo) -> None: + # Set event location (text string) from e_inf. + if 'LOCATION' in ev: + del(ev['LOCATION']) + if e_inf.location: + ev.add('LOCATION', e_inf.location) + + + @classmethod + def delete_entry(cls, event:iEvent) -> None: + # Delete given event. + # Need to clear cache containing the entry... + if 'RRULE' in event: + cls._entry_rep_list = None + else: + cls._entry_norep_list_sorted = None + cls.calConnector.delete_entry(event) + + + @classmethod + def set_toggle_status_entry(cls, event:iEvent, stat:Optional[str]) -> None: + # Set event STATUS to stat. + # If STATUS is set and equals stat, toggle it off. + if 'STATUS' in event: + if stat==event['STATUS']: + stat = None # If on, we toggle it off + del(event['STATUS']) + if stat in ('TENTATIVE','CONFIRMED','CANCELLED'): + event.add('STATUS', stat) + cls.calConnector.update_entry(event) # Write to store + + + @classmethod + def _update_entry_norep_list(cls) -> None: + # Re-build _entry_norep_list_sorted, if it has been cleared (==None) + if cls._entry_norep_list_sorted is None: + # Get events with no repeat rule + evs = cls.calConnector.cal.walk('vEvent') + cls._entry_norep_list_sorted = [e for e in evs if 'RRULE' not in e] + cls._entry_norep_list_sorted.sort() + + + @classmethod + def _update_entry_rep_list(cls) -> None: + # Re-build _entry_rep_list, if it has been cleared (==None) + # Possible optimisation: sort most -> least frequent + # (so don't get last one inserting loads into array) + if cls._entry_rep_list is None: + evs = cls.calConnector.cal.walk('vEvent') + cls._entry_rep_list = [e for e in evs if 'RRULE' in e] + + + @classmethod + def occurrence_list(cls, start:dt_date, stop:dt_date, include_single:bool=True, include_repeated:bool=True) -> list: + # Return list of occurences in range start <= . < stop. + # Designed to be called by View classes to get events in range. + # An "occurrence" is a pair: (event,datetime) + # for repeating entries, datetime may not be the DTSTART entry + # Needs to also return events that last/end over range?? + ret_list = [] + if include_single: + cls._update_entry_norep_list() + # bisect to find starting point + ii = 0 + llen = len(cls._entry_norep_list_sorted) + top = llen + while ii None: + # Save file to disk/storage. Called after any entry updated. + # Implementation tries to minimise possibility/extent of data loss. + file_exists = False + try: + mode = os_stat(self._filename).st_mode + file_exists = True + except FileNotFoundError: + mode = stat.S_IRUSR|stat.S_IWUSR # Default - private to user + + # We don't want to just write over current file - it will be + # zero-length for a time, so a crash would lose data. + # We save to a new file, so a crash before the write completes + # will leave original file in place to be opened on restart. + realfile = os_path.realpath(self._filename) + tfdir = os_path.dirname(realfile) + tfpre = '{:s}.{:s}-{:s}-'.format(os_path.basename(realfile),self.NEWFILE_EXT,dt_datetime.now().strftime('%Y%m%d%H%M%S')) + with tempfile.NamedTemporaryFile(mode='wb', prefix=tfpre, dir=tfdir, delete=False) as tf: + temp_filename = tf.name + os_chmod(temp_filename, mode) + tf.write(self.cal.to_ical()) + + # Possibly make a backup of original file before overwriting + if file_exists and time_monotonic() - self._backup_saved_time > self.BACKUP_PERIOD: + os_rename(self._filename, '{:s}.{:s}'.format(self._filename,self.BACKUP_EXT)) + self._backup_saved_time = time_monotonic() # re-read time + # Rename temp saved version to desired name + os_rename(temp_filename, realfile) + + + def add_entry(self, event:iEvent) -> None: + # Add a new entry component to the file data and write file. + self.cal.add_component(event) + self._save_file() + + + def update_entry(self, event:iEvent) -> None: + # event is a component of the file data, so it's already updated. + # We just need to write the file data. + self._save_file() + + + def delete_entry(self, event:iEvent) -> None: + # Delete entry component to the file data and write file. + self.cal.subcomponents.remove(event) + self._save_file() + + +# +# Connector class for CalDAV server +# This works by reading all the data from the server on startup, and +# using its copy to calculate query responses. I did it this way +# because, on testing, querying the server directly was too slow: +# using the Radicale server on the Gemini, a simple query with a +# range of a week took more than 3 seconds (0.5 seconds on a laptop). +# +class CalendarConnectorCalDAV(CalendarConnector): + def __init__(self, url:str, user:str, passwd:str, calname:Optional[str]): + import caldav # Postponed import, so Pygenda can be used without caldav + + client = caldav.DAVClient(url=url, username=user, password=passwd) + try: + principal = client.principal() + except Exception as excep: + print('Error: Can\'t connect to CalDAV server at {:s}. Message: {:s}'.format(url,str(excep)), file=stderr) + raise + if calname is None: + calendars = principal.calendars() + if len(calendars) > 0: + self.calendar = calendars[0] + else: + # Create a calendar with default name + self.calendar = principal.make_calendar(name='pygenda') + else: + # Open or create named calendar + try: + self.calendar = principal.calendar(calname) + except caldav.lib.error.NotFoundError: + self.calendar = principal.make_calendar(name=calname) + + # Make list of references to events for convenient access + events = self.calendar.events() + self.cal = iCalendar() + for ev in events: + # Each icalendar_instance is a calendar containing the event. + # We want to extract the event itself, so walk() & take the first. + vevent = ev.icalendar_instance.walk('vEvent')[0] + vevent.__conn_event = ev # Sneakily add ev, for rapid access later + self.cal.add_component(vevent) + + + def add_entry(self, event:iEvent) -> None: + # Create a new entry component on the server, and locally. + vcstr = 'BEGIN:VCALENDAR\r\n{:s}END:VCALENDAR\r\n'.format(event.to_ical().decode()) # !! Should we specify encoding for decode()? + try: + conn_event = self.calendar.save_event(vcstr) + except Exception as excep: + # !! While code is in development, just exit on failure. + # May change to something "friendlier" later... + print('Error creating entry on CalDAV server. Message: {:s}'.format(str(excep)), file=stderr) + exit(-1) + + # Save to local store + # Add embedded event, so we can modify & save directly + newevent = conn_event.icalendar_instance.walk('vEvent')[0] + newevent.__conn_event = conn_event + self.cal.add_component(newevent) + + + def update_entry(self, event:iEvent) -> None: + # Event struct has been modified, so can just send update to server. + try: + event.__conn_event.save() # Write to server + except Exception as excep: + # !! While code is in development, just exit on failure. + # May change to something "friendlier" later... + print('Error updating entry on CalDAV server. Message: {:s}'.format(str(excep)), file=stderr) + exit(-1) + + + def delete_entry(self, event:iEvent) -> None: + # Delete entry component from server and in local copy. + try: + event.__conn_event.delete() # delete from server + except Exception as excep: + # !! While code is in development, just exit on failure. + # May change to something "friendlier" later... + print('Error deleting entry on CalDAV server. Message: {:s}'.format(str(excep)), file=stderr) + exit(-1) + self.cal.subcomponents.remove(event) # delete local copy + + +# +# Helper class for repeats_in_range() function (below) +# +class RepeatInfo: + DAY_ABBR = ('MO','TU','WE','TH','FR','SA','SU') + + def __init__(self, event:iEvent, start:dt_date, stop:dt_date): + # Note: start argument is INclusive, stop is EXclusive + rrule = event['RRULE'] + self.start = event['DTSTART'].dt + # Quickly eliminate some out-of-range cases + if stop is not None and dt_lte(stop, self.start): + self.start_in_rng = None + return + if start is not None and 'UNTIL' in rrule and dt_lt(rrule['UNTIL'][0],start): + self.start_in_rng = None + return + + dt_st = event['DTSTART'].dt + if rrule['FREQ'][0]=='MONTHLY' and dt_st.day>28: + raise ValueError('Unsupported MONTHLY (day>28) {} in RRULE'.format(dt_st)) + if rrule['FREQ'][0]=='YEARLY' and dt_st.month==2 and dt_st.day==29: + raise ValueError('Unsupported YEARLY (29/2) {} in RRULE'.format(dt_st)) + + self._set_freq(rrule) + if 'EXDATE' in event: + self._set_exdates(event['EXDATE']) + else: + self.exdates = None + self._set_start_in_rng(start) + self._set_stop(rrule, stop) + + + def _set_freq(self, rrule:vRecur) -> None: + # Set repeat frequency (self.delta). + # Called during construction. + freq = rrule['FREQ'][0] + interval = int(rrule['INTERVAL'][0]) if 'INTERVAL' in rrule else 1 + if 'BYDAY' in rrule and rrule['BYDAY'][0] not in self.DAY_ABBR: + raise ValueError('Unsupported BYDAY {} in RRULE'.format(rrule['BYDAY'])) + if 'BYMONTH' in rrule and len(rrule['BYMONTH'])>1: + raise ValueError('Unsupported multi-BYMONTH {} in RRULE'.format(rrule['BYMONTH'])) + if 'BYYEARDAY' in rrule: + raise ValueError('Unsupported BYYEARDAY in RRULE') + if 'BYMONTHDAY' in rrule: + if (freq!='YEARLY' or 'BYMONTH' not in rrule): + raise ValueError('Unsupported BYMONTHDAY in RRULE (not YEARLY/BYMONTH)') + # If we get here, it's YEARLY/BYMONTH/BYMONTHDAY + if len(rrule['BYMONTH'])>1: + raise ValueError('Unsupported BYMONTHDAY, multi-BYMONTH in RRULE') + if len(rrule['BYMONTHDAY'])>1: + raise ValueError('Unsupported multi-BYMONTHDAY in RRULE') + bmd_day = int(rrule['BYMONTHDAY'][0]) + bmd_month = int(rrule['BYMONTH'][0]) + if bmd_day!=self.start.day or bmd_month!=self.start.month: + raise ValueError('Unsupported YEARLY/BYMONTH/BYMONTHDAY != DTSTART in RRULE') + if 'BYSETPOS' in rrule: + raise ValueError('Unsupported BYSETPOS in RRULE') + if 'BYHOUR' in rrule: + raise ValueError('Unsupported BYHOUR in RRULE') + if 'BYMINUTE' in rrule: + raise ValueError('Unsupported BYMINUTE in RRULE') + if 'BYSECOND' in rrule: + raise ValueError('Unsupported BYSECOND in RRULE') + if 'BYWEEKNO' in rrule: + raise ValueError('Unsupported BYWEEKNO in RRULE') + if freq=='YEARLY': + self._set_yearly(interval) + elif freq=='MONTHLY': + self._set_monthly(interval) + elif freq=='WEEKLY': + self._set_weekly(rrule, interval) + elif freq=='DAILY': + self._set_daily(interval) + elif freq=='HOURLY': + self._set_hourly(interval) + elif freq=='MINUTELY': + self._set_minutely(interval) + else: # unrecognised freq - skip entry + raise ValueError('Unknown FREQ {:s} in RRULE'.format(freq)) + + + def _set_yearly(self, interval:int) -> None: + # Called on construction if a simple yearly repeat + self.delta = relativedelta(years=interval) + + + def _set_monthly(self, interval:int) -> None: + # Called on construction if a simple monthly repeat + self.delta = relativedelta(months=interval) + + + def _set_weekly(self, rrule:vRecur, interval:int) -> None: + # Called on construction if a weekly repeat. + # Try to also handle cases where rrule gives multiple days/startweek. + days_in_week = 0 # bitmask to be created + diw_from_st = 1<1 and 'WKST' in rrule: + st_wk <<= self.DAY_ABBR.index(rrule['WKST'][0]) + + # Accumulte list of deltas to add to loop through days + self.delta = [] + diw_bit = diw_from_st + i = 0 + while True: + i += 1 + diw_bit = (diw_bit<<1)%127 # rotate bits left + if diw_bit==st_wk: + i+=7*(interval-1) + if diw_bit & days_in_week: + self.delta.append(timedelta(days=i)) + i = 0 + if diw_bit==diw_from_st: + break + + + def _set_daily(self, interval:int) -> None: + # Called on construction if a simple daily repeat + self.delta = timedelta(days=interval) + + + def _set_hourly(self, interval:int) -> None: + # Called on construction if a simple hourly repeat + self.delta = timedelta(hours=interval) + + + def _set_minutely(self, interval:int) -> None: + # Called on construction if a simple minutely repeat + self.delta = timedelta(minutes=interval) + + + def _set_exdates(self, exdate) -> None: + # Initialise object's exdates variable, given an exdate structure. + # exdate argument is in format of exdate from an ical event, it + # might be a list, or might not... + if isinstance(exdate,list): + l = [dl.dts for dl in exdate] + self.exdates = sorted(set([datetime_to_date(t[i].dt) for t in l for i in range(len(t))])) + else: + l = exdate.dts + self.exdates = sorted(set([datetime_to_date(t.dt) for t in l])) + + + def _set_start_in_rng(self, start:dt_date) -> None: + # Set start date within given range (that is, on/after 'start') + self.start_in_rng = self.start + if isinstance(self.delta,list): + self.delta_index = 0 + if start is not None: + # We try to jump to first entry in range + # First compute d, distance from the range + d = start - datetime_to_date(self.start) + if isinstance(self.delta,list): + if d>timedelta(0): # start provided was after first repeat, so inc + # Want to do as much as possible in one increment + per = reduce(lambda x,y:x+y,self.delta) # sum of delta + s = d//per + self.start_in_rng += per * s + # After approximate jump, clear any extras. + # Do this even if d<=0 to catch case when first dates excluded + i = 0 + while dt_lt(self.start_in_rng,start) or self.is_exdate(self.start_in_rng): + self.start_in_rng += self.delta[i] + i = (i+1)%len(self.delta) + self.delta_index = i + else: # single delta, could be timedelta or relativedelta + if d>timedelta(0): # start provided was after first repeat, so inc + # Want to do as much as possible in one increment + if isinstance(self.delta,timedelta): + s = round(d/self.delta) + else: + # self.delta is a relativedelta + sst = self.start + try: + # If we have tzinfo, get rid to make relativedelta + sst = sst.replace(tzinfo=None) + except TypeError: + pass + d = relativedelta(start, sst) + if self.delta.years>0: + s = round(d.years/self.delta.years) + elif self.delta.months>0: + s = round((d.years*12 + d.months)/self.delta.months) + else: + s = 1 + self.start_in_rng += self.delta * s + # After approximate jump, clear any extras. + # Do this even if d<=0 to catch case when first dates excluded + while dt_lt(self.start_in_rng,start) or self.is_exdate(self.start_in_rng): + self.start_in_rng += self.delta + + + def _set_stop(self, rrule:vRecur, stop:dt_date) -> None: + # Set stop date in range (i.e. before 'stop' parameter). + # Note, 'stop' parameter is exclusive (this is more usual) + # but 'UNTIL' field in iCal is inclusive (according to standard). + # Internally RepeatInfo class will use an exclusive stop, so: + # - Name it stop_exc for clarity + # - Take care when using 'UNTIL' + until = rrule['UNTIL'][0] if 'UNTIL' in rrule else None + if until is None or (stop is not None and dt_lte(stop, until)): + self.stop_exc = stop + elif isinstance(until, dt_datetime): + self.stop_exc = until+timedelta(milliseconds=1) + else: + # dt_datetime is a date only + self.stop_exc = dt_datetime.combine(until,dt_time(microsecond=1)) + count = rrule['COUNT'][0] if 'COUNT' in rrule else None + if count is not None: + if self.exdates is not None: + raise ValueError('Unsupported COUNT & EXDATE') # !! fix me + if isinstance(self.delta, list): + di,md = divmod(count-1, len(self.delta)) + last_by_count = self.start + last_by_count += di*reduce(lambda x,y:x+y,self.delta) # sum() + if md: + last_by_count += reduce(lambda x,y:x+y,self.delta[:md]) + else: + last_by_count = self.start + (self.delta*(count-1)) + if self.stop_exc is None or dt_lt(last_by_count,self.stop_exc): + self.stop_exc = last_by_count+timedelta(milliseconds=1) if isinstance(last_by_count,dt_datetime) else dt_datetime.combine(last_by_count,dt_time(microsecond=1)) + if self.stop_exc is None: + raise RuntimeError('Unbounded repeats will lead to infinite list') + + + def is_exdate(self, dt:dt_date) -> bool: + # Returns True if dt is in exdates, False otherwise. + # Special case: if dt is a datetime, returns True if date + # component is in exdates. + if self.exdates is None: + return False + if isinstance(dt,dt_datetime) and dt.date() in self.exdates: + return True + return dt in self.exdates + + + def __iter__(self) -> 'RepeatIter_simpledelta': + # Return an iterator for this RepeatInfo + if self.start_in_rng is not None and isinstance(self.delta, list): + return RepeatIter_multidelta(self) + return RepeatIter_simpledelta(self) + + +class RepeatIter_simpledelta: + # Iterator class for RepeatInfo where we can just use a simple delta. + + def __init__(self, rinfo:RepeatInfo): + self.rinfo = rinfo + self.dt = rinfo.start_in_rng + + def __iter__(self) -> 'RepeatIter_simpledelta': + # Standard method for iterators + return self + + def __next__(self) -> dt_date: + # Return date/dattime for next occurence in range. + # Excluded dates are taken into account. + # Raises StopIteration at end of occurence list. + if self.dt is None or dt_lte(self.rinfo.stop_exc,self.dt): + raise StopIteration + r = self.dt + while True: + self.dt += self.rinfo.delta + if not self.rinfo.is_exdate(self.dt): + break + return r + + +class RepeatIter_multidelta(RepeatIter_simpledelta): + # Iterator class for RepeatInfo where we have different deltas + # (e.g. we may have different gaps between occurences, such as + # a weekly repeat that occurs on Mondays and Wednesdays). + + def __init__(self, rinfo:RepeatInfo): + super().__init__(rinfo) + self.i = rinfo.delta_index + + def __next__(self) -> dt_date: + # Return date/dattime for next occurence in range. + # Excluded dates are taken into account. + # Raises StopIteration at end of occurence list. + if self.dt is None or dt_lte(self.rinfo.stop_exc,self.dt): + raise StopIteration + r = self.dt + while True: + self.dt += self.rinfo.delta[self.i] + self.i = (self.i+1)%len(self.rinfo.delta) + if not self.rinfo.is_exdate(self.dt): + break + return r + + +def merge_repeating_entries_sort(target:list, ev:iEvent, start:dt_date, stop:dt_date) -> None: + # Given a sorted list of occurrences, 'target', and a single + # repeating event 'ev', splice the repeats of ev from 'start' + # to 'stop' into 'target', keeping it sorted. + ev_reps = repeats_in_range(ev, start, stop) + i,j = 0,0 + end_i = len(target) + end_j = len(ev_reps) + while i dt_date: + # Returns the date or datetime of the first occurrence of an event, + # given an rrule and an (earliest possible) start date. + # Does not take account of excluded dates. + rr = rrulestr(rrstr, dtstart=dtstart) + has_time = isinstance(dtstart, dt_datetime) + st = dtstart if has_time else dt_datetime.combine(dtstart, dt_time()) + ret = rr.after(st, inc=True) + if ret and not has_time: + ret = ret.date() + return ret + + +def repeats_in_range_with_rrstr(ev:iEvent, start:dt_date, stop:dt_date) -> list: + # Get repeat dates within given range using dateutil.rrule module. + # Slow, but comprehensive. Used as a fallback from repeats_in_range() + # when quick methods can't be used. + # Repeats are super clunky. + # Can caching results help? + rrstr = ev['RRULE'].to_ical().decode('utf-8') + dt = ev['DTSTART'].dt + hastime = isinstance(dt,dt_datetime) + exd = 'EXDATE' in ev + rr = rrulestr(rrstr,dtstart=dt,forceset=exd) + st = dt_datetime.combine(start, dt_time()) # set time to midnight + sp = dt_datetime.combine(stop, dt_time()) + if hastime: + # Could add tzinfo to combine() calls above, but this allows Python<3.6 + st = st.replace(tzinfo=dt.tzinfo) + sp = sp.replace(tzinfo=dt.tzinfo) + sp -= timedelta(milliseconds=1) + ret = rr.between(after=st,before=sp,inc=True) + if not hastime: + ret = [d.date() for d in ret] + if exd: + exdate_list = [ev['EXDATE'][i].dts[j] for i in range(len(ev['EXDATE'])) for j in range(len(ev['EXDATE'][i].dts))] if isinstance(ev['EXDATE'], list) else ev['EXDATE'].dts + if hastime: + for de in exdate_list: + if isinstance(de.dt,dt_datetime): + ret = [d for d in ret if d!=de.dt] + else: + ret = [d for d in ret if d.date()!=de.dt] + else: + for de in exdate_list: + if not isinstance(de.dt,dt_datetime): + ret = [d for d in ret if d!=de.dt] + return ret + + +def repeats_in_range(ev:iEvent, start:dt_date, stop:dt_date) -> list: + # Given a repeating event ev, return list of occurrences from + # dates start to stop. + try: + r_info = RepeatInfo(ev, start, stop) + except ValueError as err: + # RepeatInfo doesn't handle this type of repeat. + # Fall back to using rrule - more complete, but slower for simple repeats + print('Notice: Fallback to unoptimised repeat for "{:s}" ({:s})'.format(ev['SUMMARY'],str(err)), file=stderr) + return repeats_in_range_with_rrstr(ev, start, stop) + ret = list(iter(r_info)) + # Uncomment the next two lines to test calculated values (slow!) + #if ret != repeats_in_range_with_rrstr(ev, start, stop): + # print('Error: Wrong repeats for "{:s}"'.format(ev['SUMMARY']), file=stderr) + return ret diff --git a/pygenda/pygenda_config.py b/pygenda/pygenda_config.py new file mode 100644 index 0000000..f0d9128 --- /dev/null +++ b/pygenda/pygenda_config.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# +# pygenda_config.py +# Provides the Config class, to access configuration settings. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +import argparse +from gi.repository import GLib +from pathlib import Path +import configparser +from sys import stderr +from datetime import datetime +from typing import Optional + + +# Singleton class to handle config from .ini file & command line +class Config: + _cparser = configparser.RawConfigParser() + CONFIG_DIR = GLib.get_user_config_dir() + '/pygenda' + DEFAULT_CONFIG_FILE = CONFIG_DIR + '/pygenda.ini' + DEFAULT_ICS_FILENAME = 'pygenda.ics' # Put this here to avoid cyclic dep. + + + @classmethod + def init(cls) -> None: + # Initialise config - read command line, config file etc. + # This called when file is first included (see bottom of file). + cl_args = cls._read_arguments() + config_file = cl_args.config + cls.date = datetime.strptime(cl_args.date, '%Y-%m-%d').date() if cl_args.date else None + # Now read the config file + default_config = not config_file + if default_config: + config_file = cls.DEFAULT_CONFIG_FILE + # If using default, create directory if it doesn't exist + Path(config_file).parent.mkdir(parents=True, exist_ok=True) + if not cls._cparser.read(config_file): + if not default_config: + print("Configuration file {:s} not found".format(config_file), file=stderr) + exit(-1) + # Store the dirname so it can be used by other components + cls.conf_dirname = Path(config_file).parent.as_posix() + + # Read 'file' from command line, write to config store as ics_file + if cl_args.file: + cls.set('calendar', 'ics_file', cl_args.file) + cls.set('calendar', 'caldav_server', None) + if cl_args.view is not None: + cls.set('startup','view',cl_args.view.lower()) + + + @classmethod + def set_defaults(cls, sect:str, value_list:dict) -> None: + # Called by pygena components to set defaults for config parameters. + # This way, individual views etc control their own parameters. + if not cls._cparser.has_section(sect): + cls._cparser.add_section(sect) + for key in value_list: + if not cls._cparser.has_option(sect, key): + cls._cparser.set(sect,key,value_list[key]) + + + @staticmethod + def _read_arguments() -> argparse.Namespace: + # Helper function to parse & return command-line arguments. + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('-c', '--config', metavar='FILE', type=str, default=None, help='Config file. Default: {:s}'.format(Config.DEFAULT_CONFIG_FILE)) + parser.add_argument('-d', '--date', metavar='DATE', type=str, default=None, help='Cursor startup date (YYYY-MM-DD)') + parser.add_argument('-f', '--file', metavar='FILE', type=str, default=None, help='Calendar file. Default: {:s} in config directory'.format(Config.DEFAULT_ICS_FILENAME)) + parser.add_argument('-v', '--view', metavar='VIEW', type=str, default=None, help='Opening view') + return parser.parse_args() + + + @classmethod + def get(cls, section:str, option:str): + # Generic method to get a config option. + # Return type can be Any(?) - often str, also bool, None. + return cls._cparser.get(section,option) + + + @classmethod + def set(cls, section:str, option:str, value) -> None: + # Generic method to set a config option. + # 'value' parameter can be Any type. + if not cls._cparser.has_section(section): + cls._cparser.add_section(section) + cls._cparser.set(section, option, value) + + + @classmethod + def get_float(cls, section:str, option:str) -> Optional[float]: + # Get a config option as a float. + # Return None if the value can't be interpreted as a float. + v = cls._cparser.get(section,option) + if v is None or v=='': + return None + return cls._cparser.getfloat(section,option) + + + @classmethod + def get_int(cls, section:str, option:str) -> Optional[int]: + # Get a config option as an int. + # Return None if the value can't be interpreted as an int. + v = cls._cparser.get(section,option) + if v is None or v=='': + return None + return cls._cparser.getint(section,option) + + + @classmethod + def get_bool(cls, section:str, option:str) -> Optional[bool]: + # Get a config option as a bool. + # Return None if the value can't be interpreted as a bool. + v = cls._cparser.get(section,option) + if v is None or v=='': + return None + if isinstance(v, bool): + return v + return cls._cparser.getboolean(section,option) + + +# Setup when imported +Config.init() diff --git a/pygenda/pygenda_entryinfo.py b/pygenda/pygenda_entryinfo.py new file mode 100644 index 0000000..933dcd2 --- /dev/null +++ b/pygenda/pygenda_entryinfo.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# pygenda_entryinfo.py +# Class to encapsulate entry details passed from dialog to calendar. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +from datetime import date as dt_date, time as dt_time, datetime as dt_datetime, timedelta +from typing import Optional + + +class EntryInfo: + # Simple class to store entry info to be sent to Calendar + end_dt = None + duration = None + rep_type = None + rep_inter = 1 + rep_count = None + rep_until = None + rep_byday = None + rep_bymonthday = None + rep_exceptions = None + + def __init__(self, desc:str=None, start_dt:dt_date=None, end_dt:dt_date=None, duration:timedelta=None, status:str=None, location:str=None): + self.desc = '' if desc is None else desc + self.start_dt = start_dt # date or datetime + self.set_end_dt(end_dt) + self.set_duration(duration) # also checks only one of dur/enddt is set + self.status = status # string, e.g. 'CONFIRMED' + self.location = location if location else None + + + def get_start_date(self) -> Optional[dt_date]: + # Return the start date of the entry, without any time + if isinstance(self.start_dt, dt_datetime): + return self.start_dt.date() + return self.start_dt + + def get_start_time(self) -> Optional[dt_time]: + # Return the start time of the entry, or None if there's no time + if isinstance(self.start_dt, dt_datetime): + return self.start_dt.time() + return None + + def set_end_dt(self, end_dt:Optional[dt_date]) -> None: + # Set entry end date/time, checking no duration has been set + assert end_dt is None or self.duration is None + self.end_dt = end_dt + + def set_duration(self, dur:Optional[timedelta]) -> None: + # Set entry duration, checking no end date/time has been set + assert dur is None or self.end_dt is None + self.duration = dur + + def set_repeat_info(self, reptype:str, interval:int=None, count:int=None, until:dt_date=None, byday:str=None, bymonthday:str=None, except_list=None) -> None: + # Set repeat details for this Entry + assert until is None or count is None + self.rep_type = reptype + self.rep_inter = 1 if interval is None else interval + self.rep_count = count + self.rep_until = until + self.rep_byday = byday # e.g. '-1MO' = last Monday + self.rep_bymonthday = bymonthday # e.g. '-2' = second to last day + self.rep_exceptions = except_list diff --git a/pygenda/pygenda_gui.py b/pygenda/pygenda_gui.py new file mode 100644 index 0000000..4a04a3c --- /dev/null +++ b/pygenda/pygenda_gui.py @@ -0,0 +1,1880 @@ +# -*- coding: utf-8 -*- +# +# pygenda_gui.py +# Top-level GUI code and shared elements (e.g. soft buttons, dialogs) +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +from gi import require_version as gi_require_version +gi_require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib, Gio + +from datetime import date as dt_date, time as dt_time, datetime as dt_datetime, timedelta +from dateutil import rrule as du_rrule +from dateutil.relativedelta import relativedelta +from icalendar import Event as iEvent +from importlib import import_module +from os import path as ospath +from sys import stderr +import signal +import ctypes +from typing import Optional, Tuple + +# for internationalisation/localisation +import locale +_ = locale.gettext +from calendar import day_name, monthrange + +# pygenda components +from .pygenda_config import Config +from .pygenda_calendar import Calendar, RepeatInfo +from .pygenda_widgets import WidgetDate, WidgetTime, WidgetDuration +from .pygenda_entryinfo import EntryInfo +from .pygenda_util import guess_date_ord_from_locale,guess_date_sep_from_locale,guess_time_sep_from_locale, guess_date_fmt_text_from_locale, datetime_to_date +from .pygenda_version import __version__ + + +# Singleton class for top-level GUI control +class GUI: + _GLADE_FILE = '{:s}/pygenda.glade'.format(ospath.dirname(__file__)) + _CSS_FILE_APP = '{:s}/css/pygenda.css'.format(ospath.dirname(__file__)) + _CSS_FILE_USER = GLib.get_user_config_dir() + '/pygenda/pygenda.css' + _LOCALE_DIR = '{:s}/locale/'.format(ospath.dirname(__file__)) + _VIEWS = ('Week','Year') # Order gives order in menus + SPINBUTTON_INC_KEY = (Gdk.KEY_plus,Gdk.KEY_greater) + SPINBUTTON_DEC_KEY = (Gdk.KEY_minus,Gdk.KEY_less) + STYLE_ERR = 'dialog_error' + + cursor_date = dt_date.today() + cursor_idx_in_date = 0 # cursor index within date + today_toggle_date = None + today_toggle_idx = 0 + + Config.set_defaults('global',{ + 'language': '', # use OS language + 'hide_titlebar_when_maximized': False, + 'date_ord': guess_date_ord_from_locale(), + 'date_sep': guess_date_sep_from_locale(), + 'time_sep': guess_time_sep_from_locale(), + 'date_fmt_text': guess_date_fmt_text_from_locale(), + 'date_fmt_text_noyear': '', # construct from date_fmt_text + 'date_fmt_textabb': '', # construct from date_fmt_text + 'date_fmt_textabb_noyear': '', # construct from date_fmtabb_text + '24hr': False, + 'start_week_day': 0, # 0 = Monday, 6 = Sunday + 'tab_elts_datetime': False, + }) + Config.set_defaults('startup',{ + 'maximize': False, + 'fullscreen': False, + 'view': False, + 'softbutton_display': '', + }) + + # Constructor + @classmethod + def init(cls) -> None: + # First stage initialisation to bring up the UI. + # See init_stage2() below for init done after gtk_main loop started. + + # First set the locale, so UI language (e.g. in menu) is correct + cls._init_locale() + + # Construct GUI from GTK Builder XML glade file + cls._builder = Gtk.Builder() + cls._builder.add_from_file(cls._GLADE_FILE) + + cls._window = cls._builder.get_object('window_main') + if (not cls._window): # Sanity check + raise NameError('Main window not found') + cls._window.set_default_icon_name('x-office-calendar') + + cls._window.set_hide_titlebar_when_maximized(Config.get_bool('global','hide_titlebar_when_maximized')) + if Config.get_bool('startup','maximize'): + cls._window.maximize() + cls._is_fullscreen = Config.get_bool('startup','fullscreen') + if cls._is_fullscreen: + cls._window.fullscreen() + + # Handle SIGINT (e.g. from ctrl+C) etc. + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT_IDLE, signal.SIGINT, cls.exit) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT_IDLE, signal.SIGTERM, cls.exit) + + # Connect signals now, so clicking on [X] in window exits application + HANDLERS = { + 'window_main delete': cls.exit, + 'menuitem_quit': cls.exit, + 'menuitem_cut': cls.cut_request, + 'menuitem_copy': cls.copy_request, + 'menuitem_paste': cls.paste_request, + 'menuitem_newentry': cls.event_newentry, + 'menuitem_edittime': cls.event_edittime, + 'menuitem_editrepeats': cls.event_editrepeats, + 'menuitem_editalarm': cls.event_editalarm, + 'menuitem_editdetails': cls.event_editdetails, + 'menuitem_stat_none': lambda a: cls.event_stat_toggle(None), + 'menuitem_stat_confirmed': lambda a: cls.event_stat_toggle('CONFIRMED'), + 'menuitem_stat_canceled': lambda a: cls.event_stat_toggle('CANCELLED'), + 'menuitem_stat_tentative': lambda a: cls.event_stat_toggle('TENTATIVE'), + 'menuitem_deleteentry': cls.delete_request, + 'menuitem_switchview': cls.switch_view, + 'menuitem_goto': cls.dialog_goto, + 'menuitem_fullscreen': cls.toggle_fullscreen, + 'menuitem_about': cls.dialog_about, + 'button0_clicked': cls.event_newentry, + 'button1_clicked': cls.switch_view, + 'button2_clicked': cls.dialog_goto, + 'button3_clicked': cls.debug, # zoom, to be decided/implemented + 'exceptions_modify': EntryDialogController.dialog_repeat_exceptions + } + cls._builder.connect_signals(HANDLERS) + + cls._box_view_cont = cls._builder.get_object('box_view_cont') + if (not cls._box_view_cont): # Sanity check + raise NameError('View container not found') + + # Add a spinner while the view is still loading + cls._loading_indicator = Gtk.Box() + cls._loading_indicator.set_name('view_loading') + spinner = Gtk.Spinner.new() + cls._loading_indicator.set_center_widget(spinner) + cls._box_view_cont.pack_start(cls._loading_indicator, True, True, 0) + spinner.start() + cls._loading_indicator.show_all() + + # Setup CSS provider(s) now so "loading" notice is styled + css_prov = Gtk.CssProvider() + css_prov.load_from_path(cls._CSS_FILE_APP) + Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), css_prov, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + try: + css_prov_u = Gtk.CssProvider() + css_prov_u.load_from_path(cls._CSS_FILE_USER) + Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), css_prov_u, Gtk.STYLE_PROVIDER_PRIORITY_USER) + except: + pass + + # Set position/display of softbuttons before showing loading indicator + cfig_sbut = Config.get('startup','softbutton_display').lower() + bbut = cls._builder.get_object('box_buttons') + if cfig_sbut == 'hide': + bbut.hide() + elif cfig_sbut == 'left': + cls._box_view_cont.reorder_child(bbut,0) + else: + cls._box_view_cont.reorder_child(bbut,1) + + # Delay further initialisation so we can display GUI/window early + cls._window.show() + GLib.idle_add(cls.init_stage2) + + + @staticmethod + def init_cal(task:Gio.Task, src_obj, t_data, cancel:Gio.Cancellable)->None: + # Wrapper around Calendar.init(), so we can call it in a GTask thread. + Calendar.init() + task.return_int(0) + + @classmethod + def init_cal_callback(cls, data, task:Gio.Task) -> None: + # Callback called to signal that init_cal() has finished. + cls._starting_cal = False + + @staticmethod + def init_cal_iosc(job:Gio.IOSchedulerJob, cancel:Gio.Cancellable, u_data) -> bool: + # Wrapper around Calendar.init, so we can call it in an IOScheduler. + # Fallback for Gio version < 2.36 (hence needed for Gemini). + try: + Calendar.init() + finally: + GUI._starting_cal = False + return False # indicates task complete + + + @classmethod + def init_stage2(cls) -> None: + # Second-stage initialisation. + # This is run in the gtk_main thread after basic UI is displayed. + + # First, initialise calendar connector. + # Do this outside main thread, so UI remains active/responsive. + ci_cancel = Gio.Cancellable.new() + cls._starting_cal = True + if 'run_in_thread' in dir(Gio.Task): + # Preferred way to run in separate thread. + task = Gio.Task.new(cancellable=ci_cancel, callback=cls.init_cal_callback) + task.run_in_thread(cls.init_cal) + else: + # Deprecated, but needed to run on Gemini. + Gio.io_scheduler_push_job(cls.init_cal_iosc, None, GLib.PRIORITY_DEFAULT, ci_cancel) + + # Initialise some things in main thread while calendar loading + cls._init_clipboard() + cls._init_date_format() + EntryDialogController.init() + + # If date set from command line, jump there now + if Config.date: + GUI.cursor_date = Config.date + + # Wait for calendar to finish initialising before doing views + while cls._starting_cal: + if Gtk.main_iteration_do(False): + # True => main_quit() has been called + ci_cancel.cancel() + return + GLib.usleep(1000) # microsecs + del(cls._starting_cal) # Flag no longer needed + + # Get rid of loading indicator, but store position for view placement + view_pos = cls._box_view_cont.child_get_property(cls._loading_indicator, 'position') + cls._box_view_cont.remove(cls._loading_indicator) + del(cls._loading_indicator) # should be only remaining reference + + # Check that calendar initialised/connected properly + if not hasattr(Calendar, 'calConnector') or not Calendar.calConnector: + print('Error: Calendar could not be initialized', file=stderr) + Gtk.main_quit() + return + + cls._init_views() + + cls._view_idx = 0 # take first view as default + vw = Config.get('startup','view') + if vw: + for ii in range(len(GUI._VIEWS)): + if GUI._VIEWS[ii].lower() == vw: + cls._view_idx = ii + break + cls._eventbox = Gtk.EventBox() + cls._box_view_cont.pack_start(cls._eventbox, True, True, 0) + cls._box_view_cont.reorder_child(cls._eventbox, view_pos) + cls._eventbox.add(cls.view_widgets[cls._view_idx]) + cls._eventbox.connect('key_press_event', cls.keypress) + cls.view_widgets[cls._view_idx].grab_focus() # so it gets keypresses + del(cls._box_view_cont) # don't need this anymore + + # Add functionality to spinbuttons not provided by GTK + cls._init_spinbuttons() + cls._init_comboboxes() + cls._init_entryboxes() + + # Menu bar & softbutton bar made insensitive in .glade for startup. + # We make them sensitive here before activating view. + cls._builder.get_object('menu_bar').set_sensitive(True) + cls._builder.get_object('box_buttons').set_sensitive(True) + + cls.view_redraw() # Draw active view + cls._eventbox.show_all() + + + @classmethod + def _init_locale(cls) -> None: + # Initialise language from config or OS settings + lang = Config.get('global', 'language') + if not lang: + lang = locale.getlocale() + if isinstance(lang,str) and '.' not in lang: + # Need to include encoding + lang = (lang,'UTF-8') + locale.setlocale(locale.LC_ALL, lang) + locale.bindtextdomain('pygenda', cls._LOCALE_DIR) + locale.textdomain('pygenda') + + + @classmethod + def _init_clipboard(cls) -> None: + # Load clipboard helper library if available, or set to None + try: + libclip_file = '{:s}/libpygenda_clipboard.so'.format(ospath.dirname(__file__)) + cls.lib_clip = ctypes.CDLL(libclip_file) + except: + print('Warning: Failed to load clipboard library', file=stderr) + cls.lib_clip = None + + + @classmethod + def _init_date_format(cls) -> None: + # Initialise date formatting strings from config + cls.date_order = cls._date_order_from_config() + + # Make date_formatting_numeric - a format string like '%Y-$m-$d' + dto_tmp = cls.date_order.replace('M','m').replace('D','d') + cls.date_formatting_numeric = '%{0:s}{sep:s}%{1:s}{sep:s}%{2:s}'.format(dto_tmp[0],dto_tmp[1],dto_tmp[2],sep=Config.get('global','date_sep')) + + # Make format strings for longer formats, e.g. "Mon Dec 31, 2001" + cls.date_formatting_text = Config.get('global','date_fmt_text') + + # Other format strings might be constructed if not set in config file + # date_formatting_text_noyear + cst = Config.get('global','date_fmt_text_noyear') + cls.date_formatting_text_noyear = ' '.join(cls.date_formatting_text.replace('%y','%Y').replace('%Y,',' ').replace(', %Y',' ').replace('%Y','').split()) if not cst else cst + + # date_formatting_textabb + cst = Config.get('global','date_fmt_textabb') + cls.date_formatting_textabb = cls.date_formatting_text.replace('%A','%a').replace('%B','%b') if not cst else cst + + # date_formatting_textabb_noyear + cst = Config.get('global','date_fmt_textabb_noyear') + cls.date_formatting_textabb_noyear = ' '.join(cls.date_formatting_textabb.replace('%y','%Y').replace('%Y,',' ').replace(', %Y',' ').replace('%Y','').split()) if not cst else cst + + + @staticmethod + def _date_order_from_config() -> str: + # Process date order string from config. + # Converts, e.g. 'YYYY-MM-DD' -> 'YMD' & checks output is valid. + raw = Config.get('global','date_ord').upper() + ret = '' + for ch in raw: + if ch in 'YMD' and ch not in ret: + ret += ch + assert(len(ret)==3) + return ret + + + @classmethod + def _init_views(cls) -> None: + # Get new Gtk Widgets for views. + # Add view switching options to menu. + cls.views = [] + cls.view_widgets = [] + for v in GUI._VIEWS: + m = import_module('.pygenda_view_{:s}'.format(v.lower()),package='pygenda') + cls.views.append(getattr(m, 'View_{:s}'.format(v))) + cls.view_widgets.append(cls.views[-1].init()) + cls.view_widgets[-1].get_style_context().add_class('view') + + menu_views_list = cls._builder.get_object('menu_views_list') + accel_gp = Gtk.AccelGroup() + cls._window.add_accel_group(accel_gp) + for i in range(len(cls.views)): + m = Gtk.MenuItem(cls.views[i].view_name()) + m.set_use_underline(True) + akey = cls.views[i].accel_key() + if akey: + m.add_accelerator('activate', accel_gp, akey, Gdk.ModifierType.SHIFT_MASK|Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE) + m.connect('activate',cls.switch_view,i) + m.show() + menu_views_list.add(m) + + + @classmethod + def _init_spinbuttons(cls) -> None: + # Connect spinbutton keypress event to handler for extra features. + # All spinbuttons are part of the Entry dialog, so this could go + # in the EntryDialogController class. However, in theory spinbuttons + # can be anywhere, so putting this in the general GUI class. + for sb_id in ('allday_count','repeat_interval','repeat_occurrences'): + sb = cls._builder.get_object(sb_id) + sb.connect('key-press-event', cls._spinbutton_keypress) + sb.connect('focus-out-event', cls._focusout_unhighlight) + + + @classmethod + def _init_comboboxes(cls) -> None: + # Connect ComboBox events to handlers for extra features. + for cb_id in ('combo_repeat_type','combo_bydaymonth','combo_byday_ord','combo_byday_day','combo_status'): + cb = cls._builder.get_object(cb_id) + cb.connect('key-press-event', cls._combobox_keypress) + + + @classmethod + def _init_entryboxes(cls) -> None: + # Connect Entry textbox events to handlers for extra features. + for eb_id in ('entry_desc','entry_location'): + eb = cls._builder.get_object(eb_id) + eb.connect('focus-out-event', cls._focusout_unhighlight) + + + @classmethod + def keypress(cls, wid:Gtk.Widget, ev:Gdk.EventKey) -> None: + # Called whenever a key is pressed/repeated when View in focus + cls.views[cls._view_idx].keypress(wid,ev) + + + @staticmethod + def _spinbutton_keypress(wid:Gtk.SpinButton, ev:Gdk.EventKey) -> bool: + # Called to handle extra spinbutton keyboard controls + shiftdown = ev.state&Gdk.ModifierType.SHIFT_MASK + if ev.keyval in GUI.SPINBUTTON_INC_KEY or (shiftdown and ev.keyval==Gdk.KEY_Up): + wid.update() # So if user types "5" then "+" value changes to "6" + wid.spin(Gtk.SpinType.STEP_FORWARD, 1) + return True # done + if ev.keyval in GUI.SPINBUTTON_DEC_KEY or (shiftdown and ev.keyval==Gdk.KEY_Down): + wid.update() + wid.spin(Gtk.SpinType.STEP_BACKWARD, 1) + return True # done + if ev.keyval==Gdk.KEY_Up: + return wid.get_toplevel().child_focus(Gtk.DirectionType.UP) + if ev.keyval==Gdk.KEY_Down: + return wid.get_toplevel().child_focus(Gtk.DirectionType.DOWN) + return False # propagate event + + + @staticmethod + def _combobox_keypress(wid:Gtk.ComboBox, ev:Gdk.EventKey) -> bool: + # Called to handle extra combobox keyboard controls + # BUG!! This is not called when the combobox is in "popout" state + if ev.keyval==Gdk.KEY_Return: + # Manually trigger default event on dialog box + dlg = wid.get_toplevel() + if dlg: + dlg.response(Gtk.ResponseType.OK) + return True # done + + mdl = wid.get_model() + count = mdl.iter_n_children() + a = wid.get_active() + shiftdown = ev.state&Gdk.ModifierType.SHIFT_MASK + if ev.keyval in GUI.SPINBUTTON_INC_KEY or (shiftdown and ev.keyval==Gdk.KEY_Down): + a = (a+1)%count + wid.set_active(a) + return True # done + if ev.keyval in GUI.SPINBUTTON_DEC_KEY or (shiftdown and ev.keyval==Gdk.KEY_Up): + if a<0: + a = 0 + a = (a-1)%count + wid.set_active(a) + return True # done + + if ev.keyval==Gdk.KEY_Up: + return wid.get_toplevel().child_focus(Gtk.DirectionType.UP) + if ev.keyval==Gdk.KEY_Down: + return wid.get_toplevel().child_focus(Gtk.DirectionType.DOWN) + if ev.keyval in (Gdk.KEY_Left,Gdk.KEY_Right): + # Return - otherwise detected as alphabetic (not sure why!) + return False + + ch = chr(ev.keyval) + if ch.isalpha() or ch.isdigit(): + ch = ch.lower() + # Search for ch in first characters of combobox values + it = mdl.iter_nth_child(None,a) + for i in range(1,count): + it = mdl.iter_next(it) + if it is None: # at end of list, loop to top! + it = mdl.get_iter_first() + ch1 = mdl.get(it,0)[0][0].lower() + if ch==ch1: + wid.set_active((a+i)%count) + break + return True # done + + return False # propagate event + + + @staticmethod + def _focusout_unhighlight(wid:Gtk.Widget, ev:Gdk.EventKey) -> bool: + # Called to handle remove highlight from entry box/spinbutton + # when focus moves to another widget + dlg = wid.get_toplevel() + if dlg.get_focus() != wid: + wid.select_region(0,0) # remove highlight + return False # propagate event + + + @classmethod + def view_redraw(cls, ev_changes:bool=False) -> None: + # Redraw the currently active view. + # ev_changes: bool, True if events need updating too + cls.views[cls._view_idx].redraw(ev_changes) + + + @classmethod + def switch_view(cls, wid:Gtk.Widget, idx:int=None) -> None: + # Callback from UI widget (e.g. menu, softbutton) to change view. + # idx = index of new view (otherwise goes to next view in list) + if idx is None: + # Go to next view in list + cls._view_idx = (cls._view_idx+1)%len(cls.views) + elif cls._view_idx == idx: + return # No change, so skip redraw + else: + cls._view_idx = idx + cls._eventbox.remove(cls._eventbox.get_child()) + new_view = cls.views[cls._view_idx] + new_view.renew_display() + new_view.redraw(True) + new_wid = cls.view_widgets[cls._view_idx] + cls._eventbox.add(new_wid) + new_wid.grab_focus() + new_wid.show_all() + + + @classmethod + def cursor_set(cls, dt:dt_date, idx:int=None) -> None: + # Set current cursor date, and optionally the index within the date. + # Call redraw on view if required. + if dt != cls.cursor_date or (idx is not None and idx != cls.cursor_idx_in_date): + cls.cursor_date = dt + if idx is not None: + cls.cursor_idx_in_date = idx + cls.view_redraw(False) + + + @classmethod + def cursor_inc(cls, delta:timedelta, idx:int=None) -> None: + # Add delta to current cursor date; optionally set index in date. + # Call redraw on view. + cls.cursor_date += delta + if idx is not None: + cls.cursor_idx_in_date = idx + cls.view_redraw(False) + + + # Main + @classmethod + def main(cls) -> None: + # Run the man Gtk loop + Gtk.main() + + + # Signal handling functions + @classmethod + def exit(cls, *args) -> None: + # Callback for various types of exit signal (command line, menus...) + Gtk.main_quit() + + @classmethod + def event_newentry(cls, *args) -> None: + # Callback for new entry signal (menu, softbutton) + EntryDialogController.newentry() + + @classmethod + def event_edittime(cls, *args) -> None: + # Callback for change-entry-time signal + en = cls.views[cls._view_idx].get_cursor_entry() + if en: + EntryDialogController.editentry(en, EntryDialogController.TAB_TIME) + + @classmethod + def event_editrepeats(cls, *args) -> None: + # Callback for change-entry-repeats signal + en = cls.views[cls._view_idx].get_cursor_entry() + if en: + EntryDialogController.editentry(en, EntryDialogController.TAB_REPEATS) + + @classmethod + def event_editalarm(cls, *args) -> None: + # Callback for change-entry-alarm signal + en = cls.views[cls._view_idx].get_cursor_entry() + if en: + EntryDialogController.editentry(en, EntryDialogController.TAB_ALARM) + + @classmethod + def event_editdetails(cls, *args) -> None: + # Callback for change-entry-details signal + en = cls.views[cls._view_idx].get_cursor_entry() + if en: + EntryDialogController.editentry(en, EntryDialogController.TAB_DETAILS) + + + @classmethod + def event_stat_toggle(cls, stat:Optional[str]) -> None: + # Handle signals from menu to change current entry's status. + # Paramenter stat is None or text string for status (eg 'CONFIRMED'). + en = cls.views[cls._view_idx].get_cursor_entry() + if en: + Calendar.set_toggle_status_entry(en, stat) + cls.view_redraw(True) + + + @classmethod + def delete_request(cls, *args) -> None: + # Callback to implement "delete" from GUI, e.g. backspace key pressed + en = cls.views[cls._view_idx].get_cursor_entry() + if en is not None: + cls.dialog_deleteentry(en) + + + @classmethod + def cut_request(cls, *args) -> None: + # Handler to implement "cut" from GUI, e.g. cut clicked in menu + en = cls.views[cls._view_idx].get_cursor_entry() + if en and 'SUMMARY' in en: + if cls.lib_clip is None: + # Don't do fallback - might lead to unexpected data loss + print('Warning: No clipboard library, cut not available', file=stderr) + elif 'RRULE' in en: # repeating entry + # Need to think about how to implement this from UI side. + # Problem: Does user expect single occurrence to be cut, or all? + # Maybe bring up dialog "Cut single occurrence, or all repeats?" + # Then, do we do the came for Copying repeating entries? + # How do we adapt repeats when moved to a different date? + print('Warning: Cutting repeating entries not implemented', file=stderr) + else: + txtbuf = bytes(en['SUMMARY'], 'utf-8') + calbuf = en.to_ical() + cls.lib_clip.set_cb(ctypes.create_string_buffer(txtbuf),ctypes.create_string_buffer(calbuf)) + Calendar.delete_entry(en) + cls.view_redraw(True) + + + @classmethod + def copy_request(cls, *args) -> None: + # Handler to implement "copy" from GUI, e.g. copy clicked in menu + en = cls.views[cls._view_idx].get_cursor_entry() + if en and 'SUMMARY' in en: + if cls.lib_clip is None: + print('Warning: No clipboard library, fallback to text copy', file=stderr) + cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + txt = en['SUMMARY'] + cb.set_text(txt, -1) + else: + txtbuf = bytes(en['SUMMARY'], 'utf-8') + calbuf = en.to_ical() + cls.lib_clip.set_cb(ctypes.create_string_buffer(txtbuf),ctypes.create_string_buffer(calbuf)) + + + @classmethod + def paste_request(cls, *args) -> None: + # Handler to implement "paste" from GUI, e.g. paste clicked in menu + cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + + # First, we try requesting a 'text/calendar' type from the clipboard + sdat = cb.wait_for_contents(Gdk.Atom.intern('text/calendar', False)) + try: + events = iEvent.from_ical(sdat.get_data()) + ev = events.walk('VEVENT')[0] + Calendar.new_entry_from_example(ev, dt_start=cls.cursor_date) + cls.view_redraw(True) + return + except: + None + + # Fallback: request plain text from clipboard + txt = cb.wait_for_text() + if txt is not None: + txt = txt.strip() + txt = txt.replace('\n',' ') + txt = txt.replace('\t',' ') + # Open a New Entry dialog with description initialised as txt + GLib.idle_add(EntryDialogController.newentry,txt) + + + @classmethod + def dialog_deleteentry(cls, en:iEvent) -> None: + # Dialog to implement "delete" from GUI, e.g. backspace key + dialog = Gtk.Dialog(title=_('Delete Entry'), parent=cls._window, + flags=Gtk.DialogFlags.MODAL|Gtk.DialogFlags.DESTROY_WITH_PARENT, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CLOSE, Gtk.STOCK_DELETE, Gtk.ResponseType.APPLY)) + if 'RRULE' in en: + # repeating entry - clarify what is being deleted + # !! We should really ask if user wants to delete all/single etc. + l_template = _('Delete all repeats:\n"{:s}"?') + else: + l_template = _('Delete entry:\n"{:s}"?') + lab = Gtk.Label(l_template.format(en['SUMMARY'] if 'SUMMARY' in en else 'UNNAMED')) + if (not dialog or not lab): # Sanity check + raise NameError('Dialog Delete creation failure') + dialog.set_resizable(False) + lab.set_justify(Gtk.Justification.CENTER) + dialog.get_content_area().add(lab) + dialog.set_default_response(Gtk.ResponseType.APPLY)#Enter action + dialog.show_all() + response = dialog.run() + dialog.destroy() + if response == Gtk.ResponseType.APPLY: + Calendar.delete_entry(en) + cls.view_redraw(True) + + + @classmethod + def dialog_goto(cls, *args) -> None: + # Called to implement "go to" from GUI, e.g. button + dialog = Gtk.Dialog(title=_('Go To'), parent=cls._window, + flags=Gtk.DialogFlags.MODAL|Gtk.DialogFlags.DESTROY_WITH_PARENT, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CLOSE)) + wdate = WidgetDate(cls.cursor_date) + if (not dialog or not wdate): # Allocation check + raise NameError('Dialog Goto creation failure') + wdate.connect('changed', GUI.check_date_fixed) + dialog.set_resizable(False) + dialog.get_content_area().add(wdate) + wdate.set_halign(Gtk.Align.CENTER) + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_JUMP_TO, Gtk.IconSize.BUTTON) + go_but = Gtk.Button(label=_('Go'), image=icon) + go_but.set_can_default(True) + dialog.add_action_widget(go_but, Gtk.ResponseType.APPLY) + go_but.grab_default() # So "Enter" activates go_but + dialog.show_all() + while True: + response = dialog.run() + dt = wdate.get_approx_date_or_none() # So Feb 30th -> end of Feb + if response!=Gtk.ResponseType.APPLY or dt is not None: + break + # Date is invalid, add error styling + wdate.get_style_context().add_class(GUI.STYLE_ERR) + if response == Gtk.ResponseType.APPLY: + cls.cursor_set(dt) + dialog.destroy() + + + @staticmethod + def check_date_fixed(wid:WidgetDate) -> None: + # Removes error highlight if date is valid (e.g. not 30th Feb) + # Would be nice to make this a method of WidgetDate class. + # Can be used as a callback, e.g. attached to 'changed' signal + if wid.get_date_or_none() is not None: + wid.get_style_context().remove_class(GUI.STYLE_ERR) + + + @classmethod + def dialog_about(cls, *args) -> None: + # Display the "About" dialog + dialog = Gtk.AboutDialog(parent=cls._window) + dialog.set_program_name('Pygenda') + dialog.set_copyright(u'Copyright © 2022 Matthew Lewis') + dialog.set_license_type(Gtk.License.GPL_3_0_ONLY) + dialog.set_logo_icon_name('x-office-calendar') + dialog.set_authors(('Matthew Lewis',)) + dialog.set_version('version {:s}'.format(__version__)) + dialog.set_comments(_(u'A calendar/agenda application written in Python/GTK3. The UI is inspired by the Agenda programs on the Psion Series 3 and Series 5 PDAs.\nWARNING: This is in-development code, released as a preview for developers. It will probably corrupt your data.')) + dialog.show_all() + dialog.run() + dialog.destroy() + + + @classmethod + def toggle_fullscreen(cls, *args) -> None: + # Callback to toggle fullscreen mode on/off (e.g. from menu) + # !! to-do: update menu text to reflect state + cls._is_fullscreen = not cls._is_fullscreen + if cls._is_fullscreen: + cls._window.fullscreen() + else: + cls._window.unfullscreen() + + + @classmethod + def debug(cls, *args): + # Temporary callback - delete me !!!! + # Placeholder until we decide what fourth button does + print('Button clicked {}'.format(args[0])) + + +# Singleton class to manage Entry dialog +class EntryDialogController: + TAB_TIME = 0 + TAB_REPEATS = 1 + TAB_ALARM = 2 + TAB_DETAILS = 3 + TAB_COUNT = 4 + + @classmethod + def init(cls): + # Initialiser for singleton class. + # Called from GUI init_stage2(). + + # Get some references to dialog elements in glade + cls.dialog = GUI._builder.get_object('dialog_entry') + if (not cls.dialog): # Sanity check + raise NameError('Dialog Entry not found') + + cls.wid_desc = GUI._builder.get_object('entry_desc') + cls._wid_desc_changed_handler = cls.wid_desc.connect('changed', cls._desc_changed) + wid_grid = GUI._builder.get_object('dialogentry_grid') + cls.wid_tabs = GUI._builder.get_object('entry_tabs') + + # Create & add date entry widget + cls.wid_date = WidgetDate(GUI.cursor_date) + wid_grid.attach(cls.wid_date, 1,1, 1,1)# Can we locate this another way? + cls.wid_date.show_all() + + # Init other groups of widgets + cls._init_timefields() + cls._init_repeatfields() + cls._init_alarmfields() + cls._init_detailfields() + # Also need to init alarm & detail fields + cls._init_navigation() + + # cls._empty_desc_allowed has three possible values: + # None - empty desc allowed in dialog (signals delete entry) + # True - empty desc allowed, but can turn False! + # False - empty desc not allowed in dialog (e.g. new entry) + cls._empty_desc_allowed = None + + + @classmethod + def _init_timefields(cls) -> None: + # Initialise widgets etc in the Entry dialog under the "Time" tab. + # Called on app startup. + cls.wid_timed_buttons = GUI._builder.get_object('radiobuttons_istimed').get_children() + + # Create widgets for entry time & duration fields + cls.wid_time = WidgetTime(dt_time(hour=9)) + cls.wid_dur = WidgetDuration(timedelta()) + cls.wid_endtime = WidgetTime(dt_time(hour=9)) + # Add to dialog + wid_reveal_tm_e = GUI._builder.get_object('revealer_time_e') + tbox = wid_reveal_tm_e.get_child() # should be a GtkBox + tbox.add(cls.wid_time) + tbox.add(cls.wid_dur) + tbox.add(cls.wid_endtime) + tbox.show_all() + + # Make lists of revealers, so they can be hidden/revealed + cls.revs_timedur = cls._revealers_from_ids('revealer_time_l', 'revealer_time_e') + cls.revs_allday = cls._revealers_from_ids('revealer_allday_l', 'revealer_allday_e') + cls.wid_allday_count = GUI._builder.get_object('allday_count') + + cls.wid_timed_buttons[1].connect('toggled', cls._do_timed_toggle) + cls.wid_timed_buttons[2].connect('toggled', cls._do_allday_toggle) + + # We keep references to these connections, so then can be (un)blocked. + cls._tmdur_handler_time = cls.wid_time.connect('changed', cls._tmdur_changed, 0) + cls._tmdur_handler_dur = cls.wid_dur.connect('changed', cls._tmdur_changed, 1) + cls._tmdur_handler_endtime = cls.wid_endtime.connect('changed', cls._tmdur_changed, 2) + + # By default the signal is *blocked* - unblock when dialog active + cls._block_tmdur_signals() + + + @classmethod + def _init_repeatfields(cls) -> None: + # Initialise widgets etc in the Entry dialog under the "Repeats" tab. + # Called on app startup. + cls.wid_rep_type = GUI._builder.get_object('combo_repeat_type') + cls.revs_repeat = cls._revealers_from_ids('revealer_repeat_l','revealer_repeat_e') + cls.revs_rep_monthdays = cls._revealers_from_ids('revealer_repeat_monthday_e') + cls.revs_rep_weekdays = cls._revealers_from_ids('revealer_repeat_weekday_e') + cls.revs_rep_monthweekdays = cls._revealers_from_ids('revealer_repeat_day_l') + cls.wid_rep_type.connect('changed', cls._reptype_changed) + + cls.wid_rep_interval = GUI._builder.get_object('repeat_interval') + cls.wid_rep_forever = GUI._builder.get_object('repeat_forever') + cls.wid_repbymonthday = GUI._builder.get_object('combo_bydaymonth') + cls.wid_repbyweekday_day = GUI._builder.get_object('combo_byday_day') + cls.wid_repbyweekday_ord = GUI._builder.get_object('combo_byday_ord') + cls.wid_rep_forever.connect('toggled', cls._do_repeatforever_toggle) + + cls.wid_rep_occs = GUI._builder.get_object('repeat_occurrences') + cls.wid_rep_enddt = WidgetDate(GUI.cursor_date) + rbox = GUI._builder.get_object('revealer_repeat_until_e').get_child() # Should be a GtkBox + rbox.add(cls.wid_rep_enddt) + rbox.show_all() + cls.revs_rep_ends = cls._revealers_from_ids('revealer_repeat_until_l', 'revealer_repeat_until_e') + + cls.rep_occs_determines_end = True + cls._rep_handler_date = cls.wid_date.connect('changed', cls._repend_changed,0) + cls._rep_handler_time = cls.wid_time.connect('changed', cls._repend_changed,0) + cls._rep_handler_type = cls.wid_rep_type.connect('changed', cls._repend_changed,0) + cls._rep_handler_mday = cls.wid_repbymonthday.connect('changed', cls._repend_changed,0) + cls._rep_handler_wdayday = cls.wid_repbyweekday_day.connect('changed', cls._repend_changed,0) + cls._rep_handler_wdayord = cls.wid_repbyweekday_ord.connect('changed', cls._repend_changed,0) + cls._rep_handler_inter = cls.wid_rep_interval.connect('changed', cls._repend_changed,0) + cls._rep_handler_occs = cls.wid_rep_occs.connect('changed', cls._repend_changed,1) + cls._rep_handler_enddt = cls.wid_rep_enddt.connect('changed', cls._repend_changed,2) + + # For repeat on "1st Sat." etc, we fill ComboBox with local day names + day = Config.get_int('global','start_week_day') + for i in range(7): + cls.wid_repbyweekday_day.append(RepeatInfo.DAY_ABBR[day],day_name[day]) + day = (day+1)%7 + + # Get label used to display exception dates + cls._lab_rep_exceptions = GUI._builder.get_object('lab_rep_exceptions') + + # We want default to be signal *blocked* - unblock when dialog active + cls._block_rep_occend_signals() + + + @classmethod + def _init_alarmfields(cls) -> None: + # Initialise widgets etc in the Entry dialog under the "Alarm" tab. + # Called on app startup. + cls.wid_alarmset = GUI._builder.get_object('alarm-set') + + + @classmethod + def _init_detailfields(cls) -> None: + # Initialise widgets etc in the Entry dialog under the "Details" tab. + # Called on app startup. + cls.wid_status = GUI._builder.get_object('combo_status') + cls.wid_location = GUI._builder.get_object('entry_location') + + + @classmethod + def _init_navigation(cls) -> None: + # For some reason, cannot generally navigate up/down from + # radio buttons. This sets up a custom handler. + for i in range(3): + cls.wid_timed_buttons[i].connect('key_press_event', cls._radiobutton_keypress) + + + @classmethod + def _revealers_from_ids(cls, *idlist) -> list: + # Given a list of id strings, in glade file, make a list of revealers + revs = [] + for id in idlist: + obj = GUI._builder.get_object(id) + if obj is not None: + revs.append(obj) + # Reduce transition time, o/w devices like Gemini feel sluggish. + # Tried to do this with CSS, so could be set per device, + # but couldn't get it to work. So this is a workaround. + # Alternative: in ~/.config/gtk-3.0/settings.ini, try one of + # gtk-enable-animations=0 + # gtk-revealer-transition-duration=10 + obj.set_transition_duration(20) # millisecs; default 250 + else: + print('Warning: Object with id "{:s}" not found'.format(id), file=stderr) + return revs + + + @staticmethod + def _do_multireveal(revlist, reveal:bool) -> None: + # Given a list of ids, reveal (or hide) children + for r in revlist: + r.set_reveal_child(reveal) + + + @staticmethod + def _radiobutton_keypress(wid:Gtk.RadioButton, ev:Gdk.EventKey) -> bool: + # Custom hander for up/down keys on radiobuttons. + # Note: Assumes there's nothing to left/right so can use TAB_FWD/BKWD + if ev.keyval==Gdk.KEY_Up: + return wid.get_toplevel().child_focus(Gtk.DirectionType.TAB_BACKWARD) + if ev.keyval==Gdk.KEY_Down: + return wid.get_toplevel().child_focus(Gtk.DirectionType.TAB_FORWARD) + return False # Indicates event still needs handling - propagate it + + + @classmethod + def _cancel_empty_desc_allowed(cls) -> None: + # Change _empty_desc_allowed True->False + # Leave unchanged if is None + if cls._empty_desc_allowed: + cls._empty_desc_allowed = False + + + @classmethod + def _do_timed_toggle(cls, wid:Gtk.RadioButton) -> None: + # Callback. Called when "timed" radio button changes state. + # Reveals/hides appropriate sub-obtions, and flags that + # dialog state had been changed. + ti = cls.wid_timed_buttons[1].get_active() + cls._do_multireveal(cls.revs_timedur, ti) + cls._cancel_empty_desc_allowed() + + + @classmethod + def _do_allday_toggle(cls, wid:Gtk.RadioButton) -> None: + # Callback. Called when "all day" radio button changes state. + # Reveals/hides appropriate sub-obtions, and flags that + # dialog state had been changed. + ad = cls.wid_timed_buttons[2].get_active() + cls._do_multireveal(cls.revs_allday, ad) + cls._cancel_empty_desc_allowed() + + + @classmethod + def _desc_changed(cls, wid:Gtk.Entry) -> None: + # Callback. Called when entry description is changed by user. + # Flags that dialog state had been changed. + # Removes error state styling if it is no longer needed. + if cls.wid_desc.get_text(): # if desc field is non-empty + cls._cancel_empty_desc_allowed() + cls._is_valid_entry(set_style=False) # remove error styles if present + + + @classmethod + def _reptype_changed(cls, wid:Gtk.ComboBox) -> None: + # Callback. Called when entry repeat type is changed by user. + # Reveals/hides relevant sub-options. + # wid should be the repeat-type combobox + st = wid.get_active()>0 + cls._do_multireveal(cls.revs_repeat, st) + monthday = (wid.get_active_id()=='MONTHLY-MONTHDAY') + weekday = (wid.get_active_id()=='MONTHLY-WEEKDAY') # Booleans + cls._do_multireveal(cls.revs_rep_monthdays, monthday) + cls._do_multireveal(cls.revs_rep_weekdays, weekday) + cls._do_multireveal(cls.revs_rep_monthweekdays, monthday or weekday) + if monthday and not cls.repbymonthday_initialized: + # First time shown showing monthday-repeats, so initialise based on start date + cls.repbymonthday_initialized = True + sdt = cls.get_date_start() + if sdt is None: # Fallback in case date is invalid + cls.wid_repbymonthday.set_active(0) + else: + idx = sdt.day - monthrange(sdt.year,sdt.month)[1] - 1 + if -7 <= idx <= -2: + cls.wid_repbymonthday.set_active_id(str(idx)) + else: + cls.wid_repbymonthday.set_active(0) + elif weekday and not cls.repbyweekday_initialized: + # First time shown showing weekday-repeats, so initialise based on start date + cls.repbyweekday_initialized = True + sdt = cls.get_date_start() + if sdt is None: # Fallback in case date is invalid + cls.wid_repbyweekday_ord.set_active(0) + cls.wid_repbyweekday_day.set_active(0) + else: + wkst = Config.get_int('global','start_week_day') + cls.wid_repbyweekday_day.set_active((sdt.weekday()-wkst)%7) + if sdt.day<=21: + # Value will be, e.g. "1st", "2nd" (Tues of month) + cls.wid_repbyweekday_ord.set_active_id(str(1+(sdt.day-1)//7)) + else: + # Want, e.g. "last" (Friday of month) + rem = monthrange(sdt.year,sdt.month)[1]-sdt.day + cls.wid_repbyweekday_ord.set_active_id(str(-1-(rem//7))) + cls._cancel_empty_desc_allowed() + + + @classmethod + def _do_repeatforever_toggle(cls, wid:Gtk.Button) -> None: + # Callback. Called when repeat-forever state is changed by user. + # Reveals/hides relevant sub-options. + st = not wid.get_active() #If *not* forever, we show repeat-til options + cls._do_multireveal(cls.revs_rep_ends, st) + if st: + # Want to make sure revealed elements are synced + if cls.rep_occs_determines_end: + cls._sync_rep_occs_end() + else: + # if enddate determines occs, need to check enddate>=startdate + sdt = cls.get_date_start() + edt = cls.wid_rep_enddt.get_date_or_none() + if sdt is not None and (edt is None or edt < sdt): + cls.wid_rep_enddt.set_date(sdt) + cls.wid_rep_occs.set_value(1) + + cls._cancel_empty_desc_allowed() + + + @classmethod + def _block_tmdur_signals(cls) -> None: + # Block update signals from start time, duration & endtime + # fields, so they can be updated programmatically without + # getting "update signal feedback loops". + cls.wid_time.handler_block(cls._tmdur_handler_time) + cls.wid_dur.handler_block(cls._tmdur_handler_dur) + cls.wid_endtime.handler_block(cls._tmdur_handler_endtime) + + + @classmethod + def _unblock_tmdur_signals(cls) -> None: + # Unblock update signals from start time, duration & endtime + # fields, so if user changes one then other fields update. + cls.wid_time.handler_unblock(cls._tmdur_handler_time) + cls.wid_dur.handler_unblock(cls._tmdur_handler_dur) + cls.wid_endtime.handler_unblock(cls._tmdur_handler_endtime) + + + @classmethod + def _block_rep_occend_signals(cls) -> None: + # Function to disable "changed" signals from various widgets that + # lead to either the end-date or repeat count being re-calculated + # based on the new values. Generally, keep signals blocked except + # when the dialog is being shown to the user. + cls.wid_date.handler_block(cls._rep_handler_date) + cls.wid_time.handler_block(cls._rep_handler_time) + cls.wid_rep_type.handler_block(cls._rep_handler_type) + cls.wid_repbymonthday.handler_block(cls._rep_handler_mday) + cls.wid_repbyweekday_day.handler_block(cls._rep_handler_wdayday) + cls.wid_repbyweekday_ord.handler_block(cls._rep_handler_wdayord) + cls.wid_rep_interval.handler_block(cls._rep_handler_inter) + cls.wid_rep_occs.handler_block(cls._rep_handler_occs) + cls.wid_rep_enddt.handler_block(cls._rep_handler_enddt) + + + @classmethod + def _unblock_rep_occend_signals(cls) -> None: + # Function to re-enable "changed" signals from various widgets that + # lead to either the end-date or repeat count being re-calculated + # based on the new values. Generally, unblock when the dialog is + # being used and user can change these values. + cls.wid_date.handler_unblock(cls._rep_handler_date) + cls.wid_time.handler_unblock(cls._rep_handler_time) + cls.wid_rep_type.handler_unblock(cls._rep_handler_type) + cls.wid_repbymonthday.handler_unblock(cls._rep_handler_mday) + cls.wid_repbyweekday_day.handler_unblock(cls._rep_handler_wdayday) + cls.wid_repbyweekday_ord.handler_unblock(cls._rep_handler_wdayord) + cls.wid_rep_interval.handler_unblock(cls._rep_handler_inter) + cls.wid_rep_occs.handler_unblock(cls._rep_handler_occs) + cls.wid_rep_enddt.handler_unblock(cls._rep_handler_enddt) + + + @classmethod + def _tmdur_changed(cls, wid:Gtk.Widget, el:int) -> None: + # Callback. Called when starttime/endtime/duration changed. + # 'el' paramenter indicates which one changed (but could use wid). + sdt = cls.get_date_start() + stm = cls.wid_time.get_time_or_none() + d = cls.wid_dur.get_duration_or_none() + etm = cls.wid_endtime.get_time_or_none() + if sdt is not None and stm is not None and d is not None and etm is not None: + # First, update which of dur & end-time determines the other + if el==1: + cls.dur_determines_end = True + elif el==2: + cls.dur_determines_end = False + + # Second, do the required update + cls._block_tmdur_signals() # prevent accidental recursion + try: + st_dttm = dt_datetime.combine(sdt,stm) + if cls.dur_determines_end: + cls.wid_endtime.set_time(st_dttm+d) + else: # need to update dur + end_dttm = dt_datetime.combine(sdt,etm) + if end_dttm None: + # Handler for signals from any widget that might result in either the + # repeat end-date or occurence count changing. Prompts recalculation + # and possibly also updates which of end-date/occurences is the leader. + # Variable el indicates if either possible leader was updated. + if el == 1: # Occurrences edited, make it "leader" + cls.rep_occs_determines_end = True + cls._set_occs_min(1) + elif el == 2: # Repeat until edited, make it "leader" + cls.rep_occs_determines_end = False + + cls._block_rep_occend_signals() # prevent accidental recursion + try: + cls._sync_rep_occs_end() + finally: + cls._unblock_rep_occend_signals() + cls._cancel_empty_desc_allowed() + cls._is_valid_entry(set_style=False) # remove error styles if present + + + @classmethod + def _set_occs_min(cls, mn:int) -> None: + # quick method to set minimum of the occurrences spinbutton + mx = cls.wid_rep_occs.get_range().max + cls.wid_rep_occs.set_range(mn,mx) + + + # Maps used to calculate repeats using dateutil.rrule class + MAP_RTYPE_TO_RRULE = { + 'YEARLY': du_rrule.YEARLY, + 'MONTHLY': du_rrule.MONTHLY, + 'MONTHLY-MONTHDAY': du_rrule.MONTHLY, + 'MONTHLY-WEEKDAY': du_rrule.MONTHLY, + 'WEEKLY': du_rrule.WEEKLY, + 'DAILY': du_rrule.DAILY, + 'HOURLY': du_rrule.HOURLY, + 'MINUTELY': du_rrule.MINUTELY, + 'SECONDLY': du_rrule.SECONDLY + } + + MAP_RDAY_TO_RRULEDAY = { + 'MO': du_rrule.MO, + 'TU': du_rrule.TU, + 'WE': du_rrule.WE, + 'TH': du_rrule.TH, + 'FR': du_rrule.FR, + 'SA': du_rrule.SA, + 'SU': du_rrule.SU + } + + @classmethod + def _sync_rep_occs_end(cls) -> None: + # Function to recalculate repeat end-date or occurences count. + # Called when widgets that might affect repeat counts are updated. + if cls.wid_rep_forever.get_active(): + return + rtype = cls.wid_rep_type.get_active_id() + if rtype is None: + return + if cls.rep_occs_determines_end: + stdt = cls.get_date_start() + if stdt is not None: + span = (cls.get_repeat_occurrences()-1) * cls.get_repeat_interval() + if rtype=='YEARLY': + if stdt.day==29 and stdt.month==2: # 29th Feb - leap day! + cls._sync_rep_end_from_occ_rrule(rtype) + return + delta = relativedelta(years=span) + elif rtype=='MONTHLY': + if cls.get_datetime_start().day>=29: + cls._sync_rep_end_from_occ_rrule(rtype) + return + else: + delta = relativedelta(months=span) + elif rtype=='WEEKLY': + delta = timedelta(days=span*7) + elif rtype=='DAILY': + delta = timedelta(days=span) + elif rtype=='HOURLY': + delta = timedelta(hours=span) + elif rtype=='MINUTELY': + delta = timedelta(minutes=span) + elif rtype=='SECONDLY': + delta = timedelta(seconds=span) + elif rtype in ('MONTHLY-MONTHDAY','MONTHLY-WEEKDAY'): + cls._sync_rep_end_from_occ_rrule(rtype) + return + else: + # !! Don't know how to sync + print('Warning: Sync for {} not implemented'.format(rtype), file=stderr) + return + edt = stdt+delta + cls.wid_rep_enddt.set_date(edt) + else: # occurrences determined by end date - use dateutil:rrule to calc + try: + fr = cls.MAP_RTYPE_TO_RRULE[rtype] + except KeyError: + # !! Don't know how to sync + print('Warning: Sync for {} not implemented'.format(rtype), file=stderr) + return + bymtdy = None + bywkdy = None + if rtype=='MONTHLY-MONTHDAY': + bymtdy = cls._get_cal_monthday_rep() + elif rtype=='MONTHLY-WEEKDAY': + bywkdy = cls._get_cal_weekday_rep() + dtst = cls.get_date_start() # Without time, because time breaks calc + interv = cls.get_repeat_interval() + rend = cls.wid_rep_enddt.get_date_or_none() + if dtst is not None and rend is not None: + rr = du_rrule.rrule(fr, dtstart=dtst, interval=interv, until=rend, byweekday=bywkdy, bymonthday=bymtdy) + c = rr.count() + if c>=0: + cls._set_occs_min(0 if c==0 else 1) # possibly allow "0" + cls.wid_rep_occs.set_value(c) + + + @classmethod + def _sync_rep_end_from_occ_rrule(cls, rtype:str) -> None: + # Function to synchronise End Date repeat field from Occurrences in + # complex situations, e.g. leapday (29 Feb) or monthly late in month. + # Use rrule class for these. + try: + fr = cls.MAP_RTYPE_TO_RRULE[rtype] + except KeyError: + # !! Don't know how to sync + print('Warning: Sync for {} not implemented'.format(rtype), file=stderr) + return + dtst = cls.get_datetime_start() + if dtst is None: + return + interv = cls.get_repeat_interval() + occs = cls.get_repeat_occurrences() + bymtdy = None + bywkdy = None + if rtype=='MONTHLY-MONTHDAY': + bymtdy = cls._get_cal_monthday_rep() + elif rtype=='MONTHLY-WEEKDAY': + bywkdy = cls._get_cal_weekday_rep() + rr = du_rrule.rrule(fr, dtstart=dtst, interval=interv, count=occs, byweekday=bywkdy, bymonthday=bymtdy) + edt = list(rr)[-1] + cls.wid_rep_enddt.set_date(edt) + + + @classmethod + def _get_cal_monthday_rep(cls) -> int: + # Return value for monthday repeats to pass to rrule + retd = int(cls.wid_repbymonthday.get_active_id()) + return retd + + + @classmethod + def _get_cal_weekday_rep(cls) -> du_rrule.weekday: + # Return rrule structure for weekday repeats. + # E.g. MO(-2) = "2nd last Monday" + rrwd = cls.MAP_RDAY_TO_RRULEDAY[cls.wid_repbyweekday_day.get_active_id()](int(cls.wid_repbyweekday_ord.get_active_id())) + return rrwd + + + @classmethod + def newentry(cls, txt:str=None) -> None: + # Called to implement "new entry" from GUI, e.g. button + cls.dialog.set_title(_('New Entry')) + cls._empty_desc_allowed = True # initially allowed, can switch to False + response,ei = cls._do_entry_dialog(txt=txt) + if response==Gtk.ResponseType.OK and ei.desc: + Calendar.new_entry(ei) + if ei.rep_type is None: + # Jump to entry date (not repeating, so well-defined) + # !! This is a quick fix most common case. Need to fix: + # - Multiple entries on one day - jump to correct + # - Repeating entries (jump to visible/closest) + GUI.cursor_date = ei.get_start_date() + GUI.view_redraw(True) + + + @classmethod + def editentry(cls, en:iEvent, subtab:int=None) -> None: + # Called to implement "edit entry" from GUI + cls.dialog.set_title(_('Edit Entry')) + cls._empty_desc_allowed = None # empty desc always allowed (for delete) + response,ei = cls._do_entry_dialog(entry=en, subtab=subtab) + if response==Gtk.ResponseType.OK: + if ei.desc: + Calendar.update_entry(en, ei) + if ei.rep_type is None: + # Jump to entry date + GUI.cursor_date = ei.get_start_date() + GUI.view_redraw(True) + else: # Description text has been deleted in dialog + GUI.dialog_deleteentry(en) + + + @classmethod + def _do_entry_dialog(cls, entry:iEvent=None, txt:str=None, subtab:int=None) -> Tuple[int,EntryInfo]: + # Do the core work displaying entry dialog and extracting result. + # Called from both newentry() and editentry(). + cls._seed_fields(entry, txt) + + # Select visible subtab: time, repeats, alarms... + cls.wid_tabs.set_current_page(0) # default + if subtab is not None: # jump to tab requested + cls.wid_tabs.set_current_page(subtab) + cls.wid_tabs.grab_focus() + cls.wid_desc.select_region(0,0) # remove highlight + + cls._reset_err_style() # clear old error highlights + + # Unblock signals when dialog is active (user interaction -> updates) + cls._unblock_tmdur_signals() + cls._unblock_rep_occend_signals() + try: + while True: + response = cls.dialog.run() + if response!=Gtk.ResponseType.OK or cls._is_valid_entry(set_style=True): + break + cls._cancel_empty_desc_allowed() # after OK, desc required + finally: + # re-block signals + cls._block_tmdur_signals() + cls._block_rep_occend_signals() + cls.dialog.hide() + + return response,cls._get_entryinfo() + + + @classmethod + def _seed_fields(cls, entry:iEvent, txt:Optional[str]) -> None: + # Initialise entry fields when dialog is opened. + # Data optionally from an entry, or text used as summary. + # !! This function is a horrible mess. Needs rationalising !! + + # Set defaults + dur = None + end_dttm = None + cls.dur_determines_end = True + cls.wid_allday_count.set_value(1) + cls.wid_rep_type.set_active(0) + cls.repbymonthday_initialized = False # Init these later when we can + cls.repbyweekday_initialized = False # choose appropriate values. + cls.wid_rep_interval.set_value(1) + cls.wid_rep_forever.set_active(True) + cls.rep_occs_determines_end = True + cls._set_occs_min(1) + cls.wid_rep_occs.set_value(1) + # No need to set wid_rep_enddt because it will be synced when revealed + cls.wid_status.set_active(0) + cls.wid_location.set_text('') + cls.wid_alarmset.set_active(False) + + # Two cases: new entry or edit existing entry + if entry is None: + # Initialise decscription field + # We don't want this to count as user interaction, so block signal + cls.wid_desc.handler_block(cls._wid_desc_changed_handler) + if txt is None: + cls.wid_desc.set_text('') # clear text + else: + cls.wid_desc.set_text(txt) + cls.wid_desc.set_position(len(txt)) + cls.wid_desc.handler_unblock(cls._wid_desc_changed_handler)#unblock + cls.wid_desc.grab_focus_without_selecting() + dt = GUI.cursor_date + tm = None + else: # existing entry - take values + cls.wid_desc.set_text(entry['SUMMARY'] if 'SUMMARY' in entry else '') + cls.wid_desc.grab_focus() + dt = entry['DTSTART'].dt + dttm = None + if isinstance(dt,dt_datetime): + dttm = dt + dt = dttm.date() + tm = dttm.time() + if 'DTEND' in entry: + end_dttm = entry['DTEND'].dt + if isinstance(end_dttm, dt_time): + end_dttm = dt_datetime.combine(dt,end_dttm) + if end_dttm=1 else 1) + cls.rep_occs_determines_end = True + elif 'UNTIL' in rrule: + cls.wid_rep_forever.set_active(False) + u = rrule['UNTIL'][0] + if isinstance(u,dt_datetime): + u = u.date() + cls.wid_rep_enddt.set_date(u if u>dt else dt) + cls.rep_occs_determines_end = False + if 'STATUS' in entry and entry['STATUS'] in ('TENTATIVE','CONFIRMED','CANCELLED'): + cls.wid_status.set_active_id(entry['STATUS']) + if 'LOCATION' in entry: + cls.wid_location.set_text(entry['LOCATION']) + if entry.walk('VALARM'): + cls.wid_alarmset.set_active(True) + + cls.wid_date.set_date(dt) + cls._sync_rep_occs_end() + + if tm is None: + # Setting radio buttons for Untimed/Timed/Allday. + # This also reveals appropriate UI elements via signal connections. + if end_dttm is None: + cls.wid_timed_buttons[0].set_active(True) + else: + cls.wid_timed_buttons[2].set_active(True) + d = end_dttm - dt + cls.wid_allday_count.set_value(d.days) + tm = dt_time(hour=9) + else: + cls.wid_timed_buttons[1].set_active(True) + cls.wid_time.set_time(tm) + + if dur is not None: + end_dttm = dttm + dur + cls.dur_determines_end = True + elif end_dttm is not None and dttm is not None: + dur = end_dttm - dttm + cls.dur_determines_end = False + + if dur is None: + cls.wid_dur.set_duration(timedelta(0)) + cls.wid_endtime.set_time(tm) + else: + cls.wid_dur.set_duration(dur) + cls.wid_endtime.set_time(end_dttm.time()) + + cls._seed_rep_exception_list(entry) + + + @classmethod + def _seed_rep_exception_list(cls, entry:iEvent) -> None: + # Sets & displays repeat exception list from entry parameter. + # Called from _seed_fields() when dialog is opened. + cls._set_rep_exception_list(entry) + cls._set_label_rep_list() + + + @classmethod + def _set_rep_exception_list(cls, entry:iEvent) -> None: + # Set cls.exception_list to list of exception dates for entry + cls.exception_list = [] + if entry is None or 'RRULE' not in entry or 'EXDATE' not in entry: + return + exdate = entry['EXDATE'] + if isinstance(exdate,list): + # Create set => elements unique + dtlist = set([datetime_to_date(t.dts[i].dt) for t in exdate for i in range(len(t.dts))]) + else: + dtlist = set([datetime_to_date(t.dt) for t in exdate.dts]) + cls.exception_list = sorted(dtlist) + + + @classmethod + def _set_label_rep_list(cls) -> None: + # Construct string and set text for label widget displaying + # exception dates. Takes dates from cls.exception_list. + list_txt = '' + first = True + for dt in cls.exception_list: + if not first: + list_txt += ', ' + list_txt += dt.strftime(GUI.date_formatting_numeric) + first = False + if not list_txt: + list_txt = _('None') + cls._lab_rep_exceptions.set_text(list_txt) + + + @classmethod + def _get_entryinfo(cls) -> EntryInfo: + # Decipher entry fields and return info as an EntryInfo object. + desc = cls.wid_desc.get_text() + dt = cls.get_datetime_start() + stat = cls.wid_status.get_active_id() + loc = cls.wid_location.get_text() + ei = EntryInfo(desc=desc, start_dt=dt, status=stat, location=loc) + if cls.wid_timed_buttons[2].get_active(): + # "Day entry" selected, read number of days from widget + d = max(1,int(cls.wid_allday_count.get_value())) + ei.set_end_dt(dt+timedelta(days=d)) + elif cls.dur_determines_end: + ei.set_duration(cls.wid_dur.get_duration_or_none()) + else: + ei.set_end_dt(cls.wid_endtime.get_time_or_none()) + + # repeat information + reptype = cls.wid_rep_type.get_active_id() + if reptype is not None: + inter = cls.get_repeat_interval() + count = None + until = None + byday = None + bymonthday = None + if reptype=='MONTHLY-WEEKDAY': + reptype = 'MONTHLY' + byday = cls.wid_repbyweekday_ord.get_active_id() + cls.wid_repbyweekday_day.get_active_id() + elif reptype=='MONTHLY-MONTHDAY': + reptype = 'MONTHLY' + bymonthday = cls.wid_repbymonthday.get_active_id() + if not cls.wid_rep_forever.get_active(): + if cls.rep_occs_determines_end: + count = cls.get_repeat_occurrences() + else: + until = cls.wid_rep_enddt.get_date_or_none() + ei.set_repeat_info(reptype, interval=inter, count=count, until=until, bymonthday=bymonthday, byday=byday, except_list=cls.exception_list) + return ei + + + @classmethod + def _is_valid_entry(cls, set_style:bool=False) -> bool: + # Function checks if entry dialog fields are valid. + # Used when "OK" clicked in the dialog (so important!). + # Returns True if all fields valid; False if *any* field is invalid. + # If an entry is invalid and set_style==True it also adds a + # CSS class to the widget, so the error is visibly indicated. + # If the entry is valid then it removes the error style, so + # the error indication will disappear (regardless of set_style). + + ret = True + # Check description is good (optional) + ctx = cls.wid_desc.get_style_context() + if cls._empty_desc_allowed==False and not cls.wid_desc.get_text(): # is empty string + ret = False + if set_style: + ctx.add_class(GUI.STYLE_ERR) + else: + ctx.remove_class(GUI.STYLE_ERR) + + # Check date is good (e.g. not February 30th) + ctx = cls.wid_date.get_style_context() + st_dt = cls.get_date_start() + if st_dt is None: + ret = False + if set_style: + ctx.add_class(GUI.STYLE_ERR) + else: # always try to remove style, so corrections visible + ctx.remove_class(GUI.STYLE_ERR) + + # Track tabs were errors found so they can be selected if needed + err_tabs = [False]*cls.TAB_COUNT + + # Check start time is good (e.g. not 10:70) + ctx = cls.wid_time.get_style_context() + if cls.wid_time.get_time_or_none() is None: + ret = False + if set_style: + ctx.add_class(GUI.STYLE_ERR) + err_tabs[cls.TAB_TIME] = True + else: + ctx.remove_class(GUI.STYLE_ERR) + + # Check duration is good + # Don't want to do an if/else here because need to reset conterpart + ctx = cls.wid_dur.get_style_context() + if cls.dur_determines_end and cls.wid_dur.get_duration_or_none() is None: + ret = False + if set_style: + ctx.add_class(GUI.STYLE_ERR) + err_tabs[cls.TAB_TIME] = True + else: + ctx.remove_class(GUI.STYLE_ERR) + + # Check end time is good + ctx = cls.wid_endtime.get_style_context() + if not cls.dur_determines_end and cls.wid_endtime.get_time_or_none() is None: + ret = False + if set_style: + ctx.add_class(GUI.STYLE_ERR) + err_tabs[cls.TAB_TIME] = True + else: + ctx.remove_class(GUI.STYLE_ERR) + + # Shared Boolean used in checking repeat enddate & occs + reps_active = cls.wid_rep_type.get_active()>0 and not cls.wid_rep_forever.get_active() + + # Check end repeat date is good + ctx = cls.wid_rep_enddt.get_style_context() + end_dt = cls.wid_rep_enddt.get_date_or_none() + if reps_active and (end_dt is None or (st_dt is not None and end_dt None: + # Remove 'dialog_error' style class from entry dialog widgets where + # it might be set, so any visible indications of errors are removed. + # Used, for example, when the dialog is reinitialised. + for w in (cls.wid_desc, cls.wid_date, cls.wid_time, cls.wid_dur, cls.wid_endtime, cls.wid_rep_enddt, cls.wid_rep_occs): + w.get_style_context().remove_class(GUI.STYLE_ERR) + + + @classmethod + def get_date_start(cls) -> dt_date: + # Convenience function to get start date from dialog (None if invalid) + return cls.wid_date.get_date_or_none() + + + @classmethod + def get_datetime_start(cls) -> dt_date: + # Returns start date & time from dialog if time set; o/w just the date + dt = cls.wid_date.get_date_or_none() + if dt is not None and cls.wid_timed_buttons[1].get_active(): + # timed entry + tm = cls.wid_time.get_time_or_none() + if tm is not None: + dt = dt_datetime.combine(dt,tm) + return dt + + + @classmethod + def get_repeat_interval(cls) -> int: + # Use get_text() rather than get_value() since there can be + # delays in value being updated. This can lead to outdated + # values being reported. This happens almost all the time + # on the Gemini device. + try: + r = int(cls.wid_rep_interval.get_text()) + if r<1: + r = 1 + except ValueError: + r = 1 + return r + + + @classmethod + def get_repeat_occurrences(cls) -> int: + # Use get_text() rather than get_value() - see above. + try: + r = int(cls.wid_rep_occs.get_text()) + if r<1: + r = 1 + except ValueError: + r = 1 + return r + + + @classmethod + def dialog_repeat_exceptions(cls, *args) -> None: + # Callback for +/- button to open sub-dialog for "repeat exceptions" + edc = ExceptionsDialogController(cls.exception_list, parent=cls.dialog) + result = edc.run() + if result==Gtk.ResponseType.OK: + dates = sorted(edc.get_dates()) + cls.exception_list = dates + cls._set_label_rep_list() + edc.destroy() + + +class DateLabel(Gtk.Label): + # Label Widget displaying a date. + # Used for rows in listbox of Exception dates dialog. + + def __init__(self, date:dt_date): + # !! date argument assumed a dt_date here; may be dt_datetime in future + self.date = date + st = self.date.strftime(GUI.date_formatting_textabb) + super().__init__(st) + + + def compare(self, dl2:Gtk.Label) -> int: + # Compare two DateLabels. + # Return <0 if self0 otherwise. + # Used to sort ListBox. + # !! If using datetime, then this won't distinguish within day + o1 = self.date.toordinal() + o2 = dl2.date.toordinal() + return o1-o2 + + +class ExceptionsDialogController: + # Dialog box to add/remove a repeating entry's "Exception dates". + # Called when +/- button is clicked in Entry Repeats tab. + + def __init__(self, ex_date_list:list, parent:Gtk.Window=None): + self.dialog = Gtk.Dialog(title=_('Exception dates'), parent=parent, + flags=Gtk.DialogFlags.MODAL|Gtk.DialogFlags.DESTROY_WITH_PARENT, + buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CLOSE, Gtk.STOCK_OK, Gtk.ResponseType.OK)) + self.dialog.set_resizable(False) + content = Gtk.Box() + add_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + list_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + content.add(add_content) + content.add(list_content) + self.dialog.get_content_area().add(content) + + # Date widget + self.wdate = WidgetDate(GUI.cursor_date) + self.wdate.connect('changed', GUI.check_date_fixed) + add_content.add(self.wdate) + + # Add date button + button_add = Gtk.Button(_('Add date')) + button_add.get_style_context().add_class('dialogbutton') + button_add.connect('clicked', self._add_date, self) + add_content.add(button_add) + + # Date listbox (scrollable) + list_scroller = Gtk.ScrolledWindow() + list_scroller.set_size_request(-1,70) # temporary magic number !! + list_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.ALWAYS) + list_scroller.set_overlay_scrolling(False) + self.list_listbox = Gtk.ListBox() + self.list_listbox.set_selection_mode(Gtk.SelectionMode.MULTIPLE) + self.list_listbox.set_activate_on_single_click(False) + self.list_listbox.set_sort_func(self._sort_func) + for dt in set(ex_date_list): # set() to uniquify + lab = DateLabel(dt) + self.list_listbox.add(lab) + list_scroller.add(self.list_listbox) + list_content.add(list_scroller) + + # Remove date(s) button + button_remove = Gtk.Button(_('Remove date(s)')) + button_remove.get_style_context().add_class('dialogbutton') + button_remove.connect('clicked', self._remove_dates, self) + list_content.add(button_remove) + + # !! Add WIP notice, so status is clear to user !! + wip_label = Gtk.Label('Note: this dialog needs refining!') + self.dialog.get_content_area().add(wip_label) + + self.dialog.show_all() + + + def run(self) -> Gtk.ResponseType: + # run the dialog + return self.dialog.run() + + + def destroy(self) -> None: + # destroy the dialog + self.dialog.destroy() + + + def get_dates(self) -> set: + # Return list of dates. + # Returned as a set, so guaranteed no repeats, but not sorted. + dl = {row.get_child().date for row in self.list_listbox.get_children()} + return dl + + + @staticmethod + def _add_date(button:Gtk.Button, ctrl:'ExceptionsDialogController') -> None: + # Callback when "Add date" button is clicked + dt = ctrl.wdate.get_date_or_none() + if dt is None: + # Date is invalid, add error styling + ctrl.wdate.get_style_context().add_class(GUI.STYLE_ERR) + else: + ctrl.list_listbox.unselect_all() # so new row can be highlighted + for row in ctrl.list_listbox.get_children(): + if dt == row.get_child().date: + # date already in list - highlight & stop + ctrl.list_listbox.select_row(row) + return + lab = DateLabel(dt) + lab.show_all() + ctrl.list_listbox.add(lab) + # Now select the new row - helps to see where new entry is, + # and also means it can be quickly deleted if added in error. + ctrl.list_listbox.select_row(lab.get_parent()) + + + @staticmethod + def _remove_dates(button:Gtk.Button, ctrl:'ExceptionsDialogController') -> None: + # Callback when "Remove dates" button is clicked + selected = ctrl.list_listbox.get_selected_rows() + for row in selected: + ctrl.list_listbox.remove(row) + + + @staticmethod + def _sort_func(row1:Gtk.ListBoxRow, row2:Gtk.ListBoxRow) -> int: + # Used as sort function for listbox. + return row1.get_child().compare(row2.get_child()) diff --git a/pygenda/pygenda_util.py b/pygenda/pygenda_util.py new file mode 100644 index 0000000..96227da --- /dev/null +++ b/pygenda/pygenda_util.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +# +# pygenda_util.py +# Miscellaneous utility functions for Pygenda. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +from calendar import day_abbr,month_abbr +from icalendar import cal as iCal +from datetime import date, time, datetime, timedelta +import locale +from typing import Tuple + +from .pygenda_config import Config + + +def datetime_to_date(dt:date) -> date: + # Extract date from datetime object (which might be a date) + try: + return dt.date() + except AttributeError: + return dt + + +def datetime_to_time(dt:date): + # Extract time from datetime object. + # Return False if no time component. + try: + return dt.time() + except AttributeError: + return False + + +def start_end_dts_event(event:iCal.Event) -> Tuple[date,date]: + # Return start & end time of an event. + # End time calculated from duration if needed; is None if no end/duration. + start = event['DTSTART'].dt + if 'DTEND' in event: + end = event['DTEND'].dt + elif 'DURATION' in event: + end = start + event['DURATION'].dt + else: + end = None + return start,end + + +def start_end_dts_occ(occ:Tuple[iCal.Event,date]) -> Tuple[date,date]: + # Return start & end time of an occurrence (an (event,date[time]) pair) + start = occ[1] + if 'DTEND' in occ[0]: + d = start - occ[0]['DTSTART'].dt + end = occ[0]['DTEND'].dt + d + elif 'DURATION' in occ[0]: + end = start + occ[0]['DURATION'].dt + else: + end = None + return start,end + + +def format_time(dt) -> str: + # Return time as string, formatted according to app config settings. + # Argument 'dt' can be a time or a datetime. + if Config.get_bool('global','24hr'): + fmt = '{hr:02d}{hs:s}{min:02d}' + return fmt.format(hr=dt.hour, min=dt.minute, hs=Config.get('global','time_sep')) + else: + fmt = '{hr:d}{hs:s}{min:02d}{ampm:s}' + return fmt.format(hr=(dt.hour-1)%12+1, min=dt.minute, ampm = 'pm' if dt.hour>11 else 'am', hs=Config.get('global','time_sep')) + + +def format_compact_time(dt) -> str: + # Return time as string, according to app config settings. + # Compact if 12hr set & minutes=0: e.g. '1pm' rather than '1:00pm' + # Argument 'dt' can be a time or a datetime. + if Config.get_bool('global','24hr'): + fmt = '{hr:02d}{hs:s}{min:02d}' + return fmt.format(hr=dt.hour, min=dt.minute, hs=Config.get('global','time_sep')) + else: + if dt.minute: + fmt = '{hr:d}{hs:s}{min:02d}{ampm:s}' + else: + fmt = '{hr:d}{ampm:s}' + return fmt.format(hr=(dt.hour-1)%12+1, min=dt.minute, ampm = 'pm' if dt.hour>11 else 'am', hs=Config.get('global','time_sep')) + + +def format_compact_date(dt:date, show_year:bool) -> str: + # Return date as string, with abbreviated day/month names. + if show_year: + fmt = '{day:s} {date:d} {mon:s}, {yr:d}' + else: + fmt = '{day:s} {date:d} {mon:s}' + return fmt.format(day=day_abbr[dt.weekday()], date=dt.day, mon=month_abbr[dt.month], yr=dt.year) + + +def format_compact_datetime(dt:datetime, show_year:bool) -> str: + # Return datetime as string, with abbreviated day/month names, compact time. + if show_year: + fmt = '{day:s} {date:d} {mon:s}, {yr:d}, {tm:s}' + else: + fmt = '{day:s} {date:d} {mon:s}, {tm:s}' + return fmt.format(day=day_abbr[dt.weekday()], date=dt.day, mon=month_abbr[dt.month], yr=dt.year, tm=format_compact_time(dt)) + + +def day_in_week(dt:date) -> int: + # Return the day number of dt in the week + # Depends on config setting global/start_week_day + day = dt.weekday() + start_day = Config.get_int('global', 'start_week_day') + return (day-start_day)%7 + + +def start_of_week(dt:date) -> int: + # Return first day of week containing dt + # Depends on config setting global/start_week_day + return dt - timedelta(days=day_in_week(dt)) + + +def dt_lte(dt_a:date, dt_b:date) -> bool: + # Return True if dt_a <= dt_b + # dt_a and dt_b can be dates/datetimes independently + return _dt_lte_common(dt_a, dt_b, True) + + +def dt_lt(dt_a:date, dt_b:date) -> bool: + # Return True if dt_a < dt_b + # dt_a and dt_b can be dates/datetimes independently + return _dt_lte_common(dt_a, dt_b, False) + + +def _dt_lte_common(dt_a:date, dt_b:date, equality:bool) -> bool: + # If one has timezone info, both need it for comparison + try: + if dt_a.tzinfo is None: + if dt_b.tzinfo is not None: + dt_a = dt_a.replace(tzinfo=dt_b.tzinfo) + else: + if dt_b.tzinfo is None: + dt_b = dt_b.replace(tzinfo=dt_a.tzinfo) + except AttributeError: + pass + + # Now compare + try: + # These will succeed if both are dates or both are datetime + if equality: + return dt_a <= dt_b + else: + return dt_a < dt_b + except TypeError: + try: + eq = (dt_a == dt_b.date()) # try a is date + # If eq then a is *start* of day when b occurs + return eq or (dt_a < dt_b.date()) + except AttributeError: + eq = (dt_a.date() == dt_b) # so b is date + # If eq then b is *start* of day when a occurs + return False if eq else (dt_a.date() < dt_b) + return False # shouldn't reach here + + +# We want to be able to sort events/todos by datetime +def _entry_lt(self, other) -> bool: + # Return True if start date/time of self < start date/time of other + return dt_lt(self['DTSTART'].dt,other['DTSTART'].dt) + +# Attach method to classes so it can be used to sort +iCal.Event.__lt__ = _entry_lt +iCal.Todo.__lt__ = _entry_lt + + +def guess_date_ord_from_locale() -> str: + # Try to divine order of date (day/mon/yr mon/day/yr etc.) from locale. + # Return a string like 'DMY' 'MDY' etc. + dfmt = locale.nl_langinfo(locale.D_FMT) # E.g. '%x', '%m/%d/%Y' + # To cope with dfmt=='%x': format a time & look at result + st = date(year=3333,month=11,day=22).strftime(dfmt) + ret = '' + for ch in st: + if ch=='3': + if not ret or ret[-1]!='Y': + ret += 'Y' + elif ch=='1': + if not ret or ret[-1]!='M': + ret += 'M' + elif ch=='2': + if not ret or ret[-1]!='D': + ret += 'D' + return ret if len(ret)==3 else 'YMD' + + +def guess_date_sep_from_locale() -> str: + # Try to divine date separator (/, -, etc.) from locale. + dfmt = locale.nl_langinfo(locale.D_FMT) + # To cope with dfmt=='%x': format a time & look at result + st = date(year=1111,month=11,day=11).strftime(dfmt) + for ch in st: + if ch!='1': + return ch + return '-' # fallback + + +def guess_time_sep_from_locale() -> str: + # Try to divine time separator (:, etc.) from locale. + tfmt = locale.nl_langinfo(locale.T_FMT) # E.g. '%r', '%H:%M:%S' + # To cope with tfmt=='%x': format a time & look at result + st = time(hour=11,minute=11,second=11).strftime(tfmt) + for ch in st: + if ch!='1': + return ch + return ':' # fallback + + +def guess_date_fmt_text_from_locale() -> str: + # Try to divine date formatting string from locale + dtfmt = locale.nl_langinfo(locale.D_T_FMT) # includes time element + ret = '' + i = 0 + incl = True # including chars + fmtch = False # formatting character + while i < len(dtfmt): + ch = dtfmt[i] + if ch=='%' and not fmtch and dtfmt[i+1] in 'HIMRSTXZfklnprstz': + incl = False # skip these until space char + if incl: + if fmtch and ch in 'aby': + ch = ch.upper() # uppercase version for full day/month/year + ret += ch + fmtch = (ch=='%') # Boolean - next char is formatting char + if ch==' ': + incl = True + i += 1 + return ret if ret else '%A, %B %-d, %Y' # fallback diff --git a/pygenda/pygenda_version.py b/pygenda/pygenda_version.py new file mode 100644 index 0000000..0f07104 --- /dev/null +++ b/pygenda/pygenda_version.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# +# pygenda_version.py +# Version data in a separate file so it can be found easily by ../setup.py + +# For valid/suggested versioning schemes, see: +# https://packaging.python.org/guides/distributing-packages-using-setuptools/#choosing-a-versioning-scheme + +__version__ = '0.2.0' diff --git a/pygenda/pygenda_view.py b/pygenda/pygenda_view.py new file mode 100644 index 0000000..35c053e --- /dev/null +++ b/pygenda/pygenda_view.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# +# pygenda_view.py +# "View" class definition - base class for Week View, Year View. +# Provides default implementations of functions. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +from gi import require_version as gi_require_version +gi_require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib +from gi.repository.Pango import WrapMode as PWrapMode + +from icalendar import cal as iCal +from datetime import date as dt_date, timedelta + +from .pygenda_gui import GUI, EntryDialogController +from .pygenda_util import datetime_to_time, datetime_to_date, format_time, format_compact_date, format_compact_time, format_compact_datetime + + +# Base class for pygenda Views (Week View, Year View, etc.) +class View: + # Provide default implementations of required methods. + + @staticmethod + def view_name() -> str: + # Return name string to use in menu, should be localised. + # This default should never be called, so provide dummy name. + return 'Null View' + + + @staticmethod + def accel_key() -> int: + # Return keycode for menu shortcut, should be localised. + return 0 + + + @classmethod + def init(cls) -> Gtk.Widget: + # Initialise view and return Gtk widget for that view. + # Called from GUI._init_views() on startup. + return None + + + @classmethod + def renew_display(cls) -> None: + # Called when we switch to this view. + # Used to reset local state; default null implementation may be enough. + pass + + + @classmethod + def redraw(cls, ev_changes:bool) -> None: + # Called when redraw required + # ev_changes: bool indicating if event display needs updating too + pass + + + @classmethod + def keypress(cls, wid:Gtk.Widget, ev:Gdk.Event) -> None: + # Called (from GUI.keypress()) on keypress (or repeat) event + # Default does nothing. Derived views will override. + pass + + + @classmethod + def cursor_edit_entry(cls) -> None: + # Opens an entry edit dialog for the entry at the cursor, + # or to create a new entry if the cursor is not on entry. + # Assigned to the 'Enter' key in Week and Year views. + en = cls.get_cursor_entry() + if en is None: + EntryDialogController.newentry() + else: + EntryDialogController.editentry(en) + + + @staticmethod + def marker_label(ev:iCal.Event, dt_st:dt_date) -> Gtk.Label: + # Returns bullet or entry time suitable for marking entries. + # Used to display entries in Week and Year views. + BULLET = u'•' + BULLET_ALLDAY = u'‣' + BULLET_TODO = u'Ⓣ' + + lab = Gtk.Label() + lab.set_halign(Gtk.Align.END) + lab.set_valign(Gtk.Align.START) + if datetime_to_time(dt_st)!=False: + mark = format_time(dt_st) + elif type(ev) is iCal.Todo: + mark = BULLET_TODO + elif 'DTEND' in ev: + mark = BULLET_ALLDAY + else: + mark = BULLET + lab.set_text(mark) + + return lab + + + @staticmethod + def entry_text_label(ev:iCal.Event, dt_st:dt_date, dt_end:dt_date) -> Gtk.Label: + # Returns a GtkLabel with entry summary + icons as content. + # Used by Week & Year views to display entries. + lab = Gtk.Label() + lab.set_line_wrap(True) + lab.set_line_wrap_mode(PWrapMode.WORD_CHAR) + lab.set_xalign(0) + lab.set_yalign(0) + endtm = View.entry_endtime(dt_st,dt_end,True) + icons = View.entry_icons(ev,True) + d_txt = ev['SUMMARY'] if 'SUMMARY' in ev else '' + lab.set_text(u'{:s}{:s}{:s}'.format(d_txt,endtm,icons)) + View.add_event_styles(lab, ev) + return lab + + + @staticmethod + def entry_icons(ev:iCal.Event, prefix_space:bool) -> str: + # Returns string of icons for entry (repeat, alarm...) + alarm = u'♫' # alternatives: alarm clock ⏰ (U+23F0), bell 🔔 (U+1F514) + repeat = u'⟳' + icons = '' + if ev.walk('VALARM'): + icons += alarm + if 'RRULE' in ev: + icons += repeat + if prefix_space and icons: + icons = ' {:s}'.format(icons) + return icons + + + @staticmethod + def entry_endtime(dt_st:dt_date, dt_end:dt_date, frame_text:bool) -> str: + # Returns endttime in format suitable for displaying in entry. + if not dt_end or dt_st==dt_end: + return '' + if type(dt_st) is dt_date and type(dt_end) is dt_date: + end_date = dt_end-timedelta(1) + if dt_st>=end_date: + return '' + t_str = format_compact_date(end_date, dt_st.year!=end_date.year) + else: + st_date = datetime_to_date(dt_st) + end_date = datetime_to_date(dt_end) + if st_date == end_date: + t_str = format_compact_time(dt_end) + elif datetime_to_time(end_date)!=False: + t_str = format_compact_datetime(dt_end, st_date.year!=end_date.year) + else: + t_str = format_compact_date(dt_end, st_date.year!=end_date.year) + if frame_text and t_str: + t_str = u' (→{:s})'.format(t_str) + return t_str + + + @staticmethod + def add_event_styles(wid:Gtk.Label, ev:iCal.Event) -> None: + # Adds formatting class to label corresponding to event status. + # Allows entry to be formatted appropriately by CSS. + if 'STATUS' in ev and ev['STATUS'] in ('TENTATIVE','CONFIRMED','CANCELLED'): + ctx = wid.get_style_context() + ctx.add_class(ev['STATUS'].lower()) + + + @staticmethod + def scroll_to_row(rowbox:Gtk.Box, row:int, scroller:Gtk.ScrolledWindow) -> bool: + # Given a box displaying entries in a scroller (such as those + # in Week & Year views), scroll to reveal row number 'row'. + # Note: this may need to be called after rows are rendered, + # so that the allocated_heights are correct + top = rowbox.get_spacing()*row # to hold max value to show top of cell + rows = rowbox.get_children() + for i in range(row): + top += rows[i].get_allocated_height() + bot = top + rows[row].get_allocated_height() + bot -= scroller.get_allocated_height() + # Account for padding, margin, border + # First get the widths from the style context + ctx = rowbox.get_style_context() + pad = ctx.get_padding(Gtk.StateFlags.NORMAL) + bord = ctx.get_border(Gtk.StateFlags.NORMAL) + marg = ctx.get_margin(Gtk.StateFlags.NORMAL) + # Looks a bit odd to be exactly on edge, so go halfway into padding + top += bord.top + marg.top + (pad.top+1)//2 + bot += pad.top + bord.top + marg.top + (pad.bottom+1)//2 + if bot>top: # need this if row height bigger than scroll area + bot = top # to favour showing top, rather than bottom, of cell + adj = scroller.get_vadjustment() + v = adj.get_value() + if top < v: + adj.set_value(top) + elif v < bot: + adj.set_value(bot) + # According to: + # https://developer.gnome.org/glib/stable/glib-The-Main-Event-Loop.html#g-idle-add + # returning False is the right thing to do for one-shot calls. + # Not sure if this is required, but let's do it anyway. + return False + + + @staticmethod + def y_to_day_row(rowbox:Gtk.Box, y:float, maxrow:int, scroller:Gtk.ScrolledWindow=None) -> int: + # Helper function to convert y-coord to row index in day. + # Used to calculate entry to jump to when entry list is clicked, + # for example in Week or Year View. + if maxrow <= 1: + return 0 + rs = rowbox.get_spacing() + # Take account of padding/border/margins/row-spacing/scroller... + ctx = rowbox.get_style_context() + yc = ctx.get_padding(Gtk.StateFlags.NORMAL).top + yc += ctx.get_border(Gtk.StateFlags.NORMAL).top + yc += ctx.get_margin(Gtk.StateFlags.NORMAL).top + yc -= rs/2 + if scroller is not None: + yc -= scroller.get_vadjustment().get_value() + + row = -1 # return value + rows = rowbox.get_children() + # accumulate height of rows until it's greater than target y + for i in range(maxrow): + row += 1 + yc += rows[i].get_allocated_height() + yc += rs + if yc > y: + break + return row + + + @staticmethod + def remove_all_classes(ctx:Gtk.StyleContext) -> None: + # Helper function to remove all classes from a view context. + # Used when redrawing Year view, might be used for other views. + for c in ctx.list_classes(): + ctx.remove_class(c) diff --git a/pygenda/pygenda_view_week.py b/pygenda/pygenda_view_week.py new file mode 100644 index 0000000..f3ab173 --- /dev/null +++ b/pygenda/pygenda_view_week.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +# +# pygenda_view_week.py +# Provides the "Week View" for Pygenda. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +from gi import require_version as gi_require_version +gi_require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib + +from calendar import day_abbr,month_name +from datetime import time as dt_time, date as dt_date, timedelta +from icalendar import cal as iCal +from locale import gettext as _ +from typing import Optional + +# pygenda components +from .pygenda_view import View +from .pygenda_calendar import Calendar +from .pygenda_config import Config +from .pygenda_util import start_of_week, day_in_week, month_abbr, start_end_dts_occ, dt_lte +from .pygenda_gui import GUI, EntryDialogController + + +# Singleton class for Week View +class View_Week(View): + Config.set_defaults('week_view',{ + 'pageleft_datepos': 'left', + 'pageright_datepos': 'right', + }) + + _day_ent_count = [0]*7 # entry count for each day + _visible_occurrences = [] + _week_viewed = None # So view will be fully redrawn when needed + _last_cursor = None + _scroll_callback_id = None + CURSOR_STYLE = 'weekview_cursor' + + @staticmethod + def view_name() -> str: + # Return (localised) string to use in menu + return _('_Week View') + + @staticmethod + def accel_key() -> int: + # Return (localised) keycode for menu shortcut + k = _('week_view_accel') + return ord(k[0]) if len(k)>0 else 0 + + + @classmethod + def init(cls) -> Gtk.Widget: + # Called on startup. + # Gets view framework from glade file & tweaks/adds a few elements. + # Returns widget containing view. + cls._topbox = GUI._builder.get_object('view_week') + cls._month_label = GUI._builder.get_object('week_label_month') + cls._weekno_label = GUI._builder.get_object('week_label_weekno') + cls._init_week_widgets() + cls._init_keymap() + return cls._topbox + + + @classmethod + def _init_week_widgets(cls) -> None: + # Initialise widgets - create day labels, entry spaces etc. + # Do this here so it take account of start_week_day setting, + # page*_datepos settings, and set CSS styles for each day. + + # Create widgets for day labels & text spaces + cls._day_eventbox = [] + cls._day_label = [] + cls._day_rows = [] + cls._day_scroll = [] + st_wk = Config.get_int('global','start_week_day') + dpos_r = Config.get('week_view','pageleft_datepos')=='right' + day_ab = ('mon','tue','wed','thu','fri','sat','sun')#don't trans + for i in range(7): + # create event box & contained box + cls._day_eventbox.append(Gtk.EventBox()) + day_box = Gtk.Box() + ctx = day_box.get_style_context() + # Add classes for css flexibility + ctx.add_class('weekview_day') + ctx.add_class('weekview_day{:d}'.format(i)) + ctx.add_class('weekview_{:s}'.format(day_ab[(i+st_wk)%7])) + cls._day_eventbox[i].add(day_box) + cls._day_eventbox[i].connect("button_press_event", cls.click_date) + # labels + cls._day_label.append(Gtk.Label()) + cls._day_label[i].set_justify(Gtk.Justification.CENTER) + cls._day_label[i].set_xalign(0.5) + cls._day_label[i].set_yalign(0.5) + ctx = cls._day_label[i].get_style_context() + ctx.add_class('weekview_labelday') + if i==3: # starting rightpage + dpos_r = Config.get('week_view','pageright_datepos')=='right' + if dpos_r: + day_box.pack_end(cls._day_label[i], False, False, 0) + else: + day_box.pack_start(cls._day_label[i], False, False, 0) + # scroller & textview + day_scroller = Gtk.ScrolledWindow() + cls._day_scroll.append(day_scroller) + day_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + day_scroller.set_overlay_scrolling(False) + day_scroller.set_hexpand(True) + cls._day_rows.append(Gtk.Box(orientation=Gtk.Orientation.VERTICAL)) + day_scroller.add(cls._day_rows[i]) + ctx = cls._day_rows[i].get_style_context() + ctx.add_class('weekview_daytext') + day_box.pack_start(day_scroller, True, True, 0) + + # Attach elements to pages + page_l = GUI._builder.get_object('week_page_l') + page_r = GUI._builder.get_object('week_page_r') + for i in range(3): # Left + page_l.pack_start(cls._day_eventbox[i], True, True, 0) + for i in range(3,7): # ... and right + page_r.pack_start(cls._day_eventbox[i], True, True, 0) + + + @classmethod + def _set_label_text(cls) -> None: + # Sets date and month label text and style classes. + # Called on view redraw. + cls._last_cursor = None + dt = start_of_week(GUI.cursor_date) + cls._week_viewed = dt + dt_end = dt + timedelta(days=6) + # Month label + if dt.month == dt_end.month: + mn = month_name[dt.month].capitalize() + ml = u'{mon:s} {yr:d}'.format(mon=mn, yr=dt.year) + elif dt.year == dt_end.year: + msn = month_abbr[dt.month].capitalize() + ml = u'{mon_st:s} – {mon_end:s} {yr:d}'.format(mon_st=msn, mon_end=month_abbr[dt_end.month], yr=dt.year) + else: # month & year different + msn = month_abbr[dt.month].capitalize() + ml = u'{mon_st:s} {yr_st:d} – {mon_end:s} {yr_end:d}'.format(mon_st=msn, mon_end=month_abbr[dt_end.month], yr_st=dt.year, yr_end=dt_end.year) + cls._month_label.set_text(ml) + + # Week number label + # Week 1 is the first to include 4+ days of year. + # E.g. if week starts Monday then Wk1 is first to include a Thursday. + wkno = (dt.timetuple().tm_yday+9)//7 + if wkno==53 and dt.day>=29: + wkno_txt = _('Week 53/Week 1') + else: + wkno_txt = _('Week {}').format(wkno) + cls._weekno_label.set_text(wkno_txt) + + # Day labels + today = dt_date.today() + for i in range(7): + ctx = cls._day_eventbox[i].get_style_context() + if dt None: + # Sets label text and style classes for event-displaying labels. + # Called on view redraw. + cls._last_cursor = None + dt = start_of_week(GUI.cursor_date) + cls._visible_occurrences = Calendar.occurrence_list(dt, dt+timedelta(days=7)) + itr = iter(cls._visible_occurrences) + try: + occ = next(itr) + except StopIteration: + occ = None + cls._day_ent_count = [0]*7 # we'll fill this in as we display + for i in range(7): + dt_nxt = dt + timedelta(days=1) + # Delete anything previously written to day v-box + cls._day_rows[i].foreach(Gtk.Widget.destroy) + while True: + if occ is None: + break + occ_dt_sta,occ_dt_end = start_end_dts_occ(occ) + if dt_lte(dt_nxt, occ_dt_sta): + # into next day so break this loop + break + row = Gtk.Box() + # Create entry mark (bullet or time) & add to row + mark_label = cls.marker_label(occ[0], occ_dt_sta) + ctx = mark_label.get_style_context() + ctx.add_class('weekview_marker') # add style for CSS + row.add(mark_label) + + # Create entry content label & add to row + cont_label = cls.entry_text_label(occ[0],occ_dt_sta,occ_dt_end) + cont_label.set_hexpand(True) # Also sets hexpand_set to True + row.add(cont_label) + cls._day_rows[i].add(row) + cls._day_ent_count[i] += 1 + try: + occ = next(itr) + except StopIteration: + occ = None + if cls._day_ent_count[i]==0: + # an empty day, need something for cursor + mark_label = Gtk.Label() + ctx = mark_label.get_style_context() + ctx.add_class('weekview_marker') + mark_label.set_text(u' ') # en-space char + mark_label.set_halign(Gtk.Align.START) # else cursor fills line + cls._day_rows[i].add(mark_label) + dt = dt_nxt + cls._day_rows[i].show_all() + + + @classmethod + def _show_cursor(cls) -> None: + # Locates bullet/date corresponding to the current cursor and adds + # 'weekview_cursor' class to it so cursor is visible via CSS styling. + dy = day_in_week(GUI.cursor_date) + ecount = cls._day_ent_count[dy] + i = GUI.cursor_idx_in_date + if i < 0 or i >= ecount: + i = max(0,ecount-1) + GUI.cursor_idx_in_date = i + cls._hide_cursor() + row = cls._day_rows[dy].get_children()[i] + if ecount==0: + mk = row + else: + mk = row.get_children()[0] + ctx = mk.get_style_context() + ctx.add_class(cls.CURSOR_STYLE) + cls._last_cursor = int(dy+8*i) + if cls._scroll_callback_id is not None: + # Cancel existing callback to prevent inconsistent scroll requests. + GLib.source_remove(cls._scroll_callback_id) + cls._scroll_callback_id = None + if cls._day_ent_count[dy] > 0: + # We may need to scroll content to show entry at cursor. + # Want this to happen after redraw, otherwise, can't access + # dimensions of elements needed to calculate scroll size (because + # they haven't been calculated). So delay scroll to after redraw. + # We save the returned id so the callback can be cancelled. + cls._scroll_callback_id = GLib.idle_add(cls._scroll_to_row, cls._day_rows[dy], i, cls._day_scroll[dy], priority=GLib.PRIORITY_HIGH_IDLE+30) + + + @classmethod + def _scroll_to_row(cls, rowbox:Gtk.Box, row:int, scroller:Gtk.ScrolledWindow) -> bool: + # Local version of scroll_to_row() that clears the id, then + # calls the parent class scroll_to_row() to do the work. + cls._scroll_callback_id = None + return cls.scroll_to_row(rowbox, row, scroller) + + + @classmethod + def _hide_cursor(cls) -> None: + # Clears 'weekview_cursor' style class from cursor position, + # so cursor is no longer visible. + if cls._last_cursor is not None: + # _last_cursor is an int split into two parts: + # Lower 3 bits give day, other higher bits give entry within day + dy = cls._last_cursor%8 + row = cls._day_rows[dy].get_children()[cls._last_cursor//8] + if cls._day_ent_count[dy]==0: + mk = row + else: + mk = row.get_children()[0] + ctx = mk.get_style_context() + ctx.remove_class(cls.CURSOR_STYLE) + cls._last_cursor = None + + + @classmethod + def get_cursor_entry(cls) -> iCal.Event: + # Returns entry at cursor position, or None if cursor not on entry. + # Called from cursor_edit_entry(). + dy = day_in_week(GUI.cursor_date) + if cls._day_ent_count[dy]==0: + return None + i = sum(cls._day_ent_count[:dy]) + i += GUI.cursor_idx_in_date + return cls._visible_occurrences[i][0] + + + @classmethod + def renew_display(cls) -> None: + # Called when we switch to this view to reset state. + cls._week_viewed = None + + + @classmethod + def redraw(cls, ev_changes:bool) -> None: + # Called when redraw required. + # ev_changes: bool indicating if events need updating too + if cls._week_viewed != start_of_week(GUI.cursor_date): + cls._set_label_text() + ev_changes = True + if ev_changes: + cls._set_entry_text() + cls._show_cursor() + + + @classmethod + def _cursor_move_up(cls) -> None: + # Callback for user moving cursor up. + GUI.cursor_idx_in_date -= 1 + if GUI.cursor_idx_in_date < 0: + GUI.cursor_inc(timedelta(days=-1)) + # Leave idx_in_date as -1 since that signals last entry + GUI.today_toggle_date = None + else: + cls._show_cursor() + + + @classmethod + def _cursor_move_dn(cls) -> None: + # Callback for user moving cursor down. + GUI.cursor_idx_in_date += 1 + dy = day_in_week(GUI.cursor_date) + if GUI.cursor_idx_in_date >= cls._day_ent_count[dy]: + GUI.cursor_inc(timedelta(days=1), 0) + GUI.today_toggle_date = None + else: + cls._show_cursor() + + + @classmethod + def _cursor_move_lt(cls) -> None: + # Callback for user moving cursor left. + i = day_in_week(GUI.cursor_date) + d = -7 if i<3 else (-3 if i==3 else -4) + GUI.cursor_inc(timedelta(days=d), 0) + GUI.today_toggle_date = None + + + @classmethod + def _cursor_move_rt(cls) -> None: + # Callback for user moving cursor right. + i = day_in_week(GUI.cursor_date) + d = 4 if i<3 else 7 + GUI.cursor_inc(timedelta(days=d), 0) + GUI.today_toggle_date = None + + + @classmethod + def _cursor_move_today(cls) -> None: + # Callback for user toggling cursor between today & another date + today = dt_date.today() + if GUI.cursor_date != today: + GUI.today_toggle_date = GUI.cursor_date + GUI.today_toggle_idx = GUI.cursor_idx_in_date + GUI.cursor_set(today,0) + elif GUI.today_toggle_date is not None: + GUI.cursor_set(GUI.today_toggle_date,GUI.today_toggle_idx) + + + @classmethod + def _init_keymap(cls) -> None: + # Initialises KEYMAP for class. Called from init() since it needs + # to be called after class construction, so that functions exist. + cls._KEYMAP = { + Gdk.KEY_Up: lambda: cls._cursor_move_up(), + Gdk.KEY_Down: lambda: cls._cursor_move_dn(), + Gdk.KEY_Right: lambda: cls._cursor_move_rt(), + Gdk.KEY_Left: lambda: cls._cursor_move_lt(), + Gdk.KEY_space: lambda: cls._cursor_move_today(), + Gdk.KEY_Return: lambda: cls.cursor_edit_entry(), + } + + + @classmethod + def keypress(cls, wid:Gtk.Widget, ev:Gdk.EventKey) -> None: + # Handle key press event in Week view. + # Called (from GUI.keypress()) on keypress (or repeat) event + try: + f = cls._KEYMAP[ev.keyval] + GLib.idle_add(f) + except KeyError: + # If it's a character key, take as first of new entry + # !! Bug: only works for ASCII characters + if ev.state & (Gdk.ModifierType.CONTROL_MASK|Gdk.ModifierType.MOD1_MASK)==0 and Gdk.KEY_exclam <= ev.keyval <= Gdk.KEY_asciitilde: + GLib.idle_add(EntryDialogController.newentry,chr(ev.keyval)) + + + @classmethod + def click_date(cls, wid:Gtk.Widget, ev:Gdk.EventButton) -> None: + # Callback. Called whenever a date is clicked/tapped. + # Moves cursor to date/item clicked + try: + new_day = cls._day_eventbox.index(wid) + except ValueError: + return + + new_idx = 0 # default index if not going to specific entry + + # Has user clicked on a specific entry within day? + # If so, move the cursor to that entry. + if cls._day_ent_count[new_day] > 1: # Only check if >1 entry in the day + # Check if clicked in day's label area + d_wids = wid.get_child().get_children() + ll = isinstance(d_wids[0],Gtk.Label) # True if left label + if ll != (ev.x < d_wids[0].get_allocated_width()): # Clicked entries + # We're not clicking in date label area, use y to calc entry + new_idx = cls.y_to_day_row(cls._day_rows[new_day], ev.y, cls._day_ent_count[new_day], cls._day_scroll[new_day]) + + GLib.idle_add(GUI.cursor_set, cls._day_index_to_date(new_day), new_idx) + + + @classmethod + def _day_index_to_date(cls, idx:int) -> Optional[dt_date]: + # Given an day index idx=0...6 of day cell in the visible Week View, + # return the corresponding date + if cls._week_viewed is None: + return None + return cls._week_viewed+timedelta(days=idx) diff --git a/pygenda/pygenda_view_year.py b/pygenda/pygenda_view_year.py new file mode 100644 index 0000000..c288b1c --- /dev/null +++ b/pygenda/pygenda_view_year.py @@ -0,0 +1,586 @@ +# -*- coding: utf-8 -*- +# +# pygenda_view_year.py +# Provides the "Year View" for Pygenda. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + + +from gi import require_version as gi_require_version +gi_require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib + +import calendar +from datetime import date as dt_date, datetime as dt_datetime, timedelta +from locale import gettext as _ +from icalendar import cal as iCal +from typing import Tuple + +# pygenda components +from .pygenda_view import View +from .pygenda_gui import GUI, EntryDialogController +from .pygenda_config import Config +from .pygenda_calendar import Calendar +from .pygenda_util import start_end_dts_occ + + +# Singleton class for Year View +class View_Year(View): + Config.set_defaults('year_view',{}) + DAY_CLASS = [ 'yearview_day_{}'.format(s) for s in ['mon','tue','wed','thu','fri','sat','sun'] ] + GRID_COLUMNS = 37 + GRID_ROWS = 12 # One per month + GRID_CURSOR_STYLE = 'yearview_cursor' + ENTRY_CURSOR_STYLE = 'yearview_entry_cursor' + _target_col = None + _year_viewed = None + _last_cursor = None + _visible_occurrences = None + _show_datecontent_pending = False + _date_content_count = 0 + + @staticmethod + def view_name() -> str: + # Return (localised) string to use in menu + return _('_Year View') + + @staticmethod + def accel_key() -> int: + # Return (localised) keycode for menu shortcut + k = _('year_view_accel') + return ord(k[0]) if len(k)>0 else 0 + + + @classmethod + def init(cls) -> Gtk.Widget: + # Called on startup. + # Gets view framework from glade file & tweaks/adds a few elements. + # Returns widget containing view. + cls._topbox = GUI._builder.get_object('view_year') + cls._grid_cells = GUI._builder.get_object('year_grid_days') + cls._date_label = GUI._builder.get_object('year_datelabel') + cls._date_content_scroll = GUI._builder.get_object('year_datecontent_scroll') + cls._date_content = GUI._builder.get_object('year_datecontent') + cls._draw_day_month_labels() + cls._year_viewed = -1 # Indicates next redraw will draw year + cls._init_keymap() + cls._init_grid() + GUI._builder.get_object('year_grid_events').connect('button_press_event', cls.click_grid) + GUI._builder.get_object('year_datecontent_events').connect('button_press_event', cls.click_events) + return cls._topbox + + + @classmethod + def _draw_day_month_labels(cls) -> None: + # Adds text to grid day and month labels. + # Called only at initialisation. + mon_names = GUI._builder.get_object('month_names') + for i in range(cls.GRID_ROWS): + l = Gtk.Label(calendar.month_abbr[i+1].capitalize()) + ctx = l.get_style_context() + ctx.add_class('yearview_month_label') + mon_names.add(l) + + st_wk = Config.get_int('global','start_week_day') + day_names = GUI._builder.get_object('day_names') + day_label_txt = [ d[0].capitalize() for d in calendar.day_abbr ] + for i in range(st_wk,st_wk+cls.GRID_COLUMNS): + l = Gtk.Label(day_label_txt[i%7]) + ctx = l.get_style_context() + ctx.add_class('yearview_day_label') + ctx.add_class(cls.DAY_CLASS[i%7]) + day_names.add(l) + + + @classmethod + def _init_keymap(cls) -> None: + # Initialises KEYMAP for class. Called from init() since it needs + # to be called after class construction, so that functions exist. + cls._KEYMAP = { + Gdk.KEY_Left: lambda: cls._cursor_move_lr(-1), + Gdk.KEY_Right: lambda: cls._cursor_move_lr(1), + Gdk.KEY_Up: lambda: cls._cursor_move_up(), + Gdk.KEY_Down: lambda: cls._cursor_move_dn(), + Gdk.KEY_Home: lambda: cls._cursor_move_stmon(), + Gdk.KEY_End: lambda: cls._cursor_move_endmon(), + Gdk.KEY_Page_Up: lambda: cls._cursor_move_pgupdn(-1), + Gdk.KEY_Page_Down: lambda: cls._cursor_move_pgupdn(1), + Gdk.KEY_space: lambda: cls._cursor_move_today(), + Gdk.KEY_Return: lambda: cls.cursor_edit_entry(), + } + cls._KEYMAP_SHIFT = { + Gdk.KEY_Up: lambda: cls._idxcursor_move_up(), + Gdk.KEY_Down: lambda: cls._idxcursor_move_dn(), + } + + + @classmethod + def _init_grid(cls) -> None: + # Adds labels to each grid cell + for m in range(0,cls.GRID_ROWS): + for d in range(0,cls.GRID_COLUMNS): + l = Gtk.Label() + l.set_xalign(0) + l.set_yalign(0) + cls._grid_cells.attach(l,d,m,1,1) + + + @classmethod + def _draw_year(cls) -> None: + # Draws year - adds formatting classes to cells, labels to some dates. + # Called on redraw if year has changed. + # Function a bit disorganised, tidying might be beneficial... + c_date = GUI.cursor_date + yr = c_date.year + cls._year_viewed = yr + l = GUI._builder.get_object('year_yearlabel') + l.set_text('{:d}'.format(yr)) + + st_wk = Config.get_int('global','start_week_day') + mon_labs = GUI._builder.get_object('month_names').get_children() + day_labs = GUI._builder.get_object('day_names').get_children() + date = dt_date(year=yr,month=1,day=1) + oneday = timedelta(days=1) + today = dt_date.today() + + for m in range(1,cls.GRID_ROWS+1): + day,daycount = calendar.monthrange(yr,m) + col = (day-st_wk)%7 # Column for first of the month + ctx = mon_labs[m-1].get_style_context() + if col==0: + ctx.add_class('yearview_leftofdaycell') + else: + ctx.remove_class('yearview_leftofdaycell') + if m!=cls.GRID_ROWS: + # This is to take account of grid lines above next month + nday,ndaycount = calendar.monthrange(yr,m+1) + ncol = (nday-st_wk)%7 + ndayend = ncol+ndaycount + else: + ncol = 7 + ndayend = 0 + for c in range(col): # Empty cells before col + l = cls._grid_cells.get_child_at(c,m-1) + l.set_text('') + ctx = l.get_style_context() + cls.remove_all_classes(ctx) + ctx.add_class('yearview_emptycell') + if c==col-1: + ctx.add_class('yearview_leftofdaycell') + if c>=ncol: + ctx.add_class('yearview_abovedaycell') + if m==1: + ctx = day_labs[c].get_style_context() + ctx.remove_class('yearview_abovedaycell') + + for d in range(daycount): + # Potential optimisations here? Central columns are always in + # year, so no need to remove&re-add class yearview_daycell etc. + # Also if not start of week, will never be start of week, + # so we know label text is already ''. + t = '' + if day==st_wk or d==0 or d==daycount-1: + t = '{:d}'.format(d+1) + l = cls._grid_cells.get_child_at(col,m-1) + l.set_text(t) + ctx = l.get_style_context() + cls.remove_all_classes(ctx) + ctx.add_class('yearview_daycell') + ctx.add_class(cls.DAY_CLASS[day]) + if date None: + # Called when we switch to this view to reset state. + cls._target_col = None + + + @classmethod + def redraw(cls, ev_changes:bool) -> None: + # Called when redraw required. + # ev_changes: bool indicating if events need updating too + if cls._year_viewed != GUI.cursor_date.year: + cls._draw_year() + cls._last_cursor = None + ev_changes = True + cls._show_cursor() + cls._show_datelabel() + # Queue delayed redraw of day content + if not cls._show_datecontent_pending: + cls._date_content.foreach(Gtk.Widget.destroy) + cls._show_datecontent_pending = True + cls._date_content_count = 0 + # Schedule idle to add datecontent + # Priority below draw, so datelabel will be redrawn while moving + GLib.idle_add(cls._show_datecontent,priority=GLib.PRIORITY_HIGH_IDLE+40) + if ev_changes: + GLib.idle_add(cls._show_gridcontent,priority=GLib.PRIORITY_HIGH_IDLE+35) + + + @classmethod + def _show_datelabel(cls) -> None: + # Show cursor date in label in bottom panel of view. + # Called on redraw + st = GUI.cursor_date.strftime(GUI.date_formatting_text_noyear)+':' + cls._date_label.set_text(st) + + + @classmethod + def _show_datecontent(cls) -> None: + # Show events for current cursor date in bottom panel of view. + # Assumes that no events are currently shown. + # Can be slow, so called in idle from redraw. + dt = GUI.cursor_date + cls._visible_occurrences = Calendar.occurrence_list(dt, dt+timedelta(days=1)) + r = 0 + for occ in cls._visible_occurrences: + occ_dt_sta,occ_dt_end = start_end_dts_occ(occ) + row = Gtk.Box() + # Create entry mark (bullet or time) & add to row + mark_label = cls.marker_label(occ[0], occ_dt_sta) + ctx = mark_label.get_style_context() + ctx.add_class('yearview_marker') # add style for CSS + row.add(mark_label) + # Create entry content label & add to row + cont_label = cls.entry_text_label(occ[0],occ_dt_sta,occ_dt_end) + cont_label.set_hexpand(True) # Also sets hexpand_set to True + row.add(cont_label) + cls._date_content.add(row) + r += 1 + cls._date_content_count = r + cls._date_content.show_all() + cls._show_entry_cursor() + cls._show_datecontent_pending = False + + + @classmethod + def _show_entry_cursor(cls) -> None: + # Set style to display entry cursor (= cursor in lower section of view) + if cls._date_content_count==0: + # No entries for day - return + GUI.cursor_idx_in_date = 0 + return + i = GUI.cursor_idx_in_date + if i<0 or i>=cls._date_content_count: + i = cls._date_content_count-1 + GUI.cursor_idx_in_date = i + mk = cls._date_content.get_children()[i].get_children()[0] + ctx = mk.get_style_context() + ctx.add_class(cls.ENTRY_CURSOR_STYLE) + cls._last_entry_cursor = i + cls.scroll_to_row(cls._date_content, i, cls._date_content_scroll) + + + @classmethod + def _hide_entry_cursor(cls) -> None: + # Remove style from entry cursor (= cursor in lower section of view) + if cls._last_entry_cursor is not None: + mk = cls._date_content.get_children()[cls._last_entry_cursor].get_children()[0] + ctx = mk.get_style_context() + ctx.remove_class(cls.ENTRY_CURSOR_STYLE) + cls._last_entry_cursor = None + + + @classmethod + def get_cursor_entry(cls) -> iCal.Event: + # Returns entry at cursor position, or None if cursor not on entry. + # Called from cursor_edit_entry(). + if cls._date_content_count == 0: + return None + return cls._visible_occurrences[GUI.cursor_idx_in_date][0] + + + @classmethod + def _show_gridcontent(cls) -> None: + # Set styles to show events in grid. + # Can be slow, so called in idle from redraw. + # Potential optimisations here. Rather than getting occurences and + # then splitting, we can add a custom calendar function to return + # just the dates. Particularly for the repeated entries. + yr = cls._year_viewed + date = dt_date(year=yr,month=1,day=1) + oneday = timedelta(days=1) + occ_dates_single = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in Calendar.occurrence_list(date, dt_date(year=yr+1,month=1,day=1), include_single=True, include_repeated=False)] + reps_list = Calendar.occurrence_list(date, dt_date(year=yr+1,month=1,day=1), include_single=False, include_repeated=True) + occ_dates_repeated = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in reps_list] + occ_dates_repeated_year = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in reps_list if o[0]['RRULE']['FREQ'][0]=='YEARLY'] + occ_dates_repeated_month = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in reps_list if o[0]['RRULE']['FREQ'][0]=='MONTHLY'] + occ_dates_repeated_week = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in reps_list if o[0]['RRULE']['FREQ'][0]=='WEEKLY'] + occ_dates_repeated_day = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in reps_list if o[0]['RRULE']['FREQ'][0]=='DAILY'] + occ_dates_repeated_hour = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in reps_list if o[0]['RRULE']['FREQ'][0]=='HOURLY'] + occ_dates_repeated_minute = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in reps_list if o[0]['RRULE']['FREQ'][0]=='MINUTELY'] + occ_dates_repeated_second = [o[1].date() if isinstance(o[1],dt_datetime) else o[1] for o in reps_list if o[0]['RRULE']['FREQ'][0]=='SECONDLY'] + + for m in range(1,13): + day,daycount = calendar.monthrange(yr,m) + x,y = cls._date_to_cell(date) + for d in range(daycount): + l = cls._grid_cells.get_child_at(x,y) + ctx = l.get_style_context() + if date in occ_dates_single: + ctx.add_class('yearview_entry_single') + else: + ctx.remove_class('yearview_entry_single') + ctx.remove_class('yearview_entry_repeated') + ctx.remove_class('yearview_entry_repeated_year') + ctx.remove_class('yearview_entry_repeated_month') + ctx.remove_class('yearview_entry_repeated_week') + ctx.remove_class('yearview_entry_repeated_day') + ctx.remove_class('yearview_entry_repeated_hour') + ctx.remove_class('yearview_entry_repeated_minute') + ctx.remove_class('yearview_entry_repeated_second') + if date in occ_dates_repeated: + ctx.add_class('yearview_entry_repeated') + if date in occ_dates_repeated_year: + ctx.add_class('yearview_entry_repeated_year') + if date in occ_dates_repeated_month: + ctx.add_class('yearview_entry_repeated_month') + if date in occ_dates_repeated_week: + ctx.add_class('yearview_entry_repeated_week') + if date in occ_dates_repeated_day: + ctx.add_class('yearview_entry_repeated_day') + if date in occ_dates_repeated_hour: + ctx.add_class('yearview_entry_repeated_hour') + if date in occ_dates_repeated_minute: + ctx.add_class('yearview_entry_repeated_minute') + if date in occ_dates_repeated_second: + ctx.add_class('yearview_entry_repeated_second') + date += oneday + x += 1 + + + @classmethod + def _show_cursor(cls) -> None: + # Add style class for grid cursor to the appropriate cell label + new_coords = cls._date_to_cell(GUI.cursor_date) + if cls._last_cursor!=new_coords: + cls._hide_cursor() + l = cls._grid_cells.get_child_at(*new_coords) + ctx = l.get_style_context() + ctx.add_class(cls.GRID_CURSOR_STYLE) + cls._last_cursor = new_coords + + + @classmethod + def _hide_cursor(cls) -> None: + # Remove grid cursor style class so grid cursor is not shown + if cls._last_cursor is not None: + l = cls._grid_cells.get_child_at(*cls._last_cursor) + ctx = l.get_style_context() + ctx.remove_class(cls.GRID_CURSOR_STYLE) + cls._last_cursor = None + + + @classmethod + def _cursor_move_lr(cls, d:int) -> None: + # Callback for user moving grid cursor left (d<0) or right (d>0). + GUI.cursor_inc(timedelta(days=d), 0) + cls._target_col = None + GUI.today_toggle_date = None + + + @classmethod + def _cursor_move_up(cls) -> None: + # Callback for user moving grid cursor up. + cur_dt = GUI.cursor_date + if cls._target_col is None: + cls._target_col = cls._date_to_cell(cur_dt)[0] + new_y = cur_dt.month-2 # months 1..12, cells 0..11 + new_yr = cur_dt.year + if new_y<0: + new_y = 11 + new_yr -= 1 + new_dt = cls._cell_to_date_clamped(cls._target_col, new_y, new_yr) + GUI.cursor_set(new_dt,0) + GUI.today_toggle_date = None + + + @classmethod + def _cursor_move_dn(cls) -> None: + # Callback for user moving grid cursor down. + cur_dt = GUI.cursor_date + if cls._target_col is None: + cls._target_col = cls._date_to_cell(cur_dt)[0] + new_y = cur_dt.month # months 1..12, cells 0..11 + new_yr = cur_dt.year + if new_y>11: + new_y = 0 + new_yr += 1 + new_dt = cls._cell_to_date_clamped(cls._target_col, new_y, new_yr) + GUI.cursor_set(new_dt,0) + GUI.today_toggle_date = None + + + @classmethod + def _cursor_move_stmon(cls) -> None: + # Callback for user moving grid cursor to start of month. + new_dt = GUI.cursor_date.replace(day=1) + GUI.cursor_set(new_dt,0) + cls._target_col = None + GUI.today_toggle_date = None + + + @classmethod + def _cursor_move_endmon(cls) -> None: + # Callback for user moving grid cursor to end of month. + dt = GUI.cursor_date + lastday = calendar.monthrange(dt.year,dt.month)[1] + new_dt = dt.replace(day=lastday) + GUI.cursor_set(new_dt,0) + cls._target_col = None + GUI.today_toggle_date = None + + + @classmethod + def _cursor_move_pgupdn(cls, d:int) -> None: + # Callback for user moving grid cursor pageup (d<0) or pagedown (d>0). + cur_dt = GUI.cursor_date + if cls._target_col is None: + cls._target_col = cls._date_to_cell(cur_dt)[0] + new_yr = cur_dt.year+d + new_dt = cls._cell_to_date_clamped(cls._target_col, cur_dt.month-1, new_yr) + GUI.cursor_set(new_dt,0) + GUI.today_toggle_date = None + + + @classmethod + def _cursor_move_today(cls) -> None: + # Callback for user toggling cursors between today & another date + today = dt_date.today() + if GUI.cursor_date != today: + GUI.today_toggle_date = GUI.cursor_date + GUI.today_toggle_idx = GUI.cursor_idx_in_date + GUI.cursor_set(today,0) + cls._target_col = None + elif GUI.today_toggle_date is not None: + GUI.cursor_set(GUI.today_toggle_date,GUI.today_toggle_idx) + cls._target_col = None + + + @classmethod + def _idxcursor_move_up(cls) -> None: + # Callback to move entry/index cursor (=cursor in bottom section) up + if GUI.cursor_idx_in_date>0: + GUI.cursor_idx_in_date -= 1 + cls._hide_entry_cursor() + cls._show_entry_cursor() + + + @classmethod + def _idxcursor_move_dn(cls) -> None: + # Callback to move entry/index cursor (=cursor in bottom section) down + if GUI.cursor_idx_in_date None: + # Handle key press event in Year view. + # Called (from GUI.keypress()) on keypress (or repeat) event + if ev.state & Gdk.ModifierType.SHIFT_MASK: + # Shift key is being pressed + try: + f = cls._KEYMAP_SHIFT[ev.keyval] + GLib.idle_add(f, priority=GLib.PRIORITY_HIGH_IDLE+30) + return # If it gets here without errors, we're done + except KeyError: + pass + try: + f = cls._KEYMAP[ev.keyval] + GLib.idle_add(f, priority=GLib.PRIORITY_HIGH_IDLE+30) + return # If it gets here without errors, we're done + except KeyError: + pass + if ev.state & (Gdk.ModifierType.CONTROL_MASK|Gdk.ModifierType.MOD1_MASK)==0 and Gdk.KEY_exclam <= ev.keyval <= Gdk.KEY_asciitilde: + GLib.idle_add(EntryDialogController.newentry,chr(ev.keyval), priority=GLib.PRIORITY_HIGH_IDLE+30) + + + @classmethod + def click_grid(cls, wid:Gtk.Widget, ev:Gdk.EventButton) -> None: + # Callback. Called whenever day grid is clicked/tapped. + # Move grid (main) cursor to cell that was clicked. + x = int(cls.GRID_COLUMNS*ev.x/wid.get_allocated_width()) + y = int(cls.GRID_ROWS*ev.y/wid.get_allocated_height()) + dt = cls._cell_to_date_clamped(x, y, cls._year_viewed) + GLib.idle_add(cls._jump_to_date, dt, priority=GLib.PRIORITY_HIGH_IDLE+30) + + + @classmethod + def click_events(cls, wid:Gtk.Widget, ev:Gdk.EventButton) -> None: + # Callback. Called whenever events area at bottom is clicked/tapped. + # Move entry cursor to entry that was clicked + new_idx = cls.y_to_day_row(cls._date_content, ev.y, cls._date_content_count, cls._date_content_scroll) + if GUI.cursor_idx_in_date != new_idx: + GUI.cursor_idx_in_date = new_idx + cls._hide_entry_cursor() + cls._show_entry_cursor() + + + @classmethod + def _jump_to_date(cls, dt:dt_date) -> None: + # Idle callback to move grid cursor to given date, e.g. on click + GUI.cursor_set(dt,0) + cls._target_col = None + + + @staticmethod + def _date_to_cell(dt:dt_date) -> Tuple[int,int]: + # Helper function to return cell coordinates of given date. + stday = calendar.monthrange(dt.year,dt.month)[0] # start day of month + x = dt.day-1+(stday-Config.get_int('global','start_week_day'))%7 + y = dt.month-1 + return x,y + + + @staticmethod + def _cell_to_date_clamped(x:int, y:int, yr:int) -> dt_date: + # Helper function to return date given cell coordinates. + # "Clamps" date, so if the cell is before the start or after the end + # of the month then it returns the closest date in the month. + m = y+1 + st_wk = Config.get_int('global','start_week_day') + st_day,monthdays = calendar.monthrange(yr,m) + d = x+1-(st_day-st_wk)%7 + if d<1: + d = 1 + else: + d = min(d,monthdays) + return dt_date(year=yr, month=m, day=d) diff --git a/pygenda/pygenda_widgets.py b/pygenda/pygenda_widgets.py new file mode 100644 index 0000000..ab1a837 --- /dev/null +++ b/pygenda/pygenda_widgets.py @@ -0,0 +1,581 @@ +# -*- coding: utf-8 -*- +# +# pygenda_widgets.py +# Date, Time and Duration entry widgets for Pygenda. +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . + +# To-do: +# Add timezone field to WidgetTime (default "floating"/None). +# Allow WidgetDuration to take values > 1 day (2d 5h 06m) +# +# !! Possible addition: ctrl+tab in WidgetDate brings up a calendar +# (a GtkCalendar) to allow choice with touchscreen (maybe also add +# a button to WidgetDate). +# Maybe similar in WidgetTime if can find/make a graphical time +# picker widget. + + +from gi import require_version as gi_require_version +gi_require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib, GObject + +from datetime import date as dt_date, time as dt_time, timedelta +from calendar import monthrange +from typing import Optional + +# for internationalisation/localisation +import locale +_ = locale.gettext + +# pygenda components +from .pygenda_config import Config + + +class _WidgetDateTimeBase(Gtk.Box): + # Base class for WidgetDate, WidgetTime, WidgetDuration. + # Not to be used directly. + ALLOWED_KEYS = (Gdk.KEY_BackSpace, Gdk.KEY_Delete, Gdk.KEY_Right, Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Return, Gdk.KEY_Escape) + ALLOWED_KEYS_WITH_CTRL = (Gdk.KEY_a, Gdk.KEY_c, Gdk.KEY_x) + SEPARATOR_KEYS = () + FOCUS_STYLE = 'focus' + field_shortcuts = () + + def __init__(self, *args, **kwds): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=2, *args, **kwds) + try: + dummy = _WidgetDateTimeBase.SHORTCUT_KEYS + except: + # Need to set this here for it to be localisable + _WidgetDateTimeBase.SHORTCUT_KEYS = _('ymdhm') + self._elts = [] + self._elimpad = [] + self.set_halign(Gtk.Align.START) # default to pack from left (if LTR) + + + @classmethod + def _init_field_numeric(cls, mn:int, mx:int, zeropad:bool=True, align:float=0.5) -> Gtk.Entry: + # Returns a new numeric field widget with the given properties. + # Called by child classes in their initialisation. + ln = len(str(mx)) + f = Gtk.Entry() + f.set_max_length(ln) + f.set_width_chars(ln) + f.set_input_purpose(Gtk.InputPurpose.DIGITS) + f.set_alignment(align) # default 0.5 = center digits + f.set_activates_default(True) + pad = ln if zeropad else 0 # Length with zero padding + f.connect('focus-out-event', cls.validate_entry, mn, mx, pad) + return f + + + def _init_navigation(self) -> None: + # Use self._elts array to setup navigation between subfields. + if not Config.get_bool('global', 'tab_elts_datetime'): + # Make Tab key only go to first elt + self.set_focus_chain((self._elts[0],)) + self._focus_count = 0 # init counter for focus in/out + for i in range(len(self._elts)): + self._elts[i].connect('key-press-event', self._key_event, i) + self._elts[i].connect('changed', self._changed) + self._elts[i].connect('focus-out-event', self._focus_out) + self._elts[i].connect('focus-in-event', self._focus_in) + + + def _focus_in(self, entry:Gtk.Widget, ev:Gdk.EventFocus) -> None: + # Event callback for when any sub-field gets the focus + # This and the next function allow us to style whole widget with .focus + # We update counter to avoid unnecessary remove/add styles + if self._focus_count==0: + self.get_style_context().add_class(self.FOCUS_STYLE) + self._focus_count += 1 + + def _focus_out(self, entry:Gtk.Widget, ev:Gdk.EventFocus) -> None: + # Event callback for when any sub-field looses the focus + # Do the work in idle so in leave/enter pairs, leave happens last + GLib.idle_add(_WidgetDateTimeBase._do_focus_out, self) + + def _do_focus_out(self) -> None: + # Idle event callback for when any sub-field loses focus + self._focus_count -= 1 + if self._focus_count==0: + self.get_style_context().remove_class(self.FOCUS_STYLE) + + + def _key_event(self, entry:Gtk.Entry, ev:Gdk.EventKey, idx:int) -> bool: + # Event callback for keypress events. + # Return True if this handles keypress, and False to propagate event. + try: + txt_len = entry.get_text_length() + c_pos = entry.get_property('cursor-position') + sel_bnds = entry.get_selection_bounds() + at_st = c_pos==0 or (sel_bnds and sel_bnds[0]==0) + at_end = c_pos==txt_len or (sel_bnds and sel_bnds[1]==txt_len) + one_from_max = not sel_bnds and c_pos==entry.get_max_length()-1 + except AttributeError: + # Widget is not a GTkEntry (probably an am/pm combo-box) + at_st = True + at_end = True + sel_bnds = False + + # Move left/right + if at_st and ev.keyval==Gdk.KEY_Left and idx>0: + self._elts[idx-1].grab_focus() + return True + if at_end and ev.keyval==Gdk.KEY_Right and idx+1 self._elimpad[idx][1]: + v = self._elimpad[idx][0 if lp else 1] + pad = self._elimpad[idx][2] + entry.set_text('{{:0{:d}d}}'.format(pad).format(v)) + entry.select_region(0,-1) + return True + if ev.keyval in (Gdk.KEY_minus,Gdk.KEY_less) or (shiftdown and ev.keyval==Gdk.KEY_Down): + try: + v = int(entry.get_text()) + v -= 1 + except ValueError: + # field did not contain a number (e.g. empty) + v = self._elimpad[idx][1] + lp = self._elimpad[idx][3] + if v < self._elimpad[idx][0]: + v = self._elimpad[idx][1 if lp else 0] + pad = self._elimpad[idx][2] + entry.set_text('{{:0{:d}d}}'.format(pad).format(v)) + entry.select_region(0,-1) + return True + + # Move to next field from typing separator + # Small problem here. The '-' key can be a separator and can also mean + # "decrease value". Since the latter is checked above it has priority. + if at_end and not sel_bnds and ev.keyval in self.SEPARATOR_KEYS: + self._elts[idx+1].grab_focus() + return True + + # Shortcut keys to jump to fields, e.g. 'm' to jump to month field + try: + new_id = self.field_shortcuts.index(ev.keyval)# not found -> exceptn + self._elts[new_id].grab_focus() + return True + except ValueError: + pass + + # Type a digit + if ev.keyval>=Gdk.KEY_0 and ev.keyval<=Gdk.KEY_9: + if at_end and one_from_max and idx+1 None: + # If one subcomponent changes, cascade 'changed' signal down + self.emit('changed') + + + @staticmethod + def validate_entry(entry:Gtk.Entry, ev:Gdk.EventFocus, mn:int, mx:int, pad:int) -> None: + # Helper function to validate (and correct) date/time fields. + # Used as callback function when focus leaves the field. + entry.select_region(0,0) # remove highlight + try: + v = int(entry.get_text()) + v = max(mn,v) + v = min(mx,v) + except ValueError: + v = mn + entry.set_text('{{:0{:d}d}}'.format(pad).format(v)) + + + def grab_focus(self) -> None: + # Provide implementation of widget class's grab_focus() + self._elts[0].grab_focus() + + +# Allow _WidgetDateTimeBase to emit its own 'changed' signals +GObject.signal_new('changed', _WidgetDateTimeBase, GObject.SIGNAL_RUN_LAST, None, ()) + + +class WidgetDate(_WidgetDateTimeBase): + # Widget for date entry fields, to use (eg) in "New Entry" dialog. + __gtype_name__ = 'WidgetDate' + SEPARATOR_KEYS = (Gdk.KEY_slash, Gdk.KEY_minus, Gdk.KEY_period) + + def __init__(self, dt, *args, **kwds): + super().__init__(*args, **kwds) + + self.field_year = self._init_field_numeric(1,9999,zeropad=False) + self.field_month = self._init_field_numeric(1,12) + self.field_day = self._init_field_numeric(1,31) + + sep = Config.get('global','date_sep') + first = True + self.field_shortcuts = [] + + from .pygenda_gui import GUI # Delayed import to avoid circular dep + for ch in GUI.date_order: + if not first: + self.pack_start(Gtk.Label(sep), False, False, 0) + if ch=='Y': + f = self.field_year + lp = (1,9999,0,False) # min,max,pad,loop + self.field_shortcuts.append(ord(self.SHORTCUT_KEYS[0])) + elif ch=='M': + f = self.field_month + lp = (1,12,2,True) + self.field_shortcuts.append(ord(self.SHORTCUT_KEYS[1])) + else: # ch==D + f = self.field_day + lp = (1,31,2,True) + self.field_shortcuts.append(ord(self.SHORTCUT_KEYS[2])) + self._elts.append(f) # For _init_navigation() + self._elimpad.append(lp) # For +/- keys etc + self.pack_start(f, False, False, 0) + first = False + + self.set_date(dt) + self._init_navigation() # So we can navigate among elements + + + def set_date(self, dt:dt_date) -> None: + # Set widget contents. + self.field_year.set_text(str(dt.year)) + self.field_month.set_text('{:02d}'.format(dt.month)) + self.field_day.set_text('{:02d}'.format(dt.day)) + + + def get_date(self) -> dt_date: + # Get widget contents. Raises ValueError if date invalid. + y = int(self.field_year.get_text()) + m = int(self.field_month.get_text()) + d = int(self.field_day.get_text()) + dt = dt_date(year=y, month=m, day=d) + return dt + + + def get_date_or_none(self) -> Optional[dt_date]: + # Get widget contents. Returns None if date invalid. + try: + return self.get_date() + except ValueError: + return None + + + def get_approx_date_or_none(self) -> Optional[dt_date]: + # Get "approximate" date contents. Approximate here means try to + # guess invalid dates, e.g. 2022-02-30 -> 2022-02-28. + # This is for the "goto" dialog, so it doesn't force the user to + # enter a strictly valid date. + try: + return self.get_date() + except ValueError: + pass + + # If get_date() gave an error, try harder to get values + today = dt_date.today() # defaults if fields are empty/meaningless + try: + y = int(float(self.field_year.get_text())) + if y <= 0: + y = 1 + elif y > 9999: + y = 9999 + except ValueError: + y = today.year + + try: + m = int(float(self.field_month.get_text())) + if m <= 0: + m = 1 + elif m > 12: + m = 12 + except ValueError: + m = today.month + + try: + d = int(float(self.field_day.get_text())) + monthdays = monthrange(y,m)[1] + if d <= 0: + d = 1 + elif d > monthdays: + d = monthdays + except ValueError: + d = 1 + + try: + dt = dt_date(year=y, month=m, day=d) + except ValueError: + dt = None + return dt + + + def is_valid_date(self) -> bool: + # Returns True if widget date is valid, False otherwise. + try: + self.get_date() + except ValueError: + return False + return True + + +class WidgetTime(_WidgetDateTimeBase): + # Widget for time entry fields, to use (eg) in "New Entry" dialog. + __gtype_name__ = 'WidgetTime' + SEPARATOR_KEYS = (Gdk.KEY_colon, Gdk.KEY_period) + am_str = None # Initialise these later when we have locale config + pm_str = None + am_keys = None + pm_keys = None + + def __init__(self, dt, *args, **kwds): + super().__init__(*args, **kwds) + self._init_ampm_locale() + if not WidgetTime.field_shortcuts: + WidgetTime.field_shortcuts = [ord(c) for c in (_WidgetDateTimeBase.SHORTCUT_KEYS[3:5])] + + self.is24 = Config.get_bool('global','24hr') + if self.is24: + self.field_hour = self._init_field_numeric(0,23) + else: + self.field_hour = self._init_field_numeric(1,12,zeropad=False,align=1) + self.field_ampm = self._init_field_ampm() + self.field_min = self._init_field_numeric(0,59) + + self.pack_start(self.field_hour, False, False, 0) + self.pack_start(Gtk.Label(Config.get('global','time_sep')), False, False, 0) + self.pack_start(self.field_min, False, False, 0) + + self._elts.append(self.field_hour) # For _init_navigation() + self._elts.append(self.field_min) + + if self.is24: + self._elimpad.append((0,23,2,True)) # For +/- keys etc + else: + self.pack_start(self.field_ampm, False, False, 0) + self._elts.append(self.field_ampm) + self._elimpad.append((1,12,0,True)) + # Add callbacks to change am/pm: + self.field_hour.connect('key-press-event', self._remote_ampm_key_event, self.field_ampm) + self.field_min.connect('key-press-event', self._remote_ampm_key_event, self.field_ampm) + self._elimpad.append((0,59,2,True)) + + self.set_time(dt) + self._init_navigation() # So we can navigate among elements + + + @classmethod + def _init_ampm_locale(cls) -> None: + # Initialise localised am/pm strings if they're None + if cls.am_str is None: + # Use strftime to get locale-dependent am/pm strings + cls.am_str = dt_time().strftime('%p') + if not cls.am_str: # For locales with "" am/pm string (eg fr_FR) + cls.am_str = 'am' # fallback value + cls.pm_str = dt_time(12).strftime('%p') # at 12:00 + if not cls.pm_str: + cls.pm_str = 'pm' # fallback value + + # Now make combobox shortcut keys equal 1st chars of these strings + # (Edge case where 1st chars of am/pm are the same?) + ch = cls.am_str[0] + if ch.lower() == ch.upper(): + cls.am_keys = (ord(ch),) + else: + cls.am_keys = (ord(ch.lower()),ord(ch.upper())) + + ch = cls.pm_str[0] + if ch.lower() == ch.upper(): + cls.pm_keys = (ord(ch),) + else: + cls.pm_keys = (ord(ch.lower()),ord(ch.upper())) + + + @classmethod + def _init_field_ampm(cls) -> Gtk.ComboBoxText: + # Returns a new am/pm field - a ComboBox. + f = Gtk.ComboBoxText() + f.append('a', cls.am_str) + f.append('p', cls.pm_str) + f.connect('key-press-event', cls._ampm_key_event) + return f + + + @staticmethod + def _remote_ampm_key_event(entry:Gtk.Widget, ev:Gdk.EventKey, ampmfield:Gtk.ComboBoxText) -> bool: + # Callback handler so can press a/p in other fields and have + # it affect the am/pm field. + if ev.keyval in WidgetTime.am_keys: + ampmfield.set_active_id('a') + return True + if ev.keyval in WidgetTime.pm_keys: + ampmfield.set_active_id('p') + return True + return False + + + @staticmethod + def _ampm_key_event(entry:Gtk.ComboBoxText, ev:Gdk.EventKey) -> bool: + # Callback handler for keypresses when am/pm field is focussed. + # Return True if this handles keypress and nothing further to do. + if ev.keyval==Gdk.KEY_space: + entry.popup() + return True + if ev.keyval in WidgetTime.am_keys: + entry.set_active_id('a') + return True + if ev.keyval in WidgetTime.pm_keys: + entry.set_active_id('p') + return True + shiftdown = ev.state&Gdk.ModifierType.SHIFT_MASK + if ev.keyval in (Gdk.KEY_plus,Gdk.KEY_minus,Gdk.KEY_greater,Gdk.KEY_less) or (shiftdown and ev.keyval in (Gdk.KEY_Up,Gdk.KEY_Down)): + entry.set_active_id('p' if entry.get_active_id()=='a' else 'a') + return True + if ev.keyval==Gdk.KEY_Up: + return entry.get_toplevel().child_focus(Gtk.DirectionType.UP) + if ev.keyval==Gdk.KEY_Down: + return entry.get_toplevel().child_focus(Gtk.DirectionType.DOWN) + if ev.keyval==Gdk.KEY_Return: + dlg = entry.get_toplevel() + if dlg: + dlg.response(Gtk.ResponseType.OK) + return True + # Return False to allow key to be handled if it's in "safe" list + return ev.keyval not in _WidgetDateTimeBase.ALLOWED_KEYS + + + def set_time(self, tm:dt_time) -> None: + # Set widget contents. + if self.is24: + self.field_hour.set_text('{:02d}'.format(tm.hour)) + else: # 12hr + h = tm.hour + self.field_ampm.set_active_id('a' if h<12 else 'p') + if h==0: + h = 12 + elif h>12: + h -= 12 + self.field_hour.set_text('{:d}'.format(h)) + self.field_min.set_text('{:02d}'.format(tm.minute)) + + + def get_time(self) -> dt_time: + # Get widget contents. Raises ValueError if time invalid. + h = int(self.field_hour.get_text()) + if not self.is24: + if h==12: + h = 0 + if self.field_ampm.get_active_id()=='p': + h += 12 + m = int(self.field_min.get_text()) + tm = dt_time(hour=h, minute=m) + return tm + + + def get_time_or_none(self) -> Optional[dt_time]: + # Get widget contents. Returns None if time invalid. + try: + return self.get_time() + except ValueError: + return None + + + def is_valid_time(self) -> bool: + # Returns True if widget contents give a valid time; False otherwise. + try: + self.get_time() + except ValueError: + return False + return True + + +class WidgetDuration(_WidgetDateTimeBase): + # Widget for duration entry fields, to use (eg) in "New Entry" dialog. + __gtype_name__ = 'WidgetDuration' + SEPARATOR_KEYS = (Gdk.KEY_colon, Gdk.KEY_period) + + def __init__(self, timed, *args, **kwds): + super().__init__(*args, **kwds) + if not WidgetDuration.field_shortcuts: + WidgetDuration.field_shortcuts = [ord(c) for c in (_WidgetDateTimeBase.SHORTCUT_KEYS[3:5])] + + # !! For now we limit to <24hrs + self.field_hour = self._init_field_numeric(0,23,zeropad=False,align=1) + self.field_min = self._init_field_numeric(0,59) + + self.pack_start(self.field_hour, False, False, 0) + self.pack_start(Gtk.Label(Config.get('global','time_sep')), False, False, 0) + self.pack_start(self.field_min, False, False, 0) + + self._elts.append(self.field_hour) # For _init_navigation() + self._elts.append(self.field_min) + self._elimpad.append((0,23,0,False)) # For +/- keys etc + self._elimpad.append((0,59,2,True)) + + self.set_duration(timed) + self._init_navigation() # So we can navigate among elements + + + def set_duration(self, timed:timedelta) -> None: + # Set widget contents. + tot_min = int(timed.total_seconds()//60) + hr,mn = divmod(tot_min,60) + self.field_hour.set_text('{:d}'.format(hr)) + self.field_min.set_text('{:02d}'.format(mn)) + + + def get_duration(self) -> timedelta: + # Get widget contents. Raises ValueError if duration invalid. + h = int(self.field_hour.get_text()) + m = int(self.field_min.get_text()) + if not (0<=m<60) or h<0: + raise(ValueError) + td = timedelta(hours=h, minutes=m) + return td + + + def get_duration_or_none(self) -> Optional[timedelta]: + # Get widget contents. Returns None if duration invalid. + try: + return self.get_duration() + except ValueError: + return None + + +# CSS names +try: + # Requires GTK 3.20+ + WidgetDate.set_css_name('date_entry') + WidgetTime.set_css_name('time_entry') + WidgetDuration.set_css_name('duration_entry') +except AttributeError: + pass diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..086ef99 --- /dev/null +++ b/setup.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# setup.py for Pygenda +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . +# + +from setuptools import setup +from setuptools.command.bdist_egg import bdist_egg +from sys import platform +import subprocess +from os import path as ospath + + +# Subclass Egg installer to handle building clipboard library from C source +class PygendaEggInstall(bdist_egg): + def run(self): + # Override run() method of parent class + self.make_clipboard_library() + super().run() # continue with standard parent run() method + + @staticmethod + def make_clipboard_library(): + # Only know how to do this on Linux - needs fixing for other platforms + if platform == 'linux': + print("Compiling clipboard library...") + cdir = '{:s}/csrc'.format(ospath.dirname(__file__)) + subprocess.run(['make','clean'], cwd=cdir) + subprocess.run(['make'], cwd=cdir) + print("Copying clipboard library...") + subprocess.run(['make','cp'], cwd=cdir) + else: + print("Don't know how to build clipboard library on this platform.\nSkipping.") + + +# Grab version number from source code, so it only needs updating +# in one location. +pygenda_version = None +with open('pygenda/pygenda_version.py') as f: + for line in f: + if line.startswith('__version__'): + pygenda_version = line.split('=')[1].replace('\'','').replace('"','').strip() + break + +assert pygenda_version is not None + +setup( + name = "pygenda", + version = pygenda_version, + url = "https://github.com/semiprime/pygenda", + author = "Matthew Lewis", + author_email = "pygenda@semiprime.com", + description = "An agenda application inspired by Agenda programs on Psion PDAs.", + packages = ["pygenda"], + cmdclass = {'bdist_egg': PygendaEggInstall}, # use custom install above + package_data={'': ['pygenda.glade', 'css/pygenda.css', 'css/*.svg', 'libpygenda_clipboard.so']}, + license = "GPLv3 only", + python_requires = ">=3.5", + setup_requires = ["pycairo"], # For PyGObject + install_requires = [ + "PyGObject", + "python-dateutil", + "icalendar", + ], + classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Environment :: Handhelds/PDA's", + "Environment :: X11 Applications :: GTK", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Office/Business :: Scheduling", + ], +) diff --git a/test/maketest_example.py b/test/maketest_example.py new file mode 100755 index 0000000..dbb7352 --- /dev/null +++ b/test/maketest_example.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Generate test file for Pygenda +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . +# + +from datetime import datetime,timedelta, date as dt_date + +YEAR = datetime.now().year +STAMPDATE = '{}0101T000000'.format(YEAR) +uid = 1234567 + +def print_vevent(desc, date, time=None, endtime=None, daycount=None, repeat=None, repeat_count=0, status=None): + global uid + + if isinstance(date, str): + date = datetime.strptime(date,'%Y-%m-%d').date() + print('BEGIN:VEVENT', end='\r\n') + print('SUMMARY:{:s}'.format(desc), end='\r\n') + if time is not None: + if isinstance(time, str): + time = datetime.strptime(time,'%H:%M').time() + print('DTSTART;VALUE=DATE-TIME:{:04d}{:02d}{:02d}T{:02d}{:02d}{:02d}'.format(date.year, date.month, date.day, time.hour, time.minute, time.second), end='\r\n') + if endtime is not None: + if isinstance(endtime, str): + endtime = datetime.strptime(endtime,'%H:%M').time() + print('DTEND;VALUE=DATE-TIME:{:04d}{:02d}{:02d}T{:02d}{:02d}{:02d}'.format(date.year, date.month, date.day, endtime.hour, endtime.minute, endtime.second), end='\r\n') + else: + print('DTSTART;VALUE=DATE:{:04d}{:02d}{:02d}'.format(date.year, date.month, date.day), end='\r\n') + if daycount is not None: + enddate = date+timedelta(days=daycount) + print('DTEND;VALUE=DATE:{:04d}{:02d}{:02d}'.format(enddate.year, enddate.month, enddate.day), end='\r\n') + print('DTSTAMP;VALUE=DATE-TIME:{}Z'.format(STAMPDATE), end='\r\n') + print('UID:Pygenda-{:08d}'.format(uid), end='\r\n') + if repeat=='YEARLY': + print('RRULE:FREQ=YEARLY;INTERVAL=1{:s}'.format('' if repeat_count==0 else ';COUNT={:d}'.format(repeat_count)), end='\r\n') + elif repeat=='WEEKLY': + print('RRULE:FREQ=WEEKLY;INTERVAL=1{:s}'.format('' if repeat_count==0 else ';COUNT={:d}'.format(repeat_count)), end='\r\n') + elif repeat=='FORTNIGHTLY': + print('RRULE:FREQ=WEEKLY;INTERVAL=2{:s}'.format('' if repeat_count==0 else ';COUNT={:d}'.format(repeat_count)), end='\r\n') + if status is not None: + print('STATUS:{:s}'.format(status.upper()), end='\r\n') + print('END:VEVENT', end='\r\n') + uid += 1 + + +def print_daylight_saving_changes(): + global uid + print('BEGIN:VEVENT', end='\r\n') + print('SUMMARY:Clocks go forward', end='\r\n') + print('DTSTART;VALUE=DATE-TIME:20000326T010000', end='\r\n') + print('DTSTAMP;VALUE=DATE-TIME:{}'.format(STAMPDATE), end='\r\n') + print('UID:Pygenda-{:08d}'.format(uid), end='\r\n') + print('RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3', end='\r\n') + print('END:VEVENT', end='\r\n') + uid += 1 + print('BEGIN:VEVENT', end='\r\n') + print('SUMMARY:Clocks go back', end='\r\n') + print('DTSTART;VALUE=DATE-TIME:20001029T010000', end='\r\n') + print('DTSTAMP;VALUE=DATE-TIME:{}'.format(STAMPDATE), end='\r\n') + print('UID:Pygenda-{:08d}'.format(uid), end='\r\n') + print('RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10', end='\r\n') + print('END:VEVENT', end='\r\n') + uid += 1 + + +def print_thanksgiving(): + global uid + print('BEGIN:VEVENT', end='\r\n') + print('SUMMARY:Thanksgiving (US)', end='\r\n') + print('DTSTART;VALUE=DATE:19421126', end='\r\n') + print('DTSTAMP;VALUE=DATE-TIME:{}'.format(STAMPDATE), end='\r\n') + print('UID:Pygenda-{:08d}'.format(uid), end='\r\n') + print('RRULE:FREQ=YEARLY;BYDAY=4TH;BYMONTH=11', end='\r\n') + print('END:VEVENT', end='\r\n') + uid += 1 + print('BEGIN:VEVENT', end='\r\n') + print('SUMMARY:Thanksgiving (Canada)', end='\r\n') + print('DTSTART;VALUE=DATE:19571014', end='\r\n') + print('DTSTAMP;VALUE=DATE-TIME:{}'.format(STAMPDATE), end='\r\n') + print('UID:Pygenda-{:08d}'.format(uid), end='\r\n') + print('RRULE:FREQ=YEARLY;BYDAY=2MO;BYMONTH=10', end='\r\n') + print('END:VEVENT', end='\r\n') + uid += 1 + + +print('BEGIN:VCALENDAR', end='\r\n') +print('VERSION:2.0', end='\r\n') +print('PRODID:-//Semiprime//PygendaTest//EN', end='\r\n') + +Jan01 = dt_date(YEAR, 1, 1) +day_offset = Jan01.weekday() # 0=Mon, 1=Tue... +Mar01 = dt_date(YEAR, 3, 1) +day_offset2 = Mar01.weekday() # 0=Mon, 1=Tue... + +# +# Anniversaries and yearly events +# +print_vevent('New Year', '0001-01-01', repeat='YEARLY', daycount=1) +print_vevent('Christmas!', '0001-12-25', repeat='YEARLY', daycount=1) +print_vevent('Christmas Eve', '0001-12-24', repeat='YEARLY', daycount=1) +print_vevent('Boxing Day', '0001-12-26', repeat='YEARLY', daycount=1) +print_vevent('Bonfire Night', '2000-11-05', repeat='YEARLY', daycount=1) +print_vevent('Halloween', '2000-10-31', repeat='YEARLY', daycount=1) +print_vevent('Valentine\'s Day', '2000-02-14', repeat='YEARLY', daycount=1) +print_vevent('Armistice Day', '1918-11-11', repeat='YEARLY', daycount=1) +print_vevent('May Day', '2000-05-01', repeat='YEARLY', daycount=1) +print_vevent('April Fools\' Day', '2000-04-01', repeat='YEARLY', daycount=1) +print_vevent('Burns\' Night', '1759-01-25', repeat='YEARLY', daycount=1) +print_vevent('St Patrick\'s Day', '2000-03-17', repeat='YEARLY', daycount=1) +print_vevent('Winter Solstice', '0001-12-21', repeat='YEARLY', daycount=1) +print_vevent('Summer Solstice', '0001-06-21', repeat='YEARLY', daycount=1) +print_vevent('New Year\'s Eve', '0001-12-31', repeat='YEARLY', daycount=1) +print_vevent('Holocaust Memorial Day', '1945-01-27', repeat='YEARLY', daycount=1) +print_vevent('International Women\'s Day', '1977-03-08', repeat='YEARLY', daycount=1) +print_vevent('International Men\'s Day', '1999-11-19', repeat='YEARLY', daycount=1) +print_vevent('Perseids meteor shower', '2000-08-12', repeat='YEARLY') +print_vevent('Leonids meteor shower', '2000-11-17', repeat='YEARLY') +print_vevent('Beethoven\'s birthday', '1770-12-16', repeat='YEARLY', daycount=1) + +print_daylight_saving_changes() +print_thanksgiving() + +# Work events +day_back = 11-(day_offset+3)%7 # first Mon after 4th Jan +print_vevent('Back to work', '{:04d}-01-{:02d}'.format(YEAR,day_back)) +print_vevent('Team meeting', '{:04d}-01-{:02d}'.format(YEAR,day_back), time='10:30', repeat='WEEKLY') +print_vevent('Farrier', '{:04d}-01-{:02d}'.format(YEAR,day_back+3), time='19:00') +print_vevent('Presentation to Sophie & team', '{:04d}-02-{:02d}'.format(YEAR,day_back+7), time='14:00') +print_vevent('Meeting with Steve (Marketing)', '{:04d}-02-{:02d}'.format(YEAR,1 if day_offset not in (2,3) else 5-day_offset), time='14:30') +print_vevent('Funding deadline', '{:04d}-03-{:02d}'.format(YEAR, 2 if day_offset2 not in (2,3) else 9-day_offset2)) +print_vevent('Last day (half day)', '{:04d}-12-{:02}'.format(YEAR,23 if day_offset2 not in (2,3) else 24-day_offset2)) # last weekday before 24th +print_vevent('Visit from Imran (Manufacturing)', '{:04d}-03-{:02d}'.format(YEAR,1 if day_offset not in (2,3) else 5-day_offset), time='10:00', status='cancelled') + +# Birthdays (fictional!) +print_vevent('Dad\'s birthday', '1953-04-02', repeat='YEARLY', daycount=1) +print_vevent('Mum\'s birthday', '1955-07-12', repeat='YEARLY', daycount=1) +print_vevent('Grandma\'s birthday', '1930-11-29', repeat='YEARLY', daycount=1) +print_vevent('J\'s birthday', '1980-09-17', repeat='YEARLY', daycount=1) +print_vevent('Mo\'s birthday', '1979-02-16', repeat='YEARLY', daycount=1) +print_vevent('Matt P\'s birthday', '1980-03-22', repeat='YEARLY', daycount=1) +print_vevent('Matt B\'s birthday', '1982-10-29', repeat='YEARLY', daycount=1) +print_vevent('Nila\'s birthday', '1983-01-25', repeat='YEARLY', daycount=1) +print_vevent('Antoine\'s birthday', '1983-05-04', repeat='YEARLY', daycount=1) +print_vevent('The twins\' birthday', '2012-06-01', repeat='YEARLY', daycount=1) + +# Easter etc +EASTER_DATES = ('1990-04-15','1991-03-31','1992-04-19','1993-04-11','1994-04-03','1995-04-16','1996-04-07','1997-03-30','1998-04-12','1999-04-04','2000-04-23','2001-04-15','2002-03-31','2003-04-20','2004-04-11','2005-03-27','2006-04-16','2007-04-08','2008-03-23','2009-04-12','2010-04-04','2011-04-24','2012-04-08','2013-03-31','2014-04-20','2015-04-05','2016-03-27','2017-04-16','2018-04-01','2019-04-21','2020-04-12','2021-04-04','2022-04-17','2023-04-09','2024-03-31','2025-04-20','2026-04-05','2027-03-28','2028-04-16','2029-04-01','2030-04-21','2031-04-13','2032-03-28','2033-04-17','2034-04-09','2035-03-25','2036-04-13','2037-04-05','2038-04-25','2039-04-10','2040-04-01','2041-04-21','2042-04-06','2043-03-29','2044-04-17','2045-04-09','2046-03-25','2047-04-14','2048-04-05','2049-04-18','2050-04-10') +for easter_date in EASTER_DATES: + edt = datetime.strptime(easter_date,'%Y-%m-%d').date() + print_vevent('Good Friday', edt-timedelta(days=2), daycount=1) + print_vevent('Easter', edt, daycount=1) + print_vevent('Easter Monday', edt+timedelta(days=1), daycount=1) + print_vevent('Shrove Tuesday', edt-timedelta(days=47), daycount=1) + +# Full moon (note dates given here are for UTC, so don't use outside of testing) +FULLMOON_DATES =( + '2020-01-10','2020-02-09','2020-03-09','2020-04-08','2020-05-07','2020-06-05','2020-07-05','2020-08-03','2020-09-02','2020-10-01','2020-10-31','2020-11-30','2020-12-30', + '2021-01-28','2021-02-27','2021-03-28','2021-04-27','2021-05-26','2021-06-24','2021-07-24','2021-08-22','2021-09-20','2021-10-20','2021-11-19','2021-12-19', + '2022-01-17','2022-02-16','2022-03-18','2022-04-16','2022-05-16','2022-06-14','2022-07-13','2022-08-12','2022-09-10','2022-10-09','2022-11-08','2022-12-08', + '2023-01-06','2023-02-05','2023-03-07','2023-04-06','2023-05-05','2023-06-04','2023-07-03','2023-08-01','2023-08-31','2023-09-29','2023-10-28','2023-11-27','2023-12-27', + '2024-01-25','2024-02-24','2024-03-25','2024-04-23','2024-05-23','2024-06-22','2024-07-21','2024-08-19','2024-09-18','2024-10-17','2024-11-15','2024-12-15', + '2025-01-13','2025-02-12','2025-03-14','2025-04-13','2025-05-12','2025-06-11','2025-07-10','2025-08-09','2025-09-07','2025-10-07','2025-11-05','2025-12-04', +) +for fm_date in FULLMOON_DATES: + print_vevent('Full moon', fm_date) + +# Social & personal events +first_wed = 12-(day_offset+1)%7 # first Wed after 2nd Jan +print_vevent('Guitar lesson', '{:04d}-01-{:02d}'.format(YEAR, first_wed), time='19:00', repeat='FORTNIGHTLY') +print_vevent('Dentist', '{:04d}-03-{:02d}'.format(YEAR, 1 if day_offset2<5 else 8-day_offset2), time='9:00') +print_vevent('Merseyside Derby', '{:04d}-02-{:02d}'.format(YEAR, 7-day_offset2), time='14:00') +print_vevent('Party at Mo+Soph\'s', '{:04d}-02-{:02d}'.format(YEAR, 20-day_offset2 if day_offset2<5 else 27-day_offset2), time='20:00') +print_vevent('Mum+Dad visit', '{:04d}-{:02d}-{:02d}'.format(YEAR, 3 if day_offset2==6 else 2, 27-day_offset2 if day_offset2<5 else (28 if day_offset2==6 else 1))) +print_vevent('Romeo & Juliet', '{:04d}-03-11'.format(YEAR), time='19:00') +print_vevent('Take car for MOT', '{:04d}-03-17'.format(YEAR), time='10:00') +print_vevent('New bed delivered', '{:04d}-02-01'.format(YEAR), time='9:00',endtime='12:00') +print_vevent('Thai with Jay+Rich?', '{:04d}-{:02d}-{:02d}'.format(YEAR, 3, 14),status='tentative') + +# Fictional holiday +print_vevent('Fly to Barcelona', '{:04d}-07-{:02d}'.format(YEAR, 24-day_offset2)) +print_vevent('Off work', '{:04d}-07-{:02d}'.format(YEAR, 23-day_offset2), daycount=15) +print_vevent('Back to UK', '{:04d}-{:02d}-{:02d}'.format(YEAR, 8 if day_offset2<5 else 7, (5 if day_offset2<5 else 36)-day_offset2)) +print_vevent('Spanish class', '{:04d}-05-{:02d}'.format(YEAR, 12-day_offset2), time='19:30', repeat='WEEKLY', repeat_count=11) + +print('END:VCALENDAR', end='\r\n') diff --git a/test/maketest_large.py b/test/maketest_large.py new file mode 100755 index 0000000..a2d0d3b --- /dev/null +++ b/test/maketest_large.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Generate test file for Pygenda +# +# Copyright (C) 2022 Matthew Lewis +# +# This file is part of Pygenda. +# +# Pygenda 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, version 3. +# +# Pygenda 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 Pygenda. If not, see . +# + +print('BEGIN:VCALENDAR', end='\r\n') +print('VERSION:2.0', end='\r\n') +print('PRODID:-//Semiprime//PygendaTest//EN', end='\r\n') + +for x in range(50000): + print('BEGIN:VEVENT', end='\r\n') + print('UID:Pygenda-{:08d}'.format(x), end='\r\n') + print('DTSTAMP:20220101T000000Z', end='\r\n') + print('SUMMARY:Test event {:d}'.format(x), end='\r\n') + print('DTSTART:{:04d}{:02d}{:02d}'.format(2025-x//100, 11-(x//10)%10, 20-x%10), end='\r\n') + #print('DURATION:PT30M', end='\r\n') + print('END:VEVENT', end='\r\n') +print('END:VCALENDAR', end='\r\n') diff --git a/test/test00_empty.ics b/test/test00_empty.ics new file mode 100644 index 0000000..7ffba15 --- /dev/null +++ b/test/test00_empty.ics @@ -0,0 +1,4 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Semiprime//PygendaTest//EN +END:VCALENDAR diff --git a/test/test01_small.ics b/test/test01_small.ics new file mode 100644 index 0000000..89818e1 --- /dev/null +++ b/test/test01_small.ics @@ -0,0 +1,43 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Semiprime//PygendaTest//EN +BEGIN:VEVENT +UID:Pygenda-test01-012345 +DTSTAMP:20200101T000000 +CREATED:20200101T000000 +LAST-MODIFIED:20200101T000000 +SUMMARY:Test event +DTSTART:20201112T093000 +DURATION:PT30M +END:VEVENT +BEGIN:VEVENT +UID:Pygenda-test01-012346 +DTSTAMP:20200101T000000 +CREATED:20200101T000000 +LAST-MODIFIED:20200101T000000 +SUMMARY:Test event 2 +DTSTART;VALUE=DATE:20201021 +DTEND;VALUE=DATE:20201112 +END:VEVENT +BEGIN:VEVENT +UID:Pygenda-test01-012347 +DTSTAMP:20200101T000000 +CREATED:20200101T000000 +LAST-MODIFIED:20200101T000000 +SUMMARY:Test event 3 +DTSTART;VALUE=DATE:20201112 +END:VEVENT +BEGIN:VEVENT +UID:Pygenda-test01-012348 +DTSTAMP:20200101T000000 +CREATED:20200101T000000 +LAST-MODIFIED:20200101T000000 +SUMMARY:Test event 4 +DTSTART:20201021T090000 +DURATION:PT30M +BEGIN:VALARM +TRIGGER:-PT15M +ACTION:AUDIO +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/test/test02_repeats.ics b/test/test02_repeats.ics new file mode 100644 index 0000000..19be5b2 --- /dev/null +++ b/test/test02_repeats.ics @@ -0,0 +1,484 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Semiprime//PygendaTest//EN +BEGIN:VEVENT +SUMMARY:Repeat: 10th day of month +DTSTART:20200110T100000 +DTEND:20200110T110000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123456 +RRULE:FREQ=MONTHLY;BYMONTHDAY=10 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat weekly with no BYDAY +DTSTART:20200108T100000 +DTEND:20200108T110000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123457 +RRULE:FREQ=WEEKLY +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat fortnightly\, Mon\, Fri\, Start Fri\, 4 occ +DTSTART;VALUE=DATE:20200207 +DTEND;VALUE=DATE:20200208 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123458 +RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=MO,FR +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat fortnightly\, Mon\, Fri\, Start Mon\, 4 occ\, week-start Th + urs +DTSTART;VALUE=DATE:20200309 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123459 +RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=MO,FR;WKST=TH +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat fortnightly\, Sat\, Sun\, 5 occurrences +DTSTART:20200118T000000 +DTEND:20200118T010000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123460 +RRULE:FREQ=WEEKLY;COUNT=5;INTERVAL=2;BYDAY=SA,SU +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: penultimate Sunday of month +DTSTART;VALUE=DATE:20200119 +DTEND;VALUE=DATE:20200120 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123461 +RRULE:FREQ=MONTHLY;BYDAY=-2SU +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: 1st Sat of month +DTSTART:20200104T130000 +DTEND:20200104T140000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123462 +RRULE:FREQ=MONTHLY;BYDAY=1SA +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: third Thurs of month (5 occs) +DTSTART;VALUE=DATE:20200116 +DTEND;VALUE=DATE:20200117 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123463 +RRULE:FREQ=MONTHLY;COUNT=5;BYDAY=3TH +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: 30th to last day of month\, every month (check Feb) +DTSTART:20200102T000000 +DTEND:20200102T010000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123464 +RRULE:FREQ=MONTHLY;BYMONTHDAY=-30 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Every fortnight on Tuesday +DTSTART:20200107T100000 +DTEND:20200107T110000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123465 +RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat weekly with exception (no 18Jan2020) +DTSTART:20200111T100000 +DTEND:20200111T110000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123466 +RRULE:FREQ=WEEKLY;BYDAY=SA +EXDATE;VALUE=DATE:20200118 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Annually\, New year +DTSTART;VALUE=DATE:00010101 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123467 +RRULE:FREQ=YEARLY;BYMONTHDAY=1;BYMONTH=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Wed/Thu every 3 weeks +DTSTART:20200108T100000 +DTEND:20200108T110000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123468 +RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=WE,TH +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Every three days\, 7 occurences +DTSTART:20200120T100000 +DTEND:20200120T110000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123469 +RRULE:FREQ=DAILY;COUNT=7;INTERVAL=3 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat 2nd Tue every other month for 1 year +DTSTART:20200114T000000 +DTEND:20200114T010000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123470 +RRULE:FREQ=MONTHLY;UNTIL=20210114T230000;INTERVAL=2;BYDAY=2TU +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat on 60th day of year +DTSTART:20200229T000000 +DTEND:20200229T010000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123471 +RRULE:FREQ=YEARLY;BYYEARDAY=60 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: 14th from last day\, every 5 months +DTSTART;VALUE=DATE:20200118 +DTEND;VALUE=DATE:20200119 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123472 +RRULE:FREQ=MONTHLY;INTERVAL=5;BYMONTHDAY=-14 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Multiple anniversaries to test for slowdown +DTSTART;VALUE=DATE:00011221 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123473 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:b +DTSTART;VALUE=DATE:00011221 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123474 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:c +DTSTART;VALUE=DATE:00011221 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123475 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:d +DTSTART;VALUE=DATE:00011221 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123476 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:e +DTSTART;VALUE=DATE:00011221 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123477 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:a +DTSTART;VALUE=DATE:00011222 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123478 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:b +DTSTART;VALUE=DATE:00011222 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123479 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:c +DTSTART;VALUE=DATE:00011222 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123480 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:d +DTSTART;VALUE=DATE:00011222 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123481 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:e +DTSTART;VALUE=DATE:00011222 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123482 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:a +DTSTART;VALUE=DATE:00011223 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123483 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:b +DTSTART;VALUE=DATE:00011223 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123484 +RRULE:FREQ=YEARLY;INTERVAL=2 +END:VEVENT +BEGIN:VEVENT +SUMMARY:c +DTSTART;VALUE=DATE:00011223 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123485 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:d +DTSTART;VALUE=DATE:00011223 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123486 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:e +DTSTART;VALUE=DATE:00011223 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123487 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:a +DTSTART;VALUE=DATE:00011224 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123488 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:b +DTSTART;VALUE=DATE:00011224 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123489 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:c +DTSTART;VALUE=DATE:00011224 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123490 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:d +DTSTART;VALUE=DATE:00011224 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123491 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:e +DTSTART;VALUE=DATE:00011224 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123492 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:a +DTSTART;VALUE=DATE:00011225 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123493 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:b +DTSTART;VALUE=DATE:00011225 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123494 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:c +DTSTART;VALUE=DATE:00011225 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123495 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:d +DTSTART;VALUE=DATE:00011225 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123496 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:e +DTSTART;VALUE=DATE:00011225 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123497 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:a +DTSTART;VALUE=DATE:00011226 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123498 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:b +DTSTART;VALUE=DATE:00011226 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123499 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:c +DTSTART;VALUE=DATE:00011226 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123500 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:d +DTSTART;VALUE=DATE:00011226 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123501 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:e +DTSTART;VALUE=DATE:00011226 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123502 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:a +DTSTART;VALUE=DATE:00011227 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123503 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:b +DTSTART;VALUE=DATE:00011227 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123504 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:c +DTSTART;VALUE=DATE:00011227 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123505 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:d +DTSTART;VALUE=DATE:00011227 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123506 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:e +DTSTART;VALUE=DATE:00011227 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123507 +RRULE:FREQ=YEARLY;INTERVAL=1 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat weekly 5occs\, skip April 29th +DTSTART;VALUE=DATE:20200408 +DTEND;VALUE=DATE:20200409 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123508 +RRULE:FREQ=WEEKLY;COUNT=5;BYDAY=WE +EXDATE;VALUE=DATE:20200429 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Weekly Tue\,Fri to May 26th\, no May 5th +DTSTART;VALUE=DATE:20200407 +DTEND;VALUE=DATE:20200408 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123509 +RRULE:FREQ=WEEKLY;UNTIL=20200526;BYDAY=TU,FR +EXDATE;VALUE=DATE:20200505 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat fortnightly\, Wed Thu\, 7 occs\, ex 11/24 Jun +DTSTART;VALUE=DATE:20200527 +DTEND;VALUE=DATE:20200528 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123510 +RRULE:FREQ=WEEKLY;COUNT=7;INTERVAL=2;BYDAY=WE,TH +EXDATE;VALUE=DATE:20200611 +EXDATE;VALUE=DATE:20200624 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Last Sunday of March +DTSTART;VALUE=DATE:20190331 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123511 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Penultimate working day of month +DTSTART;VALUE=DATE:20190227 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123512 +RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2 +END:VEVENT +BEGIN:VEVENT +SUMMARY:5th day of month\, BYMONTHDAY +DTSTART;VALUE=DATE:20190301 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123513 +RRULE:FREQ=MONTHLY;BYMONTHDAY=5 +END:VEVENT +BEGIN:VEVENT +SUMMARY:10th and 11th days of month +DTSTART;VALUE=DATE:20190201 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123514 +RRULE:FREQ=MONTHLY;BYMONTHDAY=10,11 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Every day\, 5 times\, 1 excl +DTSTART:20200225T160000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123515 +RRULE:FREQ=DAILY;COUNT=5 +EXDATE:20200227T160000 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Every day\, 5 times\, first excl +DTSTART;VALUE=DATE-TIME:20200303T190000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123516 +RRULE:FREQ=DAILY;COUNT=5 +EXDATE;VALUE=DATE:20200303 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Monthly\, forever\, first 3 excl +DTSTART;VALUE=DATE-TIME:20200313T220000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123517 +RRULE:FREQ=MONTHLY +EXDATE;VALUE=DATE:20200313 +EXDATE;VALUE=DATE:20200413 +EXDATE;VALUE=DATE:20200513 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Weekly\, multi-exclude +DTSTART;VALUE=DATE-TIME:20200320T230000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123518 +RRULE:FREQ=WEEKLY +EXDATE;VALUE=DATE:20200327,20200417,20200424 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Repeat: Weekly\, mixed-exclude +DTSTART;VALUE=DATE-TIME:20200321T230000 +DTSTAMP:20200101T000000 +UID:Pygenda-test02-0123519 +RRULE:FREQ=WEEKLY +EXDATE;VALUE=DATE:20200328,20200418 +EXDATE:20200425T230000 +END:VEVENT +END:VCALENDAR diff --git a/test/test03_errors.ics b/test/test03_errors.ics new file mode 100644 index 0000000..1d8791f --- /dev/null +++ b/test/test03_errors.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Semiprime//PygendaTest//EN +BEGIN:VEVENT +SUMMARY:Missing DTSTAMP +DTSTART;VALUE=DATE:20210104 +UID:Pygenda-test03-01234 +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20210105 +DTSTAMP:20210101T000000Z +UID:Pygenda-test03-01235 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Missing UID +DTSTART;VALUE=DATE:20210106 +DTSTAMP:20210101T000000Z +END:VEVENT +END:VCALENDAR