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