Merge commit '01b13a65484a01f314d6d97c74b48216ead2bc24'
authorPaul Hänsch <paul@plutz.net>
Wed, 10 Feb 2021 11:35:52 +0000 (12:35 +0100)
committerPaul Hänsch <paul@plutz.net>
Wed, 10 Feb 2021 11:35:52 +0000 (12:35 +0100)
37 files changed:
COPYING [new file with mode: 0644]
Makefile [new file with mode: 0644]
cards/edit_card.sh [new file with mode: 0755]
cards/export_card.sh [new file with mode: 0755]
cards/export_csv.sh [new file with mode: 0755]
cards/filter_card.sh [new file with mode: 0755]
cards/index.cgi [new file with mode: 0755]
cards/l10n.sh [new file with mode: 0755]
cards/list.sh [new file with mode: 0755]
cards/new_card.sh [new file with mode: 0755]
cards/update_card.sh [new file with mode: 0755]
cards/widgets.sh [new file with mode: 0755]
categories/edit_categories.sh [new file with mode: 0755]
categories/index.cgi [new file with mode: 0755]
categories/l10n.sh [new file with mode: 0755]
categories/update_categories.sh [new file with mode: 0755]
cgilite/cgilite.sh [moved from cgilite.sh with 100% similarity]
cgilite/common.css [moved from common.css with 100% similarity]
cgilite/file.sh [moved from file.sh with 100% similarity]
cgilite/html-sh.sed [moved from html-sh.sed with 100% similarity]
cgilite/logging.sh [moved from logging.sh with 100% similarity]
cgilite/session.sh [moved from session.sh with 100% similarity]
cgilite/storage.sh [moved from storage.sh with 100% similarity]
courses/edit_course.sh [new file with mode: 0755]
courses/export_ical.sh [new file with mode: 0755]
courses/export_pdf.sh [new file with mode: 0755]
courses/index.cgi [new file with mode: 0755]
courses/l10n.sh [new file with mode: 0755]
courses/list.sh [new file with mode: 0755]
courses/new_course.sh [new file with mode: 0755]
courses/update_course.sh [new file with mode: 0755]
courses/widgets.sh [new file with mode: 0755]
index.cgi [new file with mode: 0755]
l10n.sh [new file with mode: 0755]
pdiread.sh [new file with mode: 0755]
session_lock.sh [new file with mode: 0644]
style.css [new file with mode: 0644]

diff --git a/COPYING b/COPYING
new file mode 100644 (file)
index 0000000..dba13ed
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+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. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<http://www.gnu.org/licenses/>.
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..24781a9
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,9 @@
+.PHONY: _subtrees
+
+_subtrees: _cgilite
+
+cgilite:
+       git subtree add --squash -P $@ https://git.plutz.net/git/$@ master
+
+_cgilite: cgilite
+       git subtree pull --squash -P $< https://git.plutz.net/git/$< master
diff --git a/cards/edit_card.sh b/cards/edit_card.sh
new file mode 100755 (executable)
index 0000000..4239cc5
--- /dev/null
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+# Copyright 2019 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+locktimeout=900
+. "$_EXEC"/session_lock.sh
+
+card="$(GET card |PATH)"
+cardfile="$_DATA/vcard/${card##*/}"
+filter="$(REF f)"
+order="$(REF o)"
+
+if tempfile="$(SLOCK "$cardfile" "$locktimeout")"; then
+  REDIRECT "/cards/?o=${order}&f=${filter}&e=${card}"
+elif [ -f "$tempfile" ]; then
+  SET_COOKIE session message="SESSLOCK"
+  REDIRECT "/cards/?o=${order}&f=${filter}#${card}"
+else
+  SET_COOKIE session message="EDITLOCK"
+  REDIRECT "/cards/?o=${order}&f=${filter}#${card}"
+fi
diff --git a/cards/export_card.sh b/cards/export_card.sh
new file mode 100755 (executable)
index 0000000..0918032
--- /dev/null
@@ -0,0 +1,28 @@
+#!/bin/sh
+
+# Copyright 2014, 2015, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+card="$(GET card |PATH)"
+cardfile="$_DATA/vcard/${card##*/}"
+
+. $_EXEC/pdiread.sh
+. $_EXEC/cgilite/file.sh
+
+printf 'Content-Disposition: inline; filename="%s.vcf"\r\n' "$(pdi_value "$(pdi_load "$cardfile")" FN)"
+
+FILE "$cardfile" "text/vcard; charset=utf-8"
diff --git a/cards/export_csv.sh b/cards/export_csv.sh
new file mode 100755 (executable)
index 0000000..9ba8993
--- /dev/null
@@ -0,0 +1,66 @@
+#!/bin/sh
+
+. $_EXEC/pdiread.sh
+. $_EXEC/cards/l10n.sh
+. $_EXEC/cards/list.sh
+
+upcase=' y;abcdefghijklmnopqrstuvwxyzäöüé;ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜÉ;; '
+
+filter="$(GET f)"
+order="$(GET o)"
+
+[ "$order" ] || order=firstname
+
+list_attendance() {
+  grep -F "    ${cardfile##*/}" "$_DATA/mappings/attendance" |while read each discard; do
+    { pdi_value "$(pdi_load "$_DATA/ical/$each")" SUMMARY || l10n "(unnamed course)"; } |unescape
+  done \
+  | sed -E 's;";\\";g;'
+}
+
+list_item() {
+  local item="$1"
+  local cnt="$(pdi_count "$card" "$item")"
+  local ret=''
+  
+  seq 1 $cnt |while read n; do case $item in
+    TEL)
+      tel="$(pdi_value "$card" "$item" "$n" |unescape)"
+      ttype="$(pdi_attrib "$card" "$item" "$n" TYPE)"
+      if [ "$tel" -a "$ttype" ]; then 
+        printf '%s: %s\n' "$(l10n "TYPE=$ttype")" "$tel"
+      elif [ "$tel" ]; then
+        printf '%s\n' "$tel"
+      fi
+      ;;
+    GENDER)
+      gen="$(pdi_value "$card" "$item" "$n" |unescape)"
+      [ "$gen" ] && l10n "gender_$gen"
+      ;;
+    *) pdi_value "$card" "$item" "$n" |unescape
+      ;;
+  esac; done \
+  | sed -E 's;";\\";g;'
+}
+
+printf '%s\r\n' \
+       'Content-Type: text/csv; charset=utf-8' \
+       'Content-Disposition: inline; filename="confetti_export_'$(date +%F_%T)'.csv"' \
+       ''
+
+printf '"%s";"%s";"%s";"%s";"%s";"%s";"%s";"%s";"%s"\n' \
+       "$(l10n FN)" "$(l10n GENDER)" "$(l10n BDAY)" \
+       "$(l10n TEL)" "$(l10n EMAIL)" "$(l10n ADR)" \
+       "$(l10n NOTE)" "$(l10n courses)" "$(l10n CATEGORIES)" \
+| sed -E 's;&shy\;;;g;'
+
+
+filter_cards \
+| order_cards \
+| while read cardfile; do
+  card="$(pdi_load "$cardfile")"
+  printf '"%s";"%s";"%s";"%s";"%s";"%s";"%s";"%s";"%s"\n' \
+  "$(list_item FN)" "$(list_item GENDER)" "$(list_item BDAY)" \
+  "$(list_item TEL)" "$(list_item EMAIL)" "$(list_item ADR)" \
+  "$(list_item NOTE)" "$(list_attendance)" "$(list_item CATEGORIES)"
+done
diff --git a/cards/filter_card.sh b/cards/filter_card.sh
new file mode 100755 (executable)
index 0000000..aacacbb
--- /dev/null
@@ -0,0 +1,58 @@
+#!/bin/sh
+
+# Copyright 2014, 2017, 2019 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+filter="$(
+  seq 0 100 |while read n; do
+    filter_type="$(POST "filter_type${n}")"
+    filter_text="$(POST "filter_text${n}")"
+    if [ ! "$filter_type" -a ! "$filter_text"  ]; then
+      break
+    elif [ "$filter_type" = CATEGORIES ]; then
+      printf '^CATEGORIES:'
+      seq 0 $(POST_COUNT filter_cat$n) |while read m; do
+        printf '|%s' "$(POST filter_cat$n $m)"
+      done
+    elif [ "$filter_type" = course ]; then
+      printf '^course:'
+      seq 0 $(POST_COUNT filter_course$n) |while read m; do
+        printf '|%s' "$(POST filter_course$n $m)"
+      done
+    else
+      printf '^%s:%s' "$filter_type" "$filter_text"
+    fi
+  done | sed -E \
+         's;\|+;\|;g;   s;\^+;\^;g;   s;:\|;:;g;
+          :X;   s;\^[^:]*:\^;\^;g;   /\^[^:]*:\^/bX;
+          s;^\^;;;   s;\^[^:]*:$;;;'
+)"
+
+case $(POST choice) in
+  filter)
+    REDIRECT "/cards/?o=$(POST order)&f=${filter}"
+    ;;
+  new_filter)
+    REDIRECT "/cards/?o=$(POST order)&f=${filter}&newfilter=yes"
+    ;;
+  export_csv)
+    REDIRECT "/cards/export_csv.sh?o=$(POST order)&f=${filter}"
+    ;;
+  *)
+    REDIRECT '/cards/'
+    ;;
+esac
diff --git a/cards/index.cgi b/cards/index.cgi
new file mode 100755 (executable)
index 0000000..934c19a
--- /dev/null
@@ -0,0 +1,25 @@
+#!/bin/sh
+
+. $_EXEC/pdiread.sh
+. $_EXEC/cards/l10n.sh
+. $_EXEC/cards/widgets.sh
+. $_EXEC/cards/list.sh
+
+upcase=' y;abcdefghijklmnopqrstuvwxyzäöüé;ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜÉ;; '
+
+filter="$(GET f)"
+order="$(GET o)"
+edit="$(GET e |PATH)"
+
+[ "$order" ] || order=firstname
+edit="${edit##*/}"
+
+{ w_filter_diag
+  printf '
+  [form class="newcard" action="/cards/new_card.sh" method="POST"
+    [button type="submit" %s]
+    [input name="seed" placeholder="%s"]
+  ]' "$(l10n newcard)" "$(l10n vcf_seed_label)"
+  [ "$edit" ] && edit_card "$edit"
+  list_cards
+} | yield_page cards #/cards/cards.css
diff --git a/cards/l10n.sh b/cards/l10n.sh
new file mode 100755 (executable)
index 0000000..2d9dc06
--- /dev/null
@@ -0,0 +1,63 @@
+# Copyright 2014, 2016, 2019, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+l10n(){
+  local word
+  [ $# -eq 0 ] && read -r word || word="$*"
+  case $word in
+    newcard) printf %s "Neuen Eintrag anlegen";;
+
+    X-HEALTH-INSURANCE) printf %s "Kran&shy;ken&shy;ver&shy;sich&shy;er&shy;ung";;
+    hi_from_list) printf %s "Aus Liste";;
+    hi_other) printf %s "Andere";;
+    hi_company) printf %s "Ver&shy;sich&shy;er&shy;ungs&shy;ge&shy;sell&shy;schaft";;
+    hi_number) printf %s "Ver&shy;sich&shy;er&shy;ten&shy;num&shy;mer";;
+    hi_status) printf %s "Ver&shy;sich&shy;er&shy;ten&shy;sta&shy;tus";;
+    X-HEALTH-INSURANCE-NOCONTRIB) printf %s "Zu&shy;zahl&shy;ungs&shy;be&shy;frei&shy;ung";;
+    X-CLIENT-REFERRAL) printf %s "Empfehl&shy;ung durch";;
+    prescriptions) printf %s "Verord&shy;nungen";;
+    new_prescription) printf %s "Neue Verord&shy;nung";;
+    no_icd) printf %s "Kein ICD Code";;
+  
+    X-ZACK-JOINDATE)  printf %s "Anmelde&shy;datum";;
+    X-ZACK-LEAVEDATE) printf %s "Abmelde&shy;datum";;
+    X-ZACK-JOINDATE_short)  printf %s "Anm.";;
+    X-ZACK-LEAVEDATE_short) printf %s "Abm.";;
+
+    *) l10n_global "$word";;
+  esac
+}
+
+# BEGIN) printf %s "";;
+# CALADRURI) printf %s "";;
+# CALURI) printf %s "";;
+# CLASS) printf %s "";;
+# CLIENTPIDMAP) printf %s "";;
+# END) printf %s "";;
+# FBURL) printf %s "";;
+# GEO) printf %s "";;
+# MAILER) printf %s "";;
+# NAME) printf %s "";;
+# PRODID) printf %s "";;
+# PROFILE) printf %s "";;
+# REV) printf %s "";;
+# SORT-STRING) printf %s "";;
+# SOURCE) printf %s "";;
+# TZ) printf %s "";;
+# UID) printf %s "";;
+# VERSION) printf %s "";;
+# XML) printf %s "";;
diff --git a/cards/list.sh b/cards/list.sh
new file mode 100755 (executable)
index 0000000..cd0d21f
--- /dev/null
@@ -0,0 +1,211 @@
+#!/bin/sh
+
+. "${_EXEC}"/pdiread.sh
+
+edit_card(){
+  local cardfile="$_DATA/vcard/$1" 
+  local tempfile card
+
+  . $_EXEC/session_lock.sh
+
+  if ! tempfile="$(CHECK_SLOCK "$cardfile")"; then
+    printf '[div .message %s]' "$(l10n "This card is not set up for editing within this session.")"
+  else
+    card="$(pdi_load "$tempfile")"
+    cat <<-EOF
+       [form .card #${cardfile##*/} action="/cards/update_card.sh" method="POST"
+         [input type="hidden" name="tid" value="$(transid ${tempfile})"]
+         [div .section .basic $(
+           edit_item "$card" N GENDER
+           [ "$(pdi_count "$card" NICKNAME)" -gt 0 ] \
+           && edit_item "$card" NICKNAME
+           edit_item "$card" BDAY
+           edit_item "$card" X-ZACK-JOINDATE
+           [ "$(pdi_count "$card" X-ZACK-LEAVEDATE)" -gt 0 ] \
+           && edit_item "$card" X-ZACK-LEAVEDATE
+           card_item "$card" SOUND PHOTO LOGO
+         )]
+         [div .section .phone   $(edit_item "$card" TEL)]
+         [div .section .message $(
+           edit_item "$card" EMAIL
+           [ $(pdi_count "$card" IMPP) -gt 0 ] && edit_item "$card" IMPP
+           [ $(pdi_count "$card" URL ) -gt 0 ] && edit_item "$card" URL
+         )]
+         [div .section .address $(edit_item "$card" ADR)]
+         [div .section .note    $(edit_item "$card" NOTE)]
+         [div .section .attendance
+           [h3 $(l10n course_attendance) ] $(
+           for course in "$_DATA"/ical/*.ics; do
+             printf '[label [input type="checkbox" name="attendance" value="%s" %s] %s]' \
+                    "${course##*/}" \
+                    "$(grep -qF "${course##*/} ${cardfile##*/}" "$_DATA/mappings/attendance" \
+                       && printf 'checked="checked"'
+                      )" \
+                    "$(pdi_value "$(pdi_load "$course")" SUMMARY || l10n "(unnamed course)" |unescape |HTML)"
+           done)
+           [h3 $(l10n CATEGORIES) ] $(
+           grep -xE '[^ ]+' "$_DATA"/mappings/categories |while read -r cat; do
+             printf '[label [input type="checkbox" name="CATEGORIES" value="%s" %s] %s]' \
+                    "$(HTML "$cat")" \
+                    "$(seq 1 $(pdi_count "$card" CATEGORIES) |while read c; do
+                      pdi_value "$card" CATEGORIES $c |grep -qxF "$cat" \
+                      && printf 'checked="checked"' && break
+                    done)" \
+                    "$(HTML "$cat")"
+           done)
+         ]
+         [div .control
+           [div .item .delete label="$(l10n edit_delete)"
+              [input type="checkbox" #delete]
+              [label for="delete" $(l10n edit_delete)]
+             [button type="submit" name="action" value="delete" $(l10n edit_delete)]
+            ]
+            [div .item .newfield
+              [select name="newfield"
+               [option value="" disabled="disabled" selected="selected" $(l10n edit_addfieldtext)]
+               $(for f in NICKNAME EMAIL TEL IMPP ADR URL NOTE; do
+                 printf '[option value="%s" %s] ' "$f" "$(l10n "$f")"
+               done)
+             ][button type="submit" name="action" value="addfield" $(l10n edit_addfield)]
+            ]
+           [button .item type="submit" name="action" value="update"   $(l10n edit_update)]
+           [button .item type="submit" name="action" value="cancel"   $(l10n edit_cancel)]
+         ]
+         [input type="hidden" name="UID" value="$(pdi_value "$card" UID |HTML)"]
+         [input type="hidden" name="card" value="${cardfile##*/}"]
+       ]
+       EOF
+  fi
+}
+
+print_card(){
+  local cardfile="$1"
+  local card="$(pdi_load "$cardfile")"
+  cat <<-EOF
+    [div .card #${cardfile##*/}
+      [div .section .basic . $(
+        card_item "$card" FN GENDER NICKNAME BDAY X-ZACK-JOINDATE X-ZACK-LEAVEDATE SOUND PHOTO LOGO
+      )]
+      [div .section .phone   . $(card_item "$card" TEL)]
+      [div .section .message . $(card_item "$card" EMAIL IMPP URL)]
+      [div .section .address . $(card_item "$card" ADR)]
+      [div .section .note    . $(card_item "$card" NOTE)]
+      [div .section .attendance [h3 $(l10n course_attendance) ] [ul
+        $(grep -F "    ${cardfile##*/}" "$_DATA/mappings/attendance" |while read each discard; do
+          printf '[li [a .item .attendance href="/courses#%s" . %s]]' \
+                 "$each" \
+                 "$(pdi_value "$(pdi_load "$_DATA/ical/$each")" SUMMARY || l10n "(unnamed course)" |unescape |HTML)"
+        done)]
+        $(card_item "$card" CATEGORIES)
+      ]
+      [div .control
+        [a .item href="/cards/edit_card.sh?card=${cardfile##*/}" $(l10n edit)]
+        [a .item href="/cards/export_card.sh?card=${cardfile##*/}" $(l10n vcf_export)]
+      ]
+    ]
+       EOF
+}
+
+print_cards(){
+  local cardfile cachefile date size name ldate=0 lsize lname
+
+  while read cardfile; do
+    cachefile="${_DATA}/cache/${cardfile##*/}.cache"
+    # if [ -s "$cachefile" -a "$cachefile" -nt "$cardfile" \
+    #                      -a "$cachefile" -nt "${_EXEC}/cards" ]; then
+    if [ -s "$cachefile" -a "$cachefile" -nt "$cardfile" ]; then
+      cat "$cachefile"
+    else
+      print_card "$cardfile" |tee "$cachefile"
+    fi
+  done
+}
+
+filter_attendance(){
+  fatt="$1"
+  attfile="$_DATA/mappings/attendance"
+
+  if [ ! "$fatt" ]; then
+    # debug 'list all'
+    printf '%s\n' "$_DATA/vcard"/*.vcf
+  elif [ "${fatt#* }" = "${fatt}" ]; then
+    # debug "list $fatt"
+    grep -xiE "(${fatt})       .+vcf" "$attfile" \
+    | while read vcf; do
+      printf '%s/vcard/%s\n' "$_DATA" "${vcf##*        }"
+    done
+  else
+    # debug "filter ${fatt%% *}"
+    filter_attendance "${fatt#* }" \
+    | while read vcf; do
+      grep -xiE "(${fatt%% *}) ${vcf##*/}" "$attfile"
+    done \
+    | while read vcf; do
+      printf '%s/vcard/%s\n' "$_DATA" "${vcf##*        }"
+    done
+  fi
+}
+
+filter_cards(){
+  local filter f fex='x;p;'
+
+  filter="$(printf %s "${filter}" \
+            | sed -E 's;[]\/\(\)\\\$\?\.\+\*\;\[\{\}];\\&;g;
+                      '"$upcase"
+           )^"
+
+  while [ "$filter" ]; do
+    f="${filter%%^*}" filter="${filter#*^}"
+    case $f in
+      '') break
+        ;;
+      COURSE:*) fatt="${fatt}${fatt:+ }${f#*:}"
+        ;;
+      ANY:*) fex="/\n.*(\;[^:]*)?:[^\n]*(${f#*:})[^\n]*\r?\n/{${fex}}"
+        ;;
+      NAME:*) fex="/\n(N|FN|NICKNAME)(\;[^:]*)?:[^\n]*(${f#*:})[^\n]*\r?\n/{${fex}}"
+        ;;
+      STREET:*|ZIP:*) fex="/\nADR(\;[^:]*)?:[^\n]*(${f#*:})[^\n]*\r?\n/{${fex}}"
+        ;;
+      *) fex="/\n${f%%:*}(\;[^:]*)?:[^\n]*(${f#*:})[^\n]*\r?\n/{${fex}}"
+        ;;
+    esac
+  done
+
+  # for cardfile in "${_DATA}"/vcard/*.vcf; do
+  filter_attendance "$fatt" |while read cardfile; do
+    printf '%s\n' "$cardfile"
+    cat "$cardfile"
+  done \
+  | sed -nE ':X; /\nEND\;?:VCARD\r?$/!{ N; bX; }; h; s;\n.*$;;; x; s;^[^\n]+\n;;;
+             '"$upcase""$fex"
+}
+
+order_cards() {
+  local cardfile card
+
+  while read cardfile; do
+    card="$(pdi_load "$cardfile")"
+
+    case $order in
+      firstname)
+        printf '%s     %s\n' "$(pdi_value "$card" FN)" "$cardfile"
+        ;;
+      lastname)
+        printf '%s     %s\n' "$(pdi_value "$card" N || pdi_value "$card" FN)" "$cardfile"
+        ;;
+      bdate)
+        printf '%s     %s\n' "$(pdi_value "$card" BDAY || printf 0000-00-00)" "$cardfile"
+        ;;
+    esac
+  done \
+  | sort \
+  | sed -E 's;^.*\t;;g'
+}
+
+list_cards(){
+  filter_cards \
+  | order_cards \
+  | grep -xvF "$edit" \
+  | print_cards
+}
diff --git a/cards/new_card.sh b/cards/new_card.sh
new file mode 100755 (executable)
index 0000000..0273a2c
--- /dev/null
@@ -0,0 +1,74 @@
+#!/bin/sh
+
+# Copyright 2014, 2019 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+filter="$(REF f)"
+order="$(REF o)"
+
+uid="$(timeid)$(randomid)"  # 32 Octets UID, starting with timestamp
+card="${uid}.vcf"
+
+vcf_escape(){
+  for each in "$@"; do
+    printf %s\\n "$each" \
+    | sed -E ':X;$!{N;bX}; s;\r\n;\n;g; s;([;,\\]);\\\1;g; s;\n;\\n;g;'
+  done \
+  | sed -E ':X;$!{N;bX}; s;\n;\;;g'
+}
+
+IFS='|' read -r date fn ln bmonth byear tel tcell junk1 email junk2 note <<-EOF
+       $(POST seed |tr \\t \|)
+       EOF
+
+[ ${#byear} = 1 ] && byear="200$byear"
+[ ${#byear} = 2 ] && byear="20$byear"
+[ ${#bmonth} = 1 ] && bmonth="0$bmonth"
+
+mn=""
+case $fn in
+  *\ *)
+    mn="${fn#* }"
+    fn="${fn%% *}"
+    ;;
+esac
+
+mkdir -p "${_DATA}/lock/vcard/"
+lockdir="${_DATA}/lock/vcard/${card}/"
+lockfile=${lockdir}/${SESSION_ID}
+
+if mkdir "$lockdir"; then
+  cat >"$lockfile" <<-EOF
+       BEGIN:VCARD
+       VERSION:4.0
+       N:$(vcf_escape "$ln" "$fn" "$mn" "" "")
+       FN:$(vcf_escape "${fn}${mn:+ }${mn} ${ln}")
+       BDAY:$(parse_date "${byear}-${bmonth}-01")
+       TEL:$(vcf_escape "$tel")
+       TEL;TYPE=CELL:$(vcf_escape "$tcell")
+       EMAIL:$(vcf_escape "$email")
+       X-ZACK-JOINDATE:$(parse_date "$date")
+       ADR:
+       NOTE:$(vcf_escape "$note")
+       UID:${uid}
+       END:VCARD
+       EOF
+  REDIRECT "/cards/?o=${order}&f=${filter}&e=${card}"
+else
+  SET_COOKIE session message="EDITLOCK"
+  REDIRECT "/cards/?o=${order}&f=${filter}"
+fi
diff --git a/cards/update_card.sh b/cards/update_card.sh
new file mode 100755 (executable)
index 0000000..d942e9a
--- /dev/null
@@ -0,0 +1,151 @@
+#!/bin/sh
+
+# Copyright 2014, 2016, 2019, 2020, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+. "$_EXEC/pdiread.sh"
+. "$_EXEC/session_lock.sh"
+. "$_EXEC/cgilite/storage.sh"
+
+unset filter order card action newfield
+unset cardfile attfile tempfile
+unset vcf field cnt delete_key
+
+filter="$(REF f)"
+order="$(REF o)"
+
+card="$(POST card |PATH)"; card="${card##*/}"
+cardfile="$_DATA/vcard/${card}"
+attfile="$_DATA/mappings/attendance"
+
+action="$(POST action)"
+newfield="$(POST newfield |grep -m 1 -xE '[A-Z][A-Z0-9-]*')"
+
+if printf '%s\n' "$action" |grep -qxE 'addfield [A-Z][A-Z0-9]*'; then
+  newfield="${action##* }"
+  action=addfield
+fi
+
+if ! tempfile=$(CHECK_SLOCK "$cardfile"); then
+  SET_COOKIE 0 message="NO VALID FILE LOCK"
+  REDIRECT "/cards/?o=${order}&f=${filter}&e=${card}"
+  exit 0
+elif [ "$(POST tid)" != "$(transid "$tempfile")" ]; then
+  SET_COOKIE 0 message="INVALID TRANSACTION ID"
+  REDIRECT "/cards/?o=${order}&f=${filter}&e=${card}"
+  exit 0
+fi
+
+vcf_escape(){
+  for each in "$@"; do
+    printf %s\\n "$each" \
+    | sed -E ':X;$!{N;bX}; s;\r\n;\n;g; s;([;,\\]);\\\1;g; s;\n;\\n;g;'
+  done \
+  | sed -E ':X;$!{N;bX}; s;\n;\;;g'
+}
+
+# [ "${_POST[hi_select]}" = "list" ] || _POST[hi_company]="${_POST[hi_other]}"
+# [ -n "${_POST[hi_company]}${_POST[hi_number]}${_POST[hi_status]}" ] \
+# && _POST[X-HEALTH-INSURANCE]="$(vcf_escape "${_POST[hi_company]}" "${_POST[hi_number]}" "${_POST[hi_status]}")"
+
+vcf="$(pdi_load "$tempfile")"
+
+n1="$(POST 1N)" n2="$(POST 2N)" n3="$(POST 3N)" n4="$(POST 4N)" n5="$(POST 5N)"
+# 3N (Middle Names) is not actually used
+n3="${n2#${n2%% *}}"
+
+vcf="$(pdi_update_value "$vcf"  N 1 "$(vcf_escape "$n1" "${n2%% *}" "${n3# }" "$n4" "$n5")")"
+vcf="$(pdi_update_value "$vcf" FN 1 "$(vcf_escape "$n4 $n2 $n1 $n5" |sed -E 's;(^ +| +$);;g; s; +; ;g;')")"
+vcf="$(printf '%s\n' "$vcf" |sed -E "/^CATEGORIES;[^:]*:.*$/d")"
+
+for field in $(POST_KEYS |grep -xE '[A-Z][A-Z0-9-]*'); do
+  for cnt in $(seq 1 $(POST_COUNT "$field")); do
+    case "$field" in
+      # (TEL)
+      #   printf '%s;TYPE=%s:%s\r\n' "${field}" "${_POST[phonetype${key#TEL}]}" "$(vcf_escape "$(POST "$field" "$cnt")")"
+      #   ;;
+      TEL)
+         vcf="$(pdi_update_attrib "$vcf" TEL $cnt TYPE="$(POST teltype $cnt |grep -Exm1 'HOME|WORK|CELL|FAX')")"
+         vcf="$(pdi_update_value "$vcf" "$field" "$cnt" "$(vcf_escape "$(POST "$field" "$cnt")")")"
+         ;;
+      *)
+         vcf="$(pdi_update_value "$vcf" "$field" "$cnt" "$(vcf_escape "$(POST "$field" "$cnt")")")"
+        ;;
+    esac
+done; done
+
+# delete fields, first mark for deletion using delete_key
+# this way the field enumeration is preserved during the process
+# finally filter marked lines
+delete_key="$(randomid)"
+for delete in $(POST_KEYS |grep -xE '[A-Z][A-Z0-9-]*_delete_[0-9]+'); do
+  f="${delete%%_*}"; c="${delete##*_}";
+  [ "$(POST "$delete")" = "true" ] && vcf="$(pdi_update_value "$vcf" "$f" "$c" "delete=${delete_key}")"
+done
+vcf="$(printf '%s\n' "$vcf" |sed -E "/^[^:]+:delete=${delete_key}\$/d")"
+
+if [ "$action" = addfield ]; then
+  vcf="$(pdi_update_value "$vcf" "$newfield" $(( $(pdi_count "$vcf" "$newfield") + 1 )) '')"
+fi
+printf '%s' "$vcf" |grep -vx '' >"$tempfile"
+
+case "$action" in
+  addfield)
+    REDIRECT "/cards/?o=${order}&f=${filter}&e=${card}"
+    ;;
+  update)
+    if LOCK "$attfile"; then
+      grep -F "        ${card}" "$attfile" |while read course junk; do
+        touch "$_DATA/ical/${course}"
+      done
+      sed -i -E "/^.+  ${card}\$/d" "$attfile"
+      seq 1 $(POST_COUNT attendance) |while read n; do
+        printf '%s     %s\n' "$(POST attendance $n)" "$card"
+      done >>"$attfile"
+      grep -F "        ${card}" "$attfile" |while read course junk; do
+        touch "$_DATA/ical/${course}"
+      done
+      RELEASE "$attfile"
+    else
+      SET_COOKIE 0 message="COULD NOT UPDATE COURSE MAPPINGS"
+    fi
+
+    cp "$tempfile" "$cardfile"
+    RELEASE_SLOCK "$cardfile"
+    REDIRECT "/cards/?o=${order}&f=${filter}#${card}"
+    ;;
+  cancel)
+    RELEASE_SLOCK "$cardfile"
+    [ -f "$cardfile" ] \
+    && REDIRECT "/cards/?o=${order}&f=${filter}#${card}" \
+    || REDIRECT "/cards/?o=${order}&f=${filter}"
+    ;;
+  delete)
+    rm "$cardfile"
+    RELEASE_SLOCK "$cardfile"
+    if LOCK "$attfile"; then
+      grep -F "        ${card}" "$attfile" |while read course junk; do
+        touch "$_DATA/ical/${course}"
+      done
+      sed -i -E "/^.+  ${card}\$/d" "$attfile"
+      RELEASE "$attfile"
+    else
+      SET_COOKIE 0 message="COULD NOT UPDATE COURSE MAPPINGS"
+    fi
+    REDIRECT "/cards/?o=${order}&f=${filter}"
+    ;;
+esac
diff --git a/cards/widgets.sh b/cards/widgets.sh
new file mode 100755 (executable)
index 0000000..463a648
--- /dev/null
@@ -0,0 +1,284 @@
+# Copyright 2014 - 2019, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+list_categories() {
+  grep -vxE '^[        ]*$' "${_DATA}/mappings/categories"
+}
+
+list_courses() {
+  local file name cachefile="${_DATA}/cache/courses.ui.cache"
+  if [ $cachefile -nt "${_DATA}/ical" ]; then
+    cat "$cachefile"
+  else
+    for file in "$_DATA/ical"/*.ics; do
+      name="$(pdi_value "$(pdi_load "$file")" SUMMARY |HTML)"
+      printf '%s       %s\n' "$file" "$name"
+    done \
+    | sort -k2 |tee "$cachefile"
+  fi
+}
+
+w_filter_item() {
+n=$3
+cat <<EOF
+  [fieldset .item
+    [legend $(l10n filter_item):]
+
+    $(for field in any name street zip TEL BDAY CATEGORIES course; do
+      printf '[input id="%s%i" type="radio" name="filter_type%i" value="%s" %s][label for="%s%i" %s ]' \
+              "$field" "$n" "$n" "$field" "$([ "$1" = "$field" ] && printf checked )" \
+              "$field" "$n" "$(l10n filter_$field)"
+    done)
+    [input type="text" name="filter_text$n" value="$([ "$1" = CATEGORIES -o "$1" = course ] || HTML "$2")" placeholder="$(l10n filter_placeholder)"]
+    [fieldset .categories
+      $(list_categories | while read cat; do
+          printf '[label [checkbox "filter_cat%i" "|%s" %s] %s ] ' \
+                 "$n" "$(HTML "$cat")" \
+                 "$(printf %s "$cat" |grep -qxEe "$2" && printf checked )" \
+                 "$(HTML "$cat")"
+      done)
+      [a href="/categories/" $(l10n edit_categories)]
+    ]
+    [fieldset .courses
+      $(list_courses | while read course coursename; do
+        printf '[label [checkbox "filter_course%i" "|%s" %s] %s ] ' \
+               "$n" "$(HTML "${course##*/}")" \
+               "$(printf %s "${course##*/}" |grep -qxEe "$2" && printf checked )" \
+               "$coursename"
+      done)
+    ]
+  ]
+EOF
+}
+
+w_filter_diag(){
+  cat <<EOF
+  [form .filter action="/cards/filter_card.sh" method="POST"
+    [h1 $(l10n filter_label)]
+    [input type="hidden" name="page" value="cards"]
+  
+    $(n=0; filter="${filter}^"
+      while [ "${filter#^}" ]; do
+        fil="${filter%%^*}" filter="${filter#*^}"
+        w_filter_item "${fil%%:*}" "${fil#*:}" $n
+        n=$((n + 1))
+      done
+      [ "$n" -eq 0 -o "$(GET newfilter)" ] && w_filter_item any '' $n
+    )
+    [button type="submit" name="choice" value="new_filter" $(l10n filter_more)]
+    [fieldset class="order"
+      [legend $(l10n filter_order):]
+      [label [radio "order" "firstname" $( [ "$order" = firstname ] && printf checked )] $(l10n filter_firstname)]
+      [label [radio "order" "lastname"  $( [ "$order" = lastname  ] && printf checked )] $(l10n filter_lastname)]
+      [label [radio "order" "bdate"     $( [ "$order" = bdate     ] && printf checked )] $(l10n filter_bdate)]
+    ]
+    [button type="submit" name="choice" value="filter" $(l10n filter_apply)]
+    [button type="submit" name="choice" value="del_filter" $(l10n filter_cancel)]
+    [button type="submit" name="choice" value="export_csv" $(l10n export_csv)]
+  ]
+EOF
+}
+
+# listcards |grep ${edit:+-v} "$edit" \
+# | while read card; do
+#   "${_EXEC}"/cgilite/html-sh.sed <<-ENDCARD
+#      [div #${card} .card
+#        $(view_card "$card")[!--
+#        --][div .control
+#          [a "?action=edit_card&card=${card}" .item $(l10n edit)]
+#          [a "?action=export_vcard&card=${card}".item $(l10n vcf_export)]
+#          ${profile_medical:+[a "?action=new_prescription&client=${card}" .item $(l10n new_prescription)]}
+#      ]]
+#      ENDCARD
+# done
+
+#!/bin/sh
+
+card_item(){
+  local card="$1"
+  local item cnt c
+  shift 1
+
+  for item in $@; do
+    cnt="$(pdi_count "$card" "$item")"
+
+    case $item in
+      FN) printf '[h2 .item .FN . %s]' "$(pdi_value "$card" FN |unescape |HTML)"
+        ;;
+      GENDER) printf '[span .item .GENDER . %s]' "$(pdi_value "$card" GENDER |l10n)"
+        ;;
+      NICKNAME) seq 1 $cnt |while read c; do
+          printf '[span .item .NICKNAME . aka. "%s"]' \
+                 "$(pdi_value "$card" NICKNAME $c |unescape |HTML)"
+        done
+        ;;
+      X-ZACK-JOINDATE|X-ZACK-LEAVEDATE) if [ $cnt -gt 0 ]; then
+          printf '[span .item .%s [b %s:] %s]' \
+                 "$item" "$(l10n "${item}_short")" \
+                 "$(pdi_value "$card" "$item" |HTML)"
+        fi
+        ;;
+      BDAY)
+       [ $cnt -gt 0 ] && printf '[span .item .BDAY [b *:] %s]' \
+                                "$(pdi_value "$card" BDAY |grep -xE '[0-9-]+')"
+        ;;
+      SOUND)
+        [ $cnt -gt 0 ] && printf '[audio .item .SOUND controls="controls"
+                                    [source type="audio/ogg" src="data:audio/ogg;base64,%s"]
+                                  ]' \
+                                  "$(pdi_value "$card" SOUND |grep -xE '[a-zA-Z0-9/+=]+')"
+        ;;
+      PHOTO|LOGO)
+        [ $cnt -gt 0 ] && printf '[img .item .%s src="data:image/%s;base64,%s"]' "$item" \
+                                 "$(pdi_attrib "$card" "$item" |sed -E 's;^(.*;)?TYPE="?(.+)"?(;.*)?$;\2;')" \
+                                 "$(pdi_value "$card" "$item" |grep -xE '[a-zA-Z0-9/+=]+')"
+        ;;
+      EMAIL) 
+        [ $cnt -gt 0 ] && printf '[h3 %s]' "$(l10n EMAIL)"
+        seq 1 $cnt |while read c; do
+          printf '[a .item .EMAIL href="mailto:%s" . %s]' \
+                 "$(pdi_value "$card" EMAIL $c |unescape |HTML)" \
+                 "$(pdi_value "$card" EMAIL $c |unescape |HTML)"
+        done
+        ;;
+      TEL)
+        [ $cnt -gt 0 ] && printf '[h3 %s]' "$(l10n TEL)"
+        seq 1 $cnt |while read c; do
+          teltype="$(pdi_attrib "$card" TEL $c TYPE)"
+          [ "$teltype" ] \
+          && printf '[span .item .TEL [span .type . %s:] %s]' \
+                    "$(l10n "TYPE=$teltype" |HTML)" \
+                    "$(pdi_value "$card" TEL $c |unescape |HTML)" \
+          || printf '[span .item .TEL . %s]' \
+                    "$(pdi_value "$card" TEL $c |unescape |HTML)"
+        done
+        ;;
+      ADR)[ $cnt -gt 0 ] && printf '[h3 %s]' "$(l10n "$item")"
+        shy="$(printf '\302\255')"
+        seq 1 $cnt |while read c; do
+          printf '[span .item .%s . %s]' "$item" \
+                 "$(pdi_value "$card" "$item" $c |sed -r "s;(straße|weg|damm|allee|ufer);${shy}\1;g" |unescape |HTML)"
+        done
+        ;;
+      *)[ $cnt -gt 0 ] && printf '[h3 %s]' "$(l10n "$item")"
+        seq 1 $cnt |while read c; do
+          printf '[span .item .%s . %s]' "$item" \
+                 "$(pdi_value "$card" "$item" $c |unescape |HTML)"
+        done
+        ;;
+    esac
+  done
+}
+
+edit_item(){
+  local card="$1"
+  local item cnt c
+  shift 1
+
+  for item in $@; do
+    cnt="$(pdi_count "$card" "$item")"
+    [ $cnt -lt 1 ] && cnt=1
+
+    case $item in
+      N)N="$(pdi_value "$card" N)"
+        if [ "$N" ]; then
+          IFS=\; read n1 n2 n3 n4 n5 <<-EOF
+               $N
+               EOF
+        else
+         N="$(pdi_value "$card" FN |unescape)"
+          n1="${N##* }"
+          n2="${N%$n1}"
+        fi
+        printf '
+        [h3 %s]
+        [input .item .N name="4N" placeholder="%s" value="%s"]
+        [input .item .N name="2N" placeholder="%s" value="%s"]
+        [input .item .N name="1N" placeholder="%s" value="%s"]
+        [input .item .N name="5N" placeholder="%s" value="%s"]
+        ' "$(l10n "$item")" \
+        "$(l10n n_pre)"   "$(HTML "$n4")" \
+        "$(l10n n_first)" "$(HTML "${n2}$([ "$n2" -a "$n3" ] && printf ' ')${n3}")" \
+        "$(l10n n_last)"  "$(HTML "$n1")" \
+        "$(l10n n_post)"  "$(HTML "$n5")"
+        ;;
+      GENDER)
+        gender="$(pdi_value "$card" GENDER)"
+        printf '
+        [select .item .GENDER name="GENDER"
+          [option value="" disabled="disabled" %s %s]
+          [option value="female" %s %s]
+          [option value="male"   %s %s]
+          [option value="other"  %s %s]
+          [option value="none"   %s %s]
+        ]\n' \
+        "$([ "$gender" = ''       ] && printf 'selected="selected"')" "$(l10n GENDER)" \
+        "$([ "$gender" = 'female' ] && printf 'selected="selected"')" "$(l10n gender_female)" \
+        "$([ "$gender" = 'male'   ] && printf 'selected="selected"')" "$(l10n gender_male)" \
+        "$([ "$gender" = 'other'  ] && printf 'selected="selected"')" "$(l10n gender_other)" \
+        "$([ "$gender" = 'none'   ] && printf 'selected="selected"')" "$(l10n gender_none)"
+        ;;
+      BDAY|X-ZACK-JOINDATE|X-ZACK-LEAVEDATE)
+        printf '[h3 %s]
+        [input .item .%s name="%s" value="%s" placeholder="YYYY-MM-DD"]' \
+        "$(l10n "$item")" "$item" "$item" "$(pdi_value "$card" "$item" |grep -xE '[0-9-]+')"
+        ;;
+      ADR|NOTE)
+        printf '[h3 %s]' "$(l10n "$item")"
+        seq 1 $cnt |while read c; do
+          printf '[checkbox "%s_delete_%i" "true" .delete #%s_delete_%i][label for="%s_delete_%i" %s]' \
+            "$item" $c "$item" $c "$item" $c "$(l10n delete)"
+          printf '<textarea class="item %s" name="%s">%s</textarea>' \
+            "$item" "$item" "$(pdi_value "$card" "$item" $c |unescape |HTML)"
+        done
+        printf '[button type="submit" name="action" value="addfield %s" %s ]' "$item" "$(l10n edit_addfield)"
+        ;;
+      TEL) printf '[h3 %s]' "$(l10n "$item")"
+        seq 1 $cnt |while read c; do
+          printf '[checkbox "%s_delete_%i" "true" .delete #%s_delete_%i][label for="%s_delete_%i" %s]' \
+            "$item" $c "$item" $c "$item" $c "$(l10n delete)"
+          teltype="$(pdi_attrib "$card" TEL $c TYPE)"
+          printf '[select .item .teltype name="teltype"
+                    [option value="" disabled="disabled" %s %s]
+                    [option value="HOME" %s %s]
+                    [option value="WORK" %s %s]
+                    [option value="CELL" %s %s]
+                    [option value="FAX"  %s %s]
+                  ]\n' \
+                  "$([ "$teltype" = ''     ] && printf 'selected="selected"')" "$(l10n teltype)" \
+                  "$([ "$teltype" = 'HOME' ] && printf 'selected="selected"')" "$(l10n TYPE=HOME)" \
+                  "$([ "$teltype" = 'WORK' ] && printf 'selected="selected"')" "$(l10n TYPE=WORK)" \
+                  "$([ "$teltype" = 'CELL' ] && printf 'selected="selected"')" "$(l10n TYPE=CELL)" \
+                  "$([ "$teltype" = 'FAX'  ] && printf 'selected="selected"')" "$(l10n TYPE=FAX)"
+
+          printf '[input .item .%s name="%s" value="%s" placeholder="%s"]' \
+            "$item" "$item" "$(pdi_value "$card" "$item" $c |unescape |HTML)" "$(l10n "$item")"
+        done
+        printf '[button type="submit" name="action" value="addfield %s" %s ]' "$item" "$(l10n edit_addfield)"
+        ;;
+      *)printf '[h3 %s]' "$(l10n "$item")"
+        seq 1 $cnt |while read c; do
+          printf '[checkbox "%s_delete_%i" "true" .delete #%s_delete_%i][label for="%s_delete_%i" %s]' \
+            "$item" $c "$item" $c "$item" $c "$(l10n delete)"
+          printf '[input .item .%s name="%s" value="%s" placeholder="%s"]' \
+            "$item" "$item" "$(pdi_value "$card" "$item" $c |unescape |HTML)" "$(l10n "$item")"
+        done
+        printf '[button type="submit" name="action" value="addfield %s" %s ]' "$item" "$(l10n edit_addfield)"
+        ;;
+    esac
+  done
+}
diff --git a/categories/edit_categories.sh b/categories/edit_categories.sh
new file mode 100755 (executable)
index 0000000..7ee6f36
--- /dev/null
@@ -0,0 +1,35 @@
+#!/bin/zsh
+
+# Copyright 2015 - 2016 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+catfile="${_DATA}/mappings/categories"
+
+remove="$(POST remove)"
+newcat="$(POST newcat)"
+
+if [ "$(POST add)" = "add" ]; then
+  categories="$( {
+    cat "$catfile"
+    printf %s\\n "$newcat"
+  } |sort -u )"
+  printf %s\\n "$categories" >"$catfile"
+elif [ "$remove" ]; then
+  sed -E -i '/^'"${remove}"'$/d' "$catfile"
+fi
+
+REDIRECT "/categories/"
diff --git a/categories/index.cgi b/categories/index.cgi
new file mode 100755 (executable)
index 0000000..236b1e3
--- /dev/null
@@ -0,0 +1,71 @@
+#!/bin/sh
+# Copyright 2015, 2017, 2018, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+. $_EXEC/pdiread.sh
+. $_EXEC/categories/l10n.sh
+
+catfile="${_DATA}/mappings/categories"
+
+list_categories() {
+  grep -vxE '[  ]*' "$catfile" |sort -u
+}
+
+list_catsel(){
+  local vcf="$1" card="$2" n=1 cats="${BR}"
+  while cats="${cats}${BR}$(pdi_value "$vcf" CATEGORIES $n)"; do n=$((n + 1)); done
+
+  list_categories |while read cat; do
+    printf '[li [label [input %s type="checkbox" name="%s" value="%s"] %s]]' \
+          "$([ "${cats%*${BR}${cat}${BR}*}" != "$cats" ] && printf checked=checked)" \
+           "$(HTML "$card")" "$(HTML "$cat")" "$(HTML "$cat")" 
+  done
+}
+
+cat <<EOF |yield_page categories
+[form .categories action="edit_categories.sh" method="POST"
+  [h1 $(l10n categories_label)]
+  [input type="hidden" name="page" value="categories"]
+  [ul
+    $(list_categories | while read cat; do
+      printf '[li . %s [button type="submit" name="remove" value="%s" . %s]]\n' \
+             "$(HTML "$cat")" "$(HTML "$cat")" "$(l10n cat_remove)"
+    done)
+    [li
+      [input type="text" name="newcat" placeholder="$(l10n cat_newlabel)"]
+      [button type="submit" name="add" value="add" . $(l10n cat_add)]
+    ]
+  ]
+]
+
+[form .namelist action="update_categories.sh" method="POST"
+  [fieldset
+    [button type="submit" name="submit" value="submit" . $(l10n cat_update)]
+  ]
+  [ul .namelist
+  $(for vcffile in "$_DATA"/vcard/*vcf; do
+    vcf="$(pdi_load "$vcffile")"
+    printf '    [li [h2 . %s][ul ' "$(pdi_value "$vcf" FN)"
+    list_catsel "$vcf" "${vcffile##*/}"
+    printf ']]\n'
+  done |sort)
+  ]
+  [fieldset
+    [button type="submit" name="submit" value="submit" . $(l10n cat_update)]
+  ]
+]
+EOF
diff --git a/categories/l10n.sh b/categories/l10n.sh
new file mode 100755 (executable)
index 0000000..c0e6421
--- /dev/null
@@ -0,0 +1,30 @@
+# Copyright 2014, 2015, 2016, 2019, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+l10n(){
+  local word
+  [ $# -eq 0 ] && read -r word || word="$*"
+  case $word in
+    cat_remove) printf %s "&#45;";;
+    cat_add) printf %s "+";;
+    cat_newlabel) printf %s "neue Kategorie";;
+    cat_update) printf %s "Zuweisungen übernehmen";;
+    categories_label) printf %s "Kategorien";;
+
+    *) l10n_global "$word";;
+  esac
+}
diff --git a/categories/update_categories.sh b/categories/update_categories.sh
new file mode 100755 (executable)
index 0000000..108c5d5
--- /dev/null
@@ -0,0 +1,41 @@
+#!/bin/sh
+
+# Copyright 2016, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+. "$_EXEC"/cgilite/storage.sh
+. "$_EXEC"/pdiread.sh
+
+catfile="${_DATA}/mappings/categories"
+
+for card in "${_DATA}"/vcard/*.vcf; do
+  n='' postcats='' cardcats=''
+  vcf="$(pdi_load "$card")"
+
+  n=1; while postcats="${postcats}${postcats:+,}$(POST "${card##*/}" $n)"; do n=$((n+1)); done
+  n=1; while cardcats="${cardcats}${cardcats:+,}$(pdi_value "$vcf" CATEGORIES $n)"; do n=$((n+1)); done
+
+  if [ "${postcats}" != "${cardcats}" ] && LOCK "$card"; then
+    sed -E -i '
+      /^CATEGORIES[;:]/d
+      /^END;?:VCARD *\r?$/iCATEGORIES:'"${postcats%,}"'\r
+    ' "${card}"
+    RELEASE "$card"
+  fi
+done
+
+REDIRECT /categories/
similarity index 100%
rename from cgilite.sh
rename to cgilite/cgilite.sh
similarity index 100%
rename from common.css
rename to cgilite/common.css
similarity index 100%
rename from file.sh
rename to cgilite/file.sh
similarity index 100%
rename from html-sh.sed
rename to cgilite/html-sh.sed
similarity index 100%
rename from logging.sh
rename to cgilite/logging.sh
similarity index 100%
rename from session.sh
rename to cgilite/session.sh
similarity index 100%
rename from storage.sh
rename to cgilite/storage.sh
diff --git a/courses/edit_course.sh b/courses/edit_course.sh
new file mode 100755 (executable)
index 0000000..3c0c54f
--- /dev/null
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+# Copyright 2014, 2019, 2020 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+locktimeout=900
+. "$_EXEC"/session_lock.sh
+
+course="$(GET course |PATH)"
+coursefile="$_DATA/ical/${course##*/}"
+
+if tempfile="$(SLOCK "$coursefile" "$locktimeout")"; then
+  REDIRECT "/courses/?e=${course}"
+elif [ -f "$tempfile" ]; then
+  SET_COOKIE session message="SESSLOCK"
+  REDIRECT "/courses/#${course}"
+else
+  SET_COOKIE session message="EDITLOCK"
+  REDIRECT "/courses/#${course}"
+fi
diff --git a/courses/export_ical.sh b/courses/export_ical.sh
new file mode 100755 (executable)
index 0000000..3649ed5
--- /dev/null
@@ -0,0 +1,28 @@
+#!/bin/zsh
+
+# Copyright 2014,2015,2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+course="$(GET course |PATH)"
+coursefile="$_DATA/ical/${course##*/}"
+
+. $_EXEC/pdiread.sh
+. $_EXEC/cgilite/file.sh
+
+printf 'Content-Disposition: inline; filename="%s.ics"\r\n' "$(pdi_value "$(pdi_load "$coursefile")" SUMMARY)"
+
+FILE "$coursefile" "text/calendar; charset=utf-8"
diff --git a/courses/export_pdf.sh b/courses/export_pdf.sh
new file mode 100755 (executable)
index 0000000..62bb69e
--- /dev/null
@@ -0,0 +1,132 @@
+#!/bin/sh
+
+. "${_EXEC}/pdiread.sh"
+. "$_EXEC/cards/l10n.sh"
+
+coursefile="${_DATA}/ical/$(GET course)"
+
+if [ ! -r "$coursefile" ]; then
+  SET_COOKIE 0 message="Cannot read course file"
+  REDIRECT /courses/
+  return 0
+elif ! mkdir -p "$_DATA/export"; then
+  SET_COOKIE 0 message="Cannot create export directory"
+  REDIRECT /courses/
+  return 0
+fi
+
+ics="$(pdi_load "$coursefile")"
+htmlfile="${_DATA}/export/$(pdi_value "$ics" SUMMARY |unescape |tr \\n/ __).html"
+pdffile=${htmlfile%.html}.pdf
+
+pdi_date() {
+  local pdt y m d H M S Z
+  [ $# -eq 0 ] && read pdt || pdt="$*"
+
+  case $pdt in
+    *T*Z)
+      Z=UTC; pdt="${pdt%Z}";;
+    TZID=*:*T*)
+      Z="${pdt%%:*}"; Z=${Z#TZID=}; pdt=${pdt#TZID=*:};;
+  esac
+
+  y="${pdt%%????T*}" pdt=${pdt#????}
+  m="${pdt%%??T*}" pdt=${pdt#??}
+  d="${pdt%%T*}" pdt=${pdt#??T}
+  H="${pdt%%????}" pdt=${pdt#??}
+  M="${pdt%%??}" pdt=${pdt#??}
+  S="${pdt}" pdt=''
+
+  case Z in 
+    UTC) date -d "${y}-${m}-${d} ${H}:${M}:${S} UTC" +%s;;
+     '') date -d "${y}-${m}-${d} ${H}:${M}:${S}" +%s;;
+      *) date -d "TZ=\"${Z}\" ${y}-${m}-${d} ${H}:${M}:${S}" +%s;;
+  esac
+}
+
+get_dates() {
+  local dts_date rrule rr_int rr_freq rec today="$(date +%Y%m%d)"
+
+  dts_date="$(pdi_value "$ics" DTSTART || printf %s "$today")"
+  dts_date="${dts_date#TZID=*:}" dts_date="${dts_date%%T*}"
+  rrule="$(pdi_value "$ics" RRULE)"
+  rr_int="${rrule##*INTERVAL=}" rr_int="${rr_int%%;*}"
+  rr_freq="${rrule##*FREQ=}" rr_freq="${rr_freq%%;*}"
+
+  [ "$rr_int" -ge 0 ] || rr_int=1 2>/dev/null
+  case "$rr_freq" in
+    YEARLY)  rec="$rr_int year";;
+    MONTHLY) rec="$rr_int month";;
+    DAILY)   rec="$rr_int day";;
+    WEEKLY)  rec="$rr_int week";;
+    *) rec="$rr_int week";;
+  esac
+
+  while [ "$dts_date" -lt "$today" ]; do dts_date="$(date -d "${dts_date} + ${rec}" +%Y%m%d)"; done
+  for n in 1 2 3 4 5 6 7 8 9 10; do
+    LANG=de_DE.UTF-8 date -d "$dts_date" +"%d. %b."
+    dts_date="$(date -d "${dts_date} + ${rec}" +%Y%m%d)"
+  done
+}
+
+# some table styles need to be inline, because this is how libreoffice works
+style_td='style="border: 1pt solid; padding: 1mm 2mm; vertical-align: top;"'
+
+"$_EXEC/cgilite/html-sh.sed" <<-EOF >"$htmlfile"
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
+[html [head
+  [meta http-equiv="content-type" content="text/html; charset=utf-8"]
+  [title]
+  [meta name="generator" content="Confetti"]
+  [meta name="created" content="$(date +%FT%T)"]
+  [meta name="changed" content="$(date +%FT%T)"]
+  [style type="text/css"
+    @page { size: 29.7cm 21cm; margin: 1.5cm; }
+    * { background: inherit; }
+    body { background: transparent; font-family: Liberation Sans, Sans-Serif; }
+
+    th { white-space: pre; }
+    th, td { text-align: left; }
+  ]
+][body lang="de_DE"
+  [table width="100%" style="page-break-after: always;"
+    [col width=10*] [col width=5*] [col width=10*] [col width=15*]
+    [thead
+      [tr [th $style_td . $(l10n N)] [th $style_td . $(l10n BDAY)] [th $style_td . $(l10n TEL)] [th $style_td . $(l10n NOTE)]]
+    ][tbody
+      $(grep -F "${coursefile##*/}     " "$_DATA/mappings/attendance" |while read discard each; do
+        vcf="$(pdi_load "$_DATA/vcard/$each")"
+        tel="$( seq 1 $(pdi_count "$vcf" TEL) |while read n; do
+                  type="$(pdi_attrib "$vcf" TEL $n TYPE)"
+                  [ "$type" ] && type="$(l10n "TYPE=$type"):"
+                  printf '%s %s<br>' "$type" "$(pdi_value "$vcf" TEL $n)"
+                done  )"
+        printf '[tr valign=top [td %s .N . %s] [td %s .BDAY . %s] [td %s .TEL . %s] [td %s .NOTE . %s]]\n' \
+               "$style_td" "$(pdi_value "$vcf" FN   |unescape |HTML)" \
+               "$style_td" "$(pdi_value "$vcf" BDAY |unescape |HTML)" \
+               "$style_td" "$tel" \
+               "$style_td" "$(pdi_value "$vcf" NOTE |unescape |HTML)"
+      done |sort)]
+  ]
+  [table width="100%"
+    [col width=30*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*]
+    [thead
+      [tr [th $style_td ] $(get_dates |xargs -d\\n printf "[th $style_td . %s]")]
+    ][tbody
+    $(grep -F "${coursefile##*/}       " "$_DATA/mappings/attendance" |while read discard each; do
+      vcf="$(pdi_load "$_DATA/vcard/$each")"
+      printf '[tr [td %s .N . %s] [td %s] [td %s] [td %s] [td %s] [td %s] [td %s] [td %s] [td %s] [td %s] [td %s]]\n' \
+             "$style_td" "$(pdi_value "$vcf" FN |unescape |HTML)" \
+             "$style_td" "$style_td" "$style_td" "$style_td" "$style_td" "$style_td" "$style_td" "$style_td" "$style_td" "$style_td"
+    done |sort)]
+  ]
+]]
+EOF
+
+export            HOME="$_DATA"
+export XDG_CONFIG_HOME="$_DATA/xdg_config"
+export  XDG_CACHE_HOME="$_DATA/xdg_cache"
+export   XDG_DATA_HOME="$_DATA/xdg_local"
+
+lowriter --convert-to pdf --outdir "$_DATA/export/" "$htmlfile" >/dev/null
+REDIRECT "$(URL "/export/${pdffile##*/}")"
diff --git a/courses/index.cgi b/courses/index.cgi
new file mode 100755 (executable)
index 0000000..7ff426f
--- /dev/null
@@ -0,0 +1,26 @@
+#!/bin/sh
+
+. $_EXEC/pdiread.sh
+. $_EXEC/courses/l10n.sh
+. $_EXEC/courses/widgets.sh
+. $_EXEC/courses/list.sh
+
+upcase=' y;abcdefghijklmnopqrstuvwxyzäöüé;ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜÉ;; '
+
+order="$(GET o |grep -m1 -xE 'DOW|TOD')"
+edit="$(GET e |PATH)"
+
+[ "$order" ] || order=DOW
+edit="${edit##*/}"
+
+{ w_sort_courses
+  printf '
+  [form .newcourses action="/courses/new_course.sh" method="POST"
+    [button type="submit" %s]
+  ]' "$(l10n newcourse)"
+
+  [ "$edit" ] && edit_course "$edit"
+  printf '[div .courselist\n'
+    list_courses
+  printf ']'
+} | yield_page courses #/courses/courses.css
diff --git a/courses/l10n.sh b/courses/l10n.sh
new file mode 100755 (executable)
index 0000000..f98529f
--- /dev/null
@@ -0,0 +1,50 @@
+# Copyright 2014, 2016, 2019 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+l10n(){
+  local word
+  [ $# -eq 0 ] && read -r word || word="$*"
+
+  case $word in
+    newcourse) printf "Neuen Kurs anlegen";;
+    time) printf "Uhrzeit";;
+
+    edit_dtscal) printf "&#x2713;";;
+    edit) printf "Bearbeiten";;
+    ics_export) printf "ICal exportieren";;
+    courselist) printf "Kursliste (PDF)";;
+    
+    course_mail) printf "Mail an Teilnehmende";;
+    
+    sort_order) printf "Sortierung";;
+    order_DOW) printf "Wochentag";;
+    order_TOD) printf "Uhrzeit";;
+    order_apply) printf "Sortieren";;
+    
+    t_every) printf "Alle";;
+    t_eternal) printf "ewig";;
+    t_times) printf "mal";;
+    t_until) printf "Bis";;
+    t_oclock) printf "Uhr";;
+
+    "Mon Tue Wed Thu Fri Sat Sun") printf "Mo Di Mi Do Fr Sa So";;
+    "January February March April May June July August September October November December")
+      printf "Januar Februar März April Mai Juni Juli August September Oktober November Dezember";;
+
+    *) l10n_global "$word";;
+  esac
+}
diff --git a/courses/list.sh b/courses/list.sh
new file mode 100755 (executable)
index 0000000..e9ae7bc
--- /dev/null
@@ -0,0 +1,120 @@
+#!/bin/sh
+
+. "${_EXEC}"/pdiread.sh
+
+SUP_FIELDS="COMMENT"
+
+edit_course(){
+  local coursefile="$_DATA/ical/$1" 
+  local tempfile course
+
+  . $_EXEC/session_lock.sh
+
+  if ! tempfile="$(CHECK_SLOCK "$coursefile")"; then
+    printf '[div .message %s]' "$(l10n "This course is not set up for editing within this session.")"
+  else
+    course="$(pdi_load "$tempfile")"
+    cat <<-EOF
+       [form .course #${coursefile##*/} action="/courses/update_course.sh" method="POST"
+          [input type="hidden" name="course" value="${coursefile##*/}"]
+         [input type="hidden" name="tid" value="$(transid ${tempfile})"]
+          [div .section .basic . $(
+            edit_item "$course" SUMMARY COMMENT
+          )]
+          [div .section .dtstart . $(
+            edit_item "$course" DTSTART
+          )]
+          [div .section .recur . $(
+            edit_item "$course" RRULE
+          )]
+          [div .section .attendance . $(
+            edit_item "$course" attendance
+          )]
+          [div .control
+            [!-- select .item name=newfield
+              [option disabled="disabled" selected . $(l10n edit_addfieldtext)]
+              $(for f in $SUP_FIELDS; do printf '[option value="%s" . %s]\n' "$f" "$(l10n "$f")"; done)
+            ]
+            [button .item type="submit" name="action" value="addfield" . $(l10n edit_addfield) --]
+            [button .item type="submit" name="action" value="update"   . $(l10n edit_update)]
+            [button .item type="submit" name="action" value="cancel"   . $(l10n edit_cancel)]
+            [div .item .delete label="$(l10n edit_delete)"
+              [input type="checkbox" #delete]
+              [label for="delete" $(l10n edit_delete)]
+              [button type="submit" name="action" value="delete" $(l10n edit_delete)]
+            ]
+          ]
+       ]
+       EOF
+  fi
+}
+
+print_course(){
+  local coursefile="$1"
+  local course="$(pdi_load "$coursefile")"
+  cat <<-EOF
+    [div .course #${coursefile##*/}
+      [div .section .basic . $(
+        cal_item "$course" SUMMARY DTSTART RRULE
+      )]
+      [div .section .COMMENT   . $(cal_item "$course" COMMENT)]
+      [div .section .attendance [h3 $(l10n course_attendance) ] [ul . 
+        $(grep -F "${coursefile##*/}   " "$_DATA/mappings/attendance" |while read discard each; do
+          printf '[li [a .item .attendance href="/cards/#%s" . %s]]\n' \
+                 "$each" \
+                 "$(pdi_value "$(pdi_load "$_DATA/vcard/$each")" FN |unescape |HTML)"
+        done |sort -k7)]
+      ]
+      [div .control
+        [a .item href="/courses/edit_course.sh?course=${coursefile##*/}" $(l10n edit)]
+        [a .item href="/courses/export_pdf.sh?course=${coursefile##*/}" target="blank" $(l10n courselist)]
+        [a .item href="/courses/export_ical.sh?course=${coursefile##*/}" $(l10n ics_export)]
+        [a .item href="mailto:zack@vuesch.org?bcc=$(course_mail "${coursefile##*/}" |HTML)" $(l10n course_mail)]
+      ]
+    ]
+       EOF
+}
+
+course_mail() {
+  course="$1"
+  grep -F "${course}   " "$_DATA/mappings/attendance" |while read junk card; do
+    cat "${_DATA}/vcard/${card}"
+  done \
+  | pdi_load - \
+  | sed -nE 's;^EMAIL(\;[^:]*)*:(.+)\r?$;\2,;p' \
+  | tr -d \\n \
+  | unescape
+}
+
+print_courses(){
+  local calfile cachefile date size name ldate=0 lsize lname
+
+  while read calfile; do
+    cachefile="${_DATA}/cache/${calfile##*/}.cache"
+    if [ -s "$cachefile" -a "$cachefile" -nt "$calfile" ]; then
+      cat "$cachefile"
+    else
+      print_course "$calfile" |tee "$cachefile"
+    fi
+  done
+}
+
+order_courses() {
+  local calfile course
+
+  while read calfile; do
+    icstime="$(pdi_value "$(pdi_load "$calfile")" DTSTART |cal_date)"
+    case $order in
+      DOW) printf '%s  %s\n' "$(date -d "$icstime" "+%u %H:%M:%S")" "$calfile";;
+      TOD) printf '%s  %s\n' "$(date -d "$icstime" "+%H:%M:%S")" "$calfile";;
+    esac
+  done \
+  | sort \
+  | sed -E 's;^.*\t;;g'
+}
+
+list_courses(){
+  printf '%s\n' ${_DATA}/ical/*.ics \
+  | order_courses \
+  | print_courses
+}
diff --git a/courses/new_course.sh b/courses/new_course.sh
new file mode 100755 (executable)
index 0000000..362752d
--- /dev/null
@@ -0,0 +1,51 @@
+#!/bin/sh
+
+# Copyright 2014, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+locktimeout=900
+. "$_EXEC"/session_lock.sh
+
+uid="$(timeid)$(randomid)"  # 32 Octets UID, starting with timestamp
+course="${uid}.ics"
+
+tzid="$(cat /etc/timezone)"
+tstamp="$(TZ="$tzid" date +%Y%m%dT%H%M%S)"
+
+coursefile="$_DATA/ical/$course"
+
+if tempfile="$(SLOCK "$coursefile")"; then
+  cat >"$tempfile" <<-EOF
+       BEGIN:VCALENDAR
+       VERSION:2.0
+       PRODID:Berlin RAW Confetti
+       BEGIN:VEVENT
+       UID:$uid
+       DTSTAMP:TZID=${tzid}:${tstamp}
+       DTSTART:TZID=${tzid}:${tstamp}
+       DURATION:
+       RRULE:
+       SUMMARY:
+       COMMENT:
+       END:VEVENT
+       END:VCARD
+       EOF
+  REDIRECT "/courses/?e=${course}"
+else
+  SET_COOKIE session message="EDITLOCK"
+  REDIRECT "/courses/"
+fi
diff --git a/courses/update_course.sh b/courses/update_course.sh
new file mode 100755 (executable)
index 0000000..4abdbe7
--- /dev/null
@@ -0,0 +1,153 @@
+#!/bin/sh
+
+# Copyright 2014, 2015, 2020, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+. "$_EXEC/pdiread.sh"
+. "$_EXEC/session_lock.sh"
+. "$_EXEC/cgilite/storage.sh"
+
+unset coursefile attfile tempfile
+
+course="$(POST course |PATH)"; course="${course##*/}"
+coursefile="$_DATA/ical/$course"
+attfile="$_DATA/mappings/attendance"
+
+if ! tempfile="$(CHECK_SLOCK "$coursefile")"; then
+  SET_COOKIE 0 message="NO VALID FILE LOCK"
+  REDIRECT "/courses/?e=${course}"
+  exit 0
+elif [ "$(POST tid)" != "$(transid "$tempfile")" ]; then
+  SET_COOKIE 0 message="INVALID TRANSACTION ID"
+  REDIRECT "/courses/?e=${course}"
+  exit 0
+fi
+
+vcf_escape(){
+  for each in "$@"; do
+    printf %s\\n "$each" \
+    | sed -E ':X;$!{N;bX}; s;\r\n;\n;g; s;([;,\\]);\\\1;g; s;\n;\\n;g;'
+  done \
+  | sed -E ':X;$!{N;bX}; s;\n;\;;g'
+}
+
+ics="$(pdi_load "$tempfile")"
+
+tzid=$(cat /etc/timezone)
+
+ics="$(pdi_update_attrib "$ics" DTSTAMP 1 "TZID=${tzid}")"
+ics="$(pdi_update_value  "$ics" DTSTAMP 1 "$(TZ="$tzid" date +%Y%m%dT%H%M%S)")"
+
+dts_year="$(  POST DTS_YEAR    |grep -m1 -xE '[0-9]{4}' || date +%Y)"
+dts_month="$( POST DTS_MONTH   |grep -m1 -xE '0[1-9]|1[012]' || date +%m)"
+dts_day="$(   POST DTS_DAY     |grep -m1 -xE '0[1-9]|[12][0-9]|3[01]' || date +%d)"
+dts_hour="$(  POST DTS_HOUR    |grep -m1 -xE '[0-9]|1[0-9]|2[0-3]' || date +%H)"
+dts_minute="$(POST DTS_MINUTE  |grep -m1 -xE '[0-9]|[1-5][0-9]' || date +%M)"
+[ ${#dts_hour}   -eq 1 ] && dts_minute="0$dts_hour"
+[ ${#dts_minute} -eq 1 ] && dts_minute="0$dts_minute"
+DTSTART="${dts_year}${dts_month}${dts_day}T${dts_hour}${dts_minute}00"
+
+ics="$(pdi_update_attrib "$ics" DTSTART 1 "TZID=${tzid}")"
+ics="$(pdi_update_value  "$ics" DTSTART 1 "$DTSTART")"
+
+rr_int=$(  POST RRULE_INTERVAL |grep -m1 -xE '[0-9]+' || printf 1)
+rr_count=$(POST RRULE_COUNT    |grep -m1 -xE '[0-9]+' || printf 1)
+rr_freq=$( POST RRULE_FREQ     |grep -m1 -xE 'DAILY|WEEKLY|MONTHLY|YEARLY' || printf MONTHLY)
+rr_uy=$(   POST RRULE_UYEAR    |grep -m1 -xE '[0-9]{4}' || date +%Y)
+rr_um=$(   POST RRULE_UMONTH   |grep -m1 -xE '[1-9]|1[012]' || date +%m)
+rr_ud=$(   POST RRULE_UDAY     |grep -m1 -xE '[1-9]|[12][0-9]|3[01]' || date +%d)
+[ ${#rr_um} -eq 1 ] && rr_um="0$rr_um"
+[ ${#rr_ud} -eq 1 ] && rr_ud="0$rr_ud"
+
+case $(POST RRULE_LIMIT) in
+  COUNT)   RRULE="FREQ=$rr_freq;INTERVAL=$rr_int;COUNT=$rr_count";;
+  UNTIL)   RRULE="FREQ=$rr_freq;INTERVAL=$rr_int;UNTIL=${rr_uy}${rr_um}${rr_ud}T000000Z";;
+  ETERN|*) RRULE="FREQ=$rr_freq;INTERVAL=$rr_int";;
+esac
+
+ics="$(pdi_update_value  "$ics" RRULE 1 "$RRULE")"
+
+for field in $(POST_KEYS |grep -xE '[A-Z][A-Z0-9-]*'); do
+  for cnt in $(seq 1 $(POST_COUNT "$field")); do
+    case "$field" in
+      *)
+         ics="$(pdi_update_value "$ics" "$field" "$cnt" "$(vcf_escape "$(POST "$field" "$cnt")")")"
+        ;;
+    esac
+done; done
+
+# delete fields, first mark for deletion using delete_key
+# this way the field enumeration is preserved during the process
+# finally filter marked lines
+delete_key="$(randomid)"
+for delete in $(POST_KEYS |grep -xE '[A-Z][A-Z0-9-]*_delete_[0-9]+'); do
+  f="${delete%%_*}"; c="${delete##*_}";
+  [ "$(POST "$delete")" = "true" ] && ics="$(pdi_update_value "$ics" "$f" "$c" "delete=${delete_key}")"
+done
+ics="$(printf '%s\n' "$ics" |sed -E "/^[^:]+:delete=${delete_key}\$/d")"
+
+case "$(POST action)" in
+  addfield)
+    newfield="$(POST newfield |grep -m 1 -xE '[A-Z][A-Z0-9-]*')"
+    ics="$(pdi_update_value "$ics" "$newfield" $(( $(pdi_count "$ics" "$newfield") + 1 )) '')"
+    printf '%s' "$ics" |grep -vx '' >"$tempfile"
+    REDIRECT "/courses/?e=${course}"
+    ;;
+  addfield\ [A-Z]*)
+    newfield="$(POST action |sed -nE '1s;^addfield ([A-Z][A-Z0-9-]*)$;\1;p')"
+    ics="$(pdi_update_value "$ics" "$newfield" $(( $(pdi_count "$ics" "$newfield") + 1 )) '')"
+    printf '%s' "$ics" |grep -vx '' >"$tempfile"
+    REDIRECT "/courses/?e=${course}"
+    ;;
+  update)
+    if LOCK "$attfile"; then
+      grep -F "${course}       " "$attfile" |while read junk card; do
+        touch "$_DATA/vcard/${card}"
+      done
+      sed -E -i "/^${course}   .+\$/d" "$attfile"
+      seq 1 $(POST_COUNT attendance) |while read n; do
+        printf '%s     %s\n' "$course" "$(POST attendance $n)"
+      done >>"$attfile"
+      grep -F "${course}       " "$attfile" |while read junk card; do
+        touch "$_DATA/vcard/${card}"
+      done
+      RELEASE "$attfile"
+    else
+      SET_COOKIE 0 message="COULD NOT UPDATE COURSE MAPPINGS"
+    fi
+
+    printf '%s' "$ics" |grep -vx '' >"${tempfile}.cp"
+    mv "${tempfile}.cp" "$coursefile"
+    RELEASE_SLOCK "$coursefile"
+    REDIRECT "/courses/#${course}"
+    ;;
+  cancel)
+    RELEASE_SLOCK "$coursefile"
+    [ -f "$coursefile" ] \
+    && REDIRECT "/courses/#${course}" \
+    || REDIRECT "/courses/"
+    ;;
+  delete)
+    rm "$coursefile"
+    RELEASE_SLOCK "$coursefile"
+    REDIRECT "/courses/"
+    ;;
+  *)
+    printf '%s' "$ics" |grep -vx '' >"$tempfile"
+    REDIRECT "/courses/?e=${course}"
+    ;;
+esac
diff --git a/courses/widgets.sh b/courses/widgets.sh
new file mode 100755 (executable)
index 0000000..5b5288c
--- /dev/null
@@ -0,0 +1,237 @@
+# Copyright 2014, 2019, 2020 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+check(){
+  [ "$1" = "$2" ] && printf 'checked="checked"'
+}
+
+edit="$(GET e)"
+order="$(GET o |grep -m1 -xE 'DOW|TOD')"
+
+w_sort_courses(){
+  cat <<-EOF
+       [form .sort .search action="?" method="GET"
+          [fieldset .order [legend $(l10n sort_order):]
+           [radio "order" "DOW" $(check $order DOW) $(l10n order_DOW)]
+           [radio "order" "TOD" $(check $order TOD) $(l10n order_TOD)]
+          ]
+         [submit "" "" $(l10n order_apply)]
+       ]
+       EOF
+}
+
+cal_date(){
+  { [ $# -eq 0 ] && cat || printf %s "$*"; } |sed -nE '
+    2q
+    s/^([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})Z$/\1-\2-\3 \4:\5:\6 UTC/p;t
+    s/^TZID=(.+)\:([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})$/TZ="\1" \2-\3-\4 \5:\6:\7/p;t
+    s/^([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})$/\1-\2-\3 \4:\5:\6/p;t
+  '
+}
+
+cal_item(){
+  local course="$1"
+  local item cnt c
+  shift 1
+
+  for item in $@; do
+    cnt="$(pdi_count "$course" "$item")"
+
+    case $item in
+      SUMMARY)
+        printf '[h2 &shy%s]' "$(pdi_value "$course" SUMMARY)"
+        ;;
+      DTSTART)
+        printf '[span .text .DTSTART %s %s ]' \
+               "$(LANG=de_DE.UTF-8 date -d "$(pdi_value "$course" DTSTART |cal_date)" '+%A, %d. %B %Y - %H:%M')" \
+               "$(l10n t_oclock)"
+        ;;
+      RRULE)
+        dts_date="$(pdi_value "$course" DTSTART |cal_date)"
+        rrule=" $(pdi_value "$course" RRULE)"
+        rr_int="${rrule##*INTERVAL=}"; rr_int="${rr_int%%;*}"
+        rr_count="${rrule##*COUNT=}"; rr_count="${rr_count%%;*}"
+        rr_freq="${rrule##*FREQ=}"; rr_freq="${rr_freq%%;*}"
+        rr_until="${rrule##*UNTIL=}"; rr_until="${rr_until%%;*}"
+        rr_until="$(cal_date "${rr_until}")"
+
+        [ "$rr_int" -eq 1 ] \
+        && printf '[span .text .RRULE %s]' "$(l10n "s$rr_freq")" \
+        || printf '[span .text .RRULE %s %s %s]' "$(l10n t_every)" "${rr_int}" "$(l10n $rr_freq)"
+        case "$rrule $rr_freq" in
+          *COUNT*DAILY*)
+            printf '[span .text %s %s]' "$(l10n t_until)" "$(date -d "$dts_date + $((rr_int * rr_count)) day" "+%A %B %d, %Y - %H:%M")"
+            ;;
+          *COUNT*WEEKLY*)
+            printf '[span .text %s %s]' "$(l10n t_until)" "$(date -d "$dts_date + $((rr_int * rr_count)) week" "+%A %B %d, %Y - %H:%M")"
+            ;;
+          *COUNT*MONTHLY*)
+            printf '[span .text %s %s]' "$(l10n t_until)" "$(date -d "$dts_date + $((rr_int * rr_count)) month" "+%A %B %d, %Y - %H:%M")"
+            ;;
+          *COUNT*YEARLY*)
+            printf '[span .text %s %s]' "$(l10n t_until)" "$(date -d "$dts_date + $((rr_int * rr_count)) year" "+%A %B %d, %Y - %H:%M")"
+            ;;
+          *UNTIL*)
+            printf '[span .text %s %s]' "$(l10n t_until)" "$(date -d "$rr_until" "+%A %B %d, %Y - %H:%M")"
+            ;;
+        esac
+        ;;
+      attendance);;
+      COMMENT)[ $cnt -gt 0 ] && printf '[h3 %s]' "$(l10n "$item")"
+        seq 1 $cnt |while read c; do
+          printf '[p .item .%s . %s]' "$item" \
+                 "$(pdi_value "$course" "$item" $c |unescape |HTML)"
+        done
+        ;;
+      *)[ $cnt -gt 0 ] && printf '[h3 %s]' "$(l10n "$item")"
+        seq 1 $cnt |while read c; do
+          printf '[span .item .%s . %s]' "$item" \
+                 "$(pdi_value "$course" "$item" $c |unescape |HTML)"
+        done
+        ;;
+    esac
+  done
+}
+
+edit_item(){
+  local course="$1"
+  local item cnt c
+  shift 1
+
+  for item in $@; do
+    cnt="$(pdi_count "$course" "$item")"
+    [ "$cnt" -lt 1 ] && cnt=1
+
+    case $item in
+      DTSTART)
+        local dtstart="$(pdi_value "$course" DTSTART |cal_date)"
+        local ystart="${dtstart%%-*}"; ystart="${ystart##* }"
+        local mstart="${dtstart#*-}"; mstart="${mstart%%-*}"
+        local dstart="${dtstart##*-}"; dstart="${dstart%% *}"
+        local hhstart="${dtstart##* }"; hhstart="${hhstart%%:*}"
+        local mmstart="${dtstart##* }"; mmstart="${mmstart#*:}"; mmstart="${mmstart%:*}"
+        local m mn cdow d
+
+        cat <<-EOF
+       [h3 . $(l10n DTSTART)]
+       [input type="number" name="DTS_YEAR" value="${ystart}" placeholder="$(l10n YYYY)"]
+       [select name="DTS_MONTH" onchange="this.form.submit();"
+       $(m=1; for mn in $(l10n January February March April May June July August September October November December); do
+         printf '  [option value="%02i" %s . %s]\n' $m "$(selected $m $mstart)" "$mn"
+         m=$((m+1))
+       done)
+       ][submit "DTS" "update" . $(l10n edit_dtscal)]
+       [table .dtscalt
+         [tr $(printf '[th . %s]' $(l10n Mon Tue Wed Thu Fri Sat Sun))]
+         [tr $(
+       local cdow d
+       cdow="$(date -d ${ystart}-${mstart}-1 +%u)"
+       seq 2 $cdow |xargs -n1 printf '[td .padding .%s]'
+       d=1; while [ "$d" -lt 29 ] || [ "$(date -d ${ystart}-${mstart}-${d} +%m)" -eq "$mstart" ]; do
+         [ $cdow -eq 1 -a $d -ne 1 ] && printf ']\n  [tr '
+         printf '[td [input type="radio" name="DTS_DAY" #DTSCAL_%i value="%02i" %s][label for="DTSCAL_%i" %i]]' \
+                $d $d "$(checked $d $dstart)" $d $d
+         d=$((d + 1)); cdow=$(((cdow + 1) % 7))
+       done 2>/dev/null
+       )]
+       ]
+       [label .DTSTIME $(l10n time):]
+       [input type="number" name="DTS_HOUR" value="$hhstart" min="0" max="23"]:[input type="number" name="DTS_MINUTE" value="$mmstart" min="0" max="59"]
+       EOF
+        ;;
+      RRULE)
+        local dtstart="$(pdi_value "$course" DTSTART |cal_date)"
+        local ystart="${dtstart%%-*}"; ystart="${ystart##* }"
+        local mstart="${dtstart#*-}"; mstart="${mstart%%-*}"
+        local dstart="${dtstart##*-}"; dstart="${dstart%% *}"
+
+        local rrule="$(pdi_value "$course" RRULE)"
+        local   rr_int="$(printf %s "$rrule" |sed -nE 's;^(.*\;[        ]*)?INTERVAL=([0-9]+)(\;.*)?$;\2;p')"
+        local rr_count="$(printf %s "$rrule" |sed -nE 's;^(.*\;[        ]*)?COUNT=([0-9]+)(\;.*)?$;\2;p')"
+        local  rr_freq="$(printf %s "$rrule" |sed -nE 's;^(.*\;[        ]*)?FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)(\;.*)?$;\2;p')"
+        local rr_until="$(printf %s "$rrule" |sed -nE 's;^(.*\;[        ]*)?UNTIL=([0-9]{8}T[0-9]{6}Z)(\;.*)?$;\2;p')"
+        local rr_uyear="${rr_until%????T??????Z}"
+        local rr_umonth=${rr_until#????}; rr_umonth="${rr_umonth%??T??????Z}"
+        local   rr_uday=${rr_until#??????}; rr_uday="${rr_uday%T??????Z}"
+        local  rr_limit="ETERN"
+        [ "$rr_count" ] && [ "$rr_count" -ge 0 ] && rr_limit="COUNT"
+        [ "$rr_uyear" ] && [ "$rr_uyear" -ge 0 ] && rr_limit="UNTIL"
+
+       cat <<-EOF
+       [h3 . $(l10n "$item")]
+       [span .item . $(l10n t_every)
+       [input type="number" .RRULE .INTERVAL name="RRULE_INTERVAL" placeholder="#N" value="${rr_int:-1}" min="1"]
+       [select .RRULE .FREQ name="RRULE_FREQ"
+        $(for f in DAILY WEEKLY MONTHLY YEARLY; do
+          printf '  [option value="%s" %s . %s]\n' "$f" "$(selected $f "$rr_freq")" "$(l10n $f)"
+        done)
+       ]]
+       [label .item [input type="radio" name="RRULE_LIMIT" value="ETERN" $(checked "$rr_limit" ETERN)] $(l10n t_eternal)]
+       [label .item
+         [input type="radio" name="RRULE_LIMIT" value="COUNT" $(checked "$rr_limit" COUNT)]
+         [input type="number" .RRULE .COUNT name="RRULE_COUNT" placeholder="#N" value="${rr_count:-1}" min="1"] $(l10n t_times)
+       ]
+       [label .item
+         [input type="radio" name="RRULE_LIMIT" value="UNTIL" $(checked "$rr_limit" UNTIL)] $(l10n t_until)
+         [input type="number" .RRULE .UYEAR name="RRULE_UYEAR" placeholder="$(l10n YYYY)" value="${rr_uyear:-$ystart}" min="$ystart"]
+         [input type="number" .RRULE .UMONTH name="RRULE_UMONTH" placeholder="$(l10n MM)" value="${rr_umonth:-$mstart}" min="1" max="12"]
+         [input type="number" .RRULE .UDAY name="RRULE_UDAY" placeholder="$(l10n DD)" value="${rr_uday:-$dstart}" min="1" max="31"]
+       ]
+       EOF
+        ;;
+      COMMENT)
+        printf '[h3 %s]' "$(l10n "$item")"
+        seq 1 $cnt |while read c; do
+          printf '[checkbox "%s_delete_%i" "true" .delete #%s_delete_%i][label for="%s_delete_%i" %s]' \
+            "$item" $c "$item" $c "$item" $c "$(l10n delete)"
+          printf '<textarea class="item %s" name="%s">%s</textarea>' \
+            "$item" "$item" "$(pdi_value "$course" "$item" $c |unescape |HTML)"
+        done
+        printf '[button type="submit" name="action" value="addfield %s" %s ]' "$item" "$(l10n edit_addfield)"
+        ;;
+      attendance)
+        printf '[h3 %s]' "$(l10n course_attendance)"
+        printf '[div .attendance\n'
+        for vcf in "$_DATA"/vcard/*.vcf; do
+          fn="$(pdi_value "$(pdi_load "$vcf")" FN)"
+          printf '%s/%s\n' "${vcf##*/}" "$fn"
+        done \
+        | sort -t/ -k2 \
+        | while IFS=/ read -r vcf fn; do
+          printf '[span .item [input type="checkbox" id="att%s" name="attendance" value="%s" %s][label for="att%s" . %s]]' \
+                 "$vcf" "$vcf" "$(grep -qxF "${coursefile##*/} $vcf" "$_DATA/mappings/attendance" && printf 'checked="checked"')" "$vcf" "$fn"
+        done
+        printf ']'
+        ;;
+      SUMMARY)
+        printf '[h3 %s]' "$(l10n "$item")"
+        printf '[input .item .%s name="%s" value="%s" placeholder="%s"]' \
+               "$item" "$item" "$(pdi_value "$course" "$item" |unescape |HTML)" "$(l10n "$item")"
+        ;;
+      *)
+        printf '[h3 %s]' "$(l10n "$item")"
+        seq 1 $cnt |while read c; do
+          printf '[checkbox "%s_delete_%i" "true" .delete #%s_delete_%i][label for="%s_delete_%i" %s]' \
+            "$item" $c "$item" $c "$item" $c "$(l10n delete)"
+          printf '[input .item .%s name="%s" value="%s" placeholder="%s"]' \
+            "$item" "$item" "$(pdi_value "$course" "$item" $c |unescape |HTML)" "$(l10n "$item")"
+        done
+        printf '[button type="submit" name="action" value="addfield %s" %s ]' "$item" "$(l10n edit_addfield)"
+        ;;
+    esac
+  done
+}
diff --git a/index.cgi b/index.cgi
new file mode 100755 (executable)
index 0000000..ee0d6f7
--- /dev/null
+++ b/index.cgi
@@ -0,0 +1,97 @@
+#!/bin/sh
+
+for n in "$@"; do case ${n%%=*} in
+  data) _DATA="${n#data=}";;
+  exec) _EXEC="${n#exec=}";;
+  debug) DEBUG="${n#debug=}";;
+esac; done
+
+[ ! "${_EXEC%/}" ] && _EXEC="$(realpath "${0%/*}")" || _EXEC="${_EXEC%/}"
+[ ! "${_DATA%/}" ] && _DATA=. || _DATA="${_DATA%/}"
+[ "$DEBUG" ] && exec 2>>"$DEBUG"
+
+mkdir -p "${_DATA}/cache" "${_DATA}/mappings" "${_DATA}/export" "${_DATA}/lock" "${_DATA}/ical" "${_DATA}/vcard"
+
+debug() {
+  local dbg=/dev/stderr
+  if [ ! "$DEBUG" ]; then
+    [ "$#" -gt 0 ] && : || cat;
+  elif [ "$#" -gt 0 ]; then
+    printf '%s\n' "$@" >>"$dbg"
+  else
+    tee -a "$dbg"
+  fi
+}
+
+. "$_EXEC/cgilite/cgilite.sh"
+. "$_EXEC/cgilite/session.sh"
+
+. "$_EXEC/l10n.sh"
+
+_PATH="$(PATH "/${PATH_INFO}")"
+ACTION="$(GET a)"
+
+message="$(COOKIE message)"
+[ "$message" ] && SET_COOKIE 0 message=''
+
+checked(){
+  if [ "$1" = "$2" ] || [ "$1" -eq "$2" ]; then
+    printf 'checked="checked"'
+  fi 2>/dev/null
+}
+selected(){
+  if [ "$1" = "$2" ] || [ "$1" -eq "$2" ]; then
+    printf 'selected="selected"'
+  fi 2>/dev/null
+}
+
+yield_page() {
+  local class="$1" style="$2"
+  printf 'Content-Type: text/html; charset=utf-8\r\n\r\n'
+  { printf '
+       [html [head
+          [title Confetti]
+         [meta name="viewport" content="width=device-width"]
+         [link rel="stylesheet" type="text/css" href="/style.css"]
+    '
+    [ -n "$style" ] && printf '
+         [link rel="stylesheet" type="text/css" href="%s"]
+    ' "$style"
+    printf '
+       ] [body #top class="%s"
+    ' "$class"
+    printf '[ul .menu [li [a "/cards/" . %s]][li [a "/courses/" . %s]]]' "$(l10n cards)" "$(l10n courses)"
+    [ "$message" ] && printf '[p #message\n%s\n]' "$(l10n "$message")"
+    cat
+    printf '] ]'
+  } \
+  | "${_EXEC}/cgilite/html-sh.sed"
+}
+
+topdir="${_PATH#/}"
+topdir="/${topdir%%/*}"
+
+case ${_PATH} in
+  /) REDIRECT /cards/
+    ;;
+  /export/*.pdf) . "$_EXEC/cgilite/file.sh"
+    FILE "${_DATA}/${_PATH}" "application/pdf"
+    ;;
+  /export/*) . "$_EXEC/cgilite/file.sh"
+    FILE "${_DATA}/${_PATH}"
+    ;;
+  *)
+    if   [ -d "${_EXEC}/${_PATH}" -a -x "${_EXEC}/${_PATH}/index.cgi" ]; then
+      . "${_EXEC}/${_PATH}/index.cgi"
+    elif [ -f "${_EXEC}/${_PATH}" -a -x "${_EXEC}/${_PATH}" ]; then
+      . "${_EXEC}/${_PATH}"
+    elif [ -f "${_EXEC}/${_PATH}" -a -r "${_EXEC}/${_PATH}" ]; then
+      . "$_EXEC/cgilite/file.sh"
+      FILE "${_EXEC}/${_PATH}"
+    elif [ -d "${_EXEC}/${topdir}" -a -x "${_EXEC}/${topdir}/index.cgi" ]; then
+      . "${_EXEC}/${topdir}/index.cgi"
+    else
+      printf '%s\r\n' 'Status: 404 Not Found' 'Content-Length: 0' ''
+    fi
+    ;;
+esac
diff --git a/l10n.sh b/l10n.sh
new file mode 100755 (executable)
index 0000000..cab5bde
--- /dev/null
+++ b/l10n.sh
@@ -0,0 +1,177 @@
+# Copyright 2014, 2016, 2019, 2021 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+l10n(){
+  local word
+  [ $# -eq 0 ] && read -r word || word="$*"
+  l10n_global "$word"
+}
+
+l10n_global() {
+  case $1 in
+    # Nav Menu
+    cards) printf %s "Teil&shy;neh&shy;mende";;
+    courses) printf %s "Kurse";;
+
+    # VCF Default
+    PHOTO) printf %s "Foto";;
+    LOGO) printf %s "Logo";;
+    FN) printf %s "Voller Name";;
+    N) printf %s "Name";;
+    n_pre) printf %s "Titel";;
+    n_first) printf %s "Vorname";;
+    n_middle) printf %s "Mittel&shy;namen";;
+    n_last) printf %s "Nachname";;
+    n_post) printf %s "Zusätze";;
+    NICKNAME) printf %s "Spitz&shy;name";;
+    SOUND) printf %s "Aus&shy;sprache";;
+    GENDER) printf %s "Ge&shy;schlecht";;
+    KIND) printf %s "Typ";;
+    TITLE) printf %s "Beruf";;
+    ROLE) printf %s "Position";;
+    ORG) printf %s "Orga&shy;ni&shy;sation";;
+    MEMBER) printf %s "Mitglied";;
+    CATEGORIES) printf %s "Kategorien";;
+    ANNIVERSARY) printf %s "Jubiläum";;
+    BDAY) printf %s "Geburtstag";;
+    EMAIL) printf %s "E-Mail";;
+    TEL) printf %s "Telefon";;
+    teltype) printf %s "Anschluss&shy;typ:";;
+    TYPE=HOME) printf %s "Privat";;
+    TYPE=WORK) printf %s "Geschäft&shy;lich";;
+    TYPE=CELL) printf %s "Mobil";;
+    TYPE=FAX) printf %s "Fax";;
+    IMPP) printf %s "Chat";;
+    ADR) printf %s "Anschrift";;
+    URL) printf %s "Webseite";;
+    LANG) printf %s "Sprache";;
+    NOTE) printf %s "Notiz";;
+    RELATED) printf %s "Kontakte";;
+
+    # ICS Default
+    SUMMARY) printf "Bezeichnung";;
+    COMMENT) printf "Kommentar";;
+    DTSTART) printf "Beginn";;
+    DURATION) printf "Dauer";;
+    RRULE) printf "Regelmäßigkeit";;
+    DAILY) printf "Tage";;
+    WEEKLY) printf "Wochen";;
+    MONTHLY) printf "Monate";;
+    YEARLY) printf "Jahre";;
+    sDAILY) printf "Täglich";;
+    sWEEKLY) printf "Wöchentlich";;
+    sMONTHLY) printf "Monatlich";;
+    sYEARLY) printf "Jährlich";;
+
+    # UI labels
+    year) printf %s "Jahr";;
+    month) printf %s "Monat";;
+    day) printf %s "Tag";;
+    edit) printf %s "Bearbeiten";;
+    edit_categories) printf %s "Kategorien Bearbeiten";;
+    vcf_export) printf %s "Vcard Exportieren";;
+    control) printf %s "Aktionen";;
+    delete) printf %s "entfernen";;
+    edit_update) printf %s "Daten übernehmen";;
+    edit_cancel) printf %s "Abbrechen";;
+    edit_delete) printf %s "Eintrag löschen";;
+    edit_addfieldtext) printf %s "Neues Feld";;
+    edit_addfield) printf %s "+";;
+    edit_deletefield) printf %s "X";;
+
+    filter_label) printf %s "Filter";;
+    filter_item) printf %s "Eingrenzung nach";;
+    filter_placeholder) printf %s "Begriffe zur Eingrenzung eingeben";;
+    filter_type) printf %s "Filter&shy;typ";;
+    filter_order) printf %s "Sortie&shy;rung";;
+    filter_any) printf %s "Alles";;
+    filter_name) printf %s "Name";;
+    filter_firstname) printf %s "Vor&shy;name";;
+    filter_lastname) printf %s "Nach&shy;name";;
+    filter_street) printf %s "Straße";;
+    filter_zip) printf %s "PLZ.";;
+    filter_TEL) printf %s "Tele&shy;fon";;
+    filter_BDAY) printf %s "Geburts&shy;jahr";;
+    filter_bdate) printf %s "Geburts&shy;datum";;
+    filter_course) printf %s "Kurs";;
+    filter_CATEGORIES) printf %s "Kate&shy;go&shy;rien";;
+    filter_more) printf %s "+ mehr Filter";;
+    filter_apply) printf %s "Filtern";;
+    filter_cancel) printf %s "Filter löschen";;
+    export_csv) printf %s "Liste als CSV-Datei";;
+
+    # UI Labels Special
+    course_attendance) printf %s "Kurs&shy;teil&shy;nahme";;
+    vcf_seed_label) printf "Anmeld.    Vorn.   Nachn.  Geb. Monat      Geb. Jahr       Tel.    Mobil   ()      EMail   ()      Notiz";;
+  
+    gender_none) printf %s "keine Angabe";;
+    gender_female) printf %s "Weiblich";;
+    gender_male) printf %s "Männlich";;
+    gender_other) printf %s "Sonstiges";;
+  
+    female) printf %s "&#x2640;";;
+    male) printf %s "&#x2642;";;
+    other) printf %s "&#x26A5;";;
+    none) printf %s "&#x26AA;";;
+
+    # Fallback
+    *) printf %s "$word";;
+  esac
+}
+
+l10n_time() {
+  [ $# -eq 0 ] && read -r time || time="$*"
+  printf '%s\n' "$time" |sed -E '
+    s;Monday;Mon\&shy\;tag;g;          s;Mon\.;Mo.;g;
+    s;Tuesday;Diens\&shy\;tag;g;       s;Tue\.;Di.;g;
+    s;Wednesday;Mitt\&shy\;woch;g;     s;Wed\.;Mi.;g;
+    s;Thursday;Don\&shy\;ners\&shy\;tag;g; s;Thu\.;Do.;g;
+    s;Friday;Frei\&shy\;tag;g;         s;Fri\.;Fr.;g;
+    s;Saturday;Sams\&shy\;tag;g;       s;Sat\.;Sa.;g;
+    s;Sunday;Sonn\&shy\;tag;g;         s;Sun\.;So.;g;
+
+    s;January;Ja\&shy\;nu\&shy\;ar;g;          s;Jan\.;Jan.;g;
+    s;February;Fe\&shy\;bru\&shy\;ar;g;                s;Feb\.;Feb.;g;
+    s;March;März;g;                           s;Mar\.;Mär.;g;
+    s;April;April;g;                           s;Apr\.;Apr.;g;
+    s;May;Mai;g;                               s;May\.;Mai.;g;
+    s;June;Juni;g;                             s;Jun\.;Jun.;g;
+    s;July;Juli;g;                             s;Jul\.;Jul.;g;
+    s;August;Au\&shy\;gust;g;                  s;Aug\.;Aug.;g;
+    s;September;Sep\&shy\;tem\&shy\;ber;g;     s;Sep\.;Sep.;g;
+    s;October;Ok\&shy\;to\&shy\;ber;g;         s;Oct\.;Okt.;g;
+    s;November;No\&shy\;vem\&shy\;ber;g;       s;Nov\.;Nov.;g;
+    s;December;De\&shy\;zem\&shy\;ber;g;       s;Dec\.;Dez.;g;
+  '
+}
+
+parse_date() {
+  [ $# -eq 0 ] && read -r date || date="$*"
+
+  case $date in
+    *[0-9].*[0-9].*[0-9])
+      d="${date%%.*}"
+      y="${date##*.}"
+      m="${date%.*}"
+      m="${m#*.}"
+      [ $y -lt 100 ] && y="$((y + 2000))"
+      date -d "$(printf '%04i-%02i-%02i' "$y" "$m" "$d")" +%F
+      ;;
+    *) date -d "$date" +%F
+      ;;
+  esac
+}
diff --git a/pdiread.sh b/pdiread.sh
new file mode 100755 (executable)
index 0000000..08fbaec
--- /dev/null
@@ -0,0 +1,196 @@
+#!/bin/zsh
+
+# Copyright 2014 - 2018 Paul Hänsch
+#
+# This file is part of Confetti.
+# 
+# Confetti is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# Confetti is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with Confetti.  If not, see <http://www.gnu.org/licenses/>. 
+
+# This is a parsing library for the Personal Data Interchange format (PDI)
+# PDI is the format for encoding VCard (.vcf) and iCalendar (.ics) files
+
+[ -n "$include_pdi" ] && return 0
+include_pdi="$0"
+
+BR='
+'
+
+unescape() {
+  local unescape='s;(^(\\\\)*|[^\\](\\\\)*)\\n;\1\n;g; s;\\(.);\1;g'
+  if [ $# -eq 0 ]; then
+    sed -E "$unescape"
+  else
+    printf %s "$*" \
+    | sed -E "$unescape"
+  fi
+}
+
+pdi_load() {
+  # normalise PDI file for processing with pdi_* functions
+  # functions in this library can only be applied to normalised data
+  # Usage example:
+  # data="$(pdi_load file.vcf)"
+
+  sed -srn '
+    # === Read entire file into buffer ===
+    :X $bY; N; bX; :Y s;^.*$;\n&\n;;
+
+    # === Join continuing lines, strip trailing CRs ===
+    s;\r*\n[ \t];;g;
+    s;\r*\n;\n;g;
+
+    # === turn property names to upper case, strip group names ===
+    s;\n([^;:\.\n]+\.)([^;:\n]+);\n\2;g;
+    :upcase
+    s;(\n[^;:]*)a;\1A;g; s;(\n[^;:]*)b;\1B;g; s;(\n[^;:]*)c;\1C;g; s;(\n[^;:]*)d;\1D;g; s;(\n[^;:]*)e;\1E;g;
+    s;(\n[^;:]*)f;\1F;g; s;(\n[^;:]*)g;\1G;g; s;(\n[^;:]*)h;\1H;g; s;(\n[^;:]*)i;\1I;g; s;(\n[^;:]*)j;\1J;g;
+    s;(\n[^;:]*)k;\1K;g; s;(\n[^;:]*)l;\1L;g; s;(\n[^;:]*)m;\1M;g; s;(\n[^;:]*)n;\1N;g; s;(\n[^;:]*)o;\1O;g;
+    s;(\n[^;:]*)p;\1P;g; s;(\n[^;:]*)q;\1Q;g; s;(\n[^;:]*)r;\1R;g; s;(\n[^;:]*)s;\1S;g; s;(\n[^;:]*)t;\1T;g;
+    s;(\n[^;:]*)u;\1U;g; s;(\n[^;:]*)v;\1V;g; s;(\n[^;:]*)w;\1W;g; s;(\n[^;:]*)x;\1X;g; s;(\n[^;:]*)y;\1Y;g;
+    s;(\n[^;:]*)z;\1Z;g;
+    t upcase;
+
+    # === Insert empty attribute fields where no attributes are present ===
+    s;\n([^;:]+):;\n\1\;:;g;
+
+    # === Unscramble aggregated fields ===
+    :disag
+    s;\n([^:\n]+:)(([^\n]*[^\])?(\\\\)*),;\n\1\2\n\1;;
+    t disag;
+
+    # === Insert FN when only N is present ===
+    /\nFN[;:]/!{
+      s,\nN(;[^:]*)?:([^;\n]*);([^;\n]*);([^;\n]*);([^;\n]*);([^;\n]*);?\n,&FN;:\5 \3 \4 \2 \6\n,;
+      :despace
+      s,(\nFN;:[^\n]*)  ([^\n]*\n),\1 \2,;
+      s,(\nFN;:) ([^\n]*\n),\1\2,;
+      s,(\nFN;:[^\n]*) (\n),\1\2,;
+      t despace;
+    }
+    /\nFN[;:]/!{ s,\n(N[;:][^\n]*)\n,&F\1\n,; }  # Fallback
+
+    # === Normalise various known vendor properties ===
+                s;\nX-MS-CARDPICTURE(\;|:);\nPHOTO\1;g;
+                        s;\nX-GENDER(\;|:);\nGENDER\1;g;
+                   s;\nX-ANNIVERSARY(\;|:);\nANNIVERSARY\1;g;
+         s;\nX-EVOLUTION-ANNIVERSARY(\;|:);\nANNIVERSARY\1;g;
+    s;\nX-KADDRESSBOOK-X-ANNIVERSARY(\;|:);\nANNIVERSARY\1;g;
+            s;\nX-EVOLUTION-BLOG-URL(\;|:);\nURL\1;g;
+                           s;\nAGENT(\;|:);\nRELATED\;VALUE=text\;TYPE=agent\1;g;
+                     s;\nX-ASSISTANT(\;|:);\nRELATED\;VALUE=text\;TYPE=assistant\1;g;
+           s;\nX-EVOLUTION-ASSISTANT(\;|:);\nRELATED\;VALUE=text\;TYPE=assistant\1;g;
+ s;\nX-KADDRESSBOOK-X-ASSISTANTSNAME(\;|:);\nRELATED\;VALUE=text\;TYPE=assistant\1;g;
+                       s;\nX-MANAGER(\;|:);\nRELATED\;VALUE=text\;TYPE=manager\1;g;
+             s;\nX-EVOLUTION-MANAGER(\;|:);\nRELATED\;VALUE=text\;TYPE=manager\1;g;
+   s;\nX-KADDRESSBOOK-X-MANAGERSNAME(\;|:);\nRELATED\;VALUE=text\;TYPE=manager\1;g;
+                        s;\nX-SPOUSE(\;|:);\nRELATED\;VALUE=text\;TYPE=spouse\1;g;
+              s;\nX-EVOLUTION-SPOUSE(\;|:);\nRELATED\;VALUE=text\;TYPE=spouse\1;g;
+     s;\nX-KADDRESSBOOK-X-SPOUSENAME(\;|:);\nRELATED\;VALUE=text\;TYPE=spouse\1;g;
+
+    # === Normalise obsolete vendor IM properties ===
+            s;\nX-AIM((\;[A-Za-z0-9-]+|\;[A-Za-z0-9-]+=([^;,:"]+|"[^"]+")(,[^;,:"]+|,"[^"]+")*)*):;\nIMPP\1:aim:;g;
+            s;\nX-ICQ((\;[A-Za-z0-9-]+|\;[A-Za-z0-9-]+=([^;,:"]+|"[^"]+")(,[^;,:"]+|,"[^"]+")*)*):;\nIMPP\1:aim:;g;
+    s;\nX-GOOGLE-TALK((\;[A-Za-z0-9-]+|\;[A-Za-z0-9-]+=([^;,:"]+|"[^"]+")(,[^;,:"]+|,"[^"]+")*)*):;\nIMPP\1:xmpp:;g;
+         s;\nX-JABBER((\;[A-Za-z0-9-]+|\;[A-Za-z0-9-]+=([^;,:"]+|"[^"]+")(,[^;,:"]+|,"[^"]+")*)*):;\nIMPP\1:xmpp:;g;
+            s;\nX-MSN((\;[A-Za-z0-9-]+|\;[A-Za-z0-9-]+=([^;,:"]+|"[^"]+")(,[^;,:"]+|,"[^"]+")*)*):;\nIMPP\1:msn:;g;
+          s;\nX-YAHOO((\;[A-Za-z0-9-]+|\;[A-Za-z0-9-]+=([^;,:"]+|"[^"]+")(,[^;,:"]+|,"[^"]+")*)*):;\nIMPP\1:ymsgr:;g;
+            s;\nX-SIP((\;[A-Za-z0-9-]+|\;[A-Za-z0-9-]+=([^;,:"]+|"[^"]+")(,[^;,:"]+|,"[^"]+")*)*):(sip:)?;\nIMPP\1:sip:;g;
+
+    # === Update obsolete LABEL property ===
+    s;\nLABEL((\;[A-Za-z0-9-]+|\;[A-Za-z0-9-]+=([^;,:"]+|"[^"]+")(,[^;,:"]+|,"[^"]+")*)*):(.*)\n;\nADR\1\;LABEL="\5":\n;g;
+
+    p;' "$@"
+}
+
+pdi_count(){
+  local card="$1" name="$2" rc='' cnt=0
+  while rc="${card#*${BR}${name};}"; do
+    [ "${rc}" != "${card}" ] || break
+    card="$rc"
+    cnt=$(($cnt + 1))
+  done
+  printf %i\\n $cnt
+}
+
+pdi_attrib(){
+  local card=":$1" name="$2" cnt="${3:-1}" attr="$4"
+  while [ $cnt -gt 0 ]; do
+    [ "${card#*${BR}${name};}" = "$card" ] && return 1
+    card="${card#*${BR}${name};}"
+    cnt=$((cnt - 1))
+  done
+  card="${card%%:*}"
+  if [ "$attr" ]; then
+    case $card in
+      *\;"$attr"=*) card="${card#*;${attr}=}";;
+      "$attr"=*) card="${card#${attr}=}";;
+      "$attr"|*\;"$attr"|"$attr"\;*|*\;"$attr"\;*) return 0;;
+      *) return 1;;
+    esac
+    case $card in
+      \"*\"\;*|\'*\'\;*)
+        card="${card#[\"\']}"; card="${card%%[\"\'];*}"
+        ;;
+      \"*\"|\'*\')
+        card="${card#[\"\']}"; card="${card%%[\"\']}"
+        ;;
+      *\;*) card="${card%%;*}";;
+    esac
+  fi
+  printf %s\\n "${card}"
+}
+
+pdi_value(){
+  local card="${BR}$1" name="$2" cnt="${3:-1}"
+  while [ "$cnt" -gt 0 ]; do
+    [ "${card#*${BR}${name};*:}" = "$card" ] && return 1
+    card="${card#*${BR}${name};*:}"
+    cnt=$((cnt - 1))
+  done
+  printf %s\\n "${card%%${BR}*}"
+}
+
+pdi_update_value(){
+  local card="${BR}$1" name="$2" cnt="$3" val="$4"
+  while [ "$cnt" -gt 0 ]; do
+    if [ "${card#*${BR}${name};*:}" = "${card}" ]; then
+       printf '%s\n%s;:' "${card%${BR}END;:VCARD*}" "${name}"
+       card="${BR}END;:VCARD"
+       break;
+    else
+       printf '%s\n%s;' "${card%%${BR}${name};*}" "${name}"
+       card="${card#*${BR}${name};}"
+       printf '%s:' "${card%%:*}"
+       card="${card#*:}"
+    fi
+    cnt=$((cnt - 1))
+  done
+  printf '%s\n%s\n' "$val" "${card#*${BR}}"
+}
+
+pdi_update_attrib(){
+  local card="${BR}$1" name="$2" cnt="$3" val="$4"
+  while [ "$cnt" -gt 0 ]; do
+    if [ "${card#*${BR}${name};*:}" = "${card}" ]; then
+       printf '%s\n%s;' "${card%${BR}END;:VCARD*}" "${name}"
+       card=":${BR}END;:VCARD"
+       break;
+    else
+       printf '%s\n%s;' "${card%%${BR}${name};*}" "${name}"
+       card="${card#*${BR}${name};}"
+    fi
+    cnt=$((cnt - 1))
+  done
+  printf '%s:%s\n' "$val" "${card#*:}"
+}
diff --git a/session_lock.sh b/session_lock.sh
new file mode 100644 (file)
index 0000000..de1641a
--- /dev/null
@@ -0,0 +1,60 @@
+#!/bin/sh
+
+[ "$include_session_lock" ] && return 0
+include_session_lock="$0"
+
+SLOCK(){
+  local file="$1";
+  local timeout="${2-900}"
+  local lockdir="$_DATA/lock/${file#$_DATA}"; lockdir="${lockdir%/}"
+  local ovlock="${lockdir%/*}/delete.${lockdir##*/}"
+  local tempfile="$lockdir/${SESSION_ID}"
+  local lockexpire=$(( $(date +%s) - timeout ))
+
+  mkdir -p "${lockdir%/*}"
+
+  if [ -e "$lockdir" ] \
+     && [ "$(stat -c %Y "$lockdir")" -lt "$lockexpire" ] \
+     && mkdir "$ovlock"; then
+    [ "$(stat -c %Y "$lockdir")" -lt "$lockexpire" ] \
+    && rm -r "$lockdir"
+    rmdir "$ovlock"
+  fi
+
+  printf '%s\n' "$tempfile"
+  if mkdir "$lockdir" 2>&-; then
+    cp "$file" "$tempfile"
+    return 0
+  else
+    return 1
+  fi
+}
+
+CHECK_SLOCK(){
+  local file="$1";
+  local lockdir="$_DATA/lock/${file#$_DATA}"; lockdir="${lockdir%/}"
+  local tempfile="$lockdir/${SESSION_ID}"
+
+  printf '%s\n' "$tempfile"
+  if [ -f "$tempfile" ]; then
+    touch "$lockdir"
+    return 0
+  else
+    return 1
+  fi
+}
+
+RELEASE_SLOCK(){
+  local file="$1";
+  local lockdir="$_DATA/lock/${file#$_DATA}"; lockdir="${lockdir%/}"
+  local ovlock="${lockdir%/*}/delete.${lockdir##*/}"
+  local tempfile="$lockdir/${SESSION_ID}"
+
+  if [ -f "$tempfile" ] && mkdir "$ovlock"; then
+    [ -f "$tempfile" ] && rm -r "$lockdir"
+    rmdir "$ovlock"
+    return 0
+  else
+    return 1
+  fi
+}
diff --git a/style.css b/style.css
new file mode 100644 (file)
index 0000000..06aacdd
--- /dev/null
+++ b/style.css
@@ -0,0 +1,456 @@
+* {
+  position: relative;
+  box-sizing: border-box;
+  max-width: 100%;
+  font-family: sans-serif;
+  font-weight: normal;
+  font-size: initial;
+  font-style: normal;
+  text-decoration: none;
+  line-height: 1.5em;
+  color: inherit; background: transparent;
+  padding: 0; margin: 0;
+  border: none;
+}
+
+body {
+  color: #000; background-color: #FFF;
+}
+
+/* ======= GENERIC HTML STYLES =======*/
+
+p { margin-bottom: 1em; }
+
+a {
+  text-decoration: underline;
+  font-style: italic;
+  color: #068;
+}
+
+i, em { font-style: italic; }
+b, strong { font-weight: bolder; }
+ul, ol { margin-left: 1.125em; }
+table th { font-weight: bold; }
+
+h1, h2, h3 {
+  font-weight: bold;
+  margin-top: 1em;
+  margin-bottom: .5em;
+}
+
+h1:first-child, h2:first-child, h3:first-child,
+p + h1, p + h2, p + h3 {
+  margin-top: 0;
+}
+
+h4, h5, h6, form legend {
+  font-weight: bolder;
+  margin-bottom: .25em;
+}
+
+h1 { font-size: 1.5em; }
+h2 { font-size: 1.125em; }
+
+select, input, button, textarea,
+.control a {
+  display: inline-block;
+  background-color: #FFF;
+  border: 1pt solid;
+  padding: .25em .75em;
+  vertical-align: text-bottom;
+}
+.control a {
+  color: inherit;
+  font-style: normal;
+  text-decoration: none;
+}
+
+select { padding: .375em 0; }
+
+input[type=radio], input[type=checkbox] {
+  vertical-align: baseline;
+}
+input[type=number] { text-align: right; padding-right: 0; }
+
+button, input[type=button],
+.control a {
+  box-shadow: .125em .125em .25em;
+  cursor: pointer;
+}
+input[type=radio], input[type=checkbox],
+input[type=radio] + label, input[type=checkbox] + label {
+  cursor: pointer;
+}
+
+label { margin-right: .75em; }
+input + label {
+  margin-left: .375em;
+}
+
+/* ====== COMMON ELEMENTS ======*/
+
+body > ul.menu {
+  display: block;
+  height: 1.75em;
+  margin: 0; -padding: 0 .5em;
+  list-style: none;
+  color: #CCC;
+  background-color: #444;
+  box-shadow: inset 0 -.25em .25em #000;
+  overflow: hidden;
+  z-index: 3;
+}
+
+body > .menu li {
+  display: inline-block;
+}
+body > .menu a {
+  color: inherit;
+  padding: .5em 3em;
+  box-shadow: inset 0 0 .5em #000;
+}
+body.cards > .menu a[href="/cards/"],
+body.courses > .menu a[href="/courses/"] {
+  color: #000;
+  background-color: #FFF;
+  box-shadow: none;
+}
+
+/* =========== FILTER AND SEARCH Headers ========= */
+
+form.categories,
+form.search, form.sort, form.filter, form.newcard, form.newcourses {
+  margin-top: 1em; padding: 0 1em;
+  z-index: 1;
+}
+form.filter > h1 { display: none; }
+
+form.filter fieldset { margin-top: .5em; }
+form.filter fieldset.item + fieldset.item legend { display: none; }
+form.filter fieldset.item input[type=radio] { display: none; }
+form.filter fieldset.item input[type=radio] + label {
+  display: table-cell;
+  padding: .5em 1em;
+  background-color: #EEE;
+  border-style: solid;
+  border-width: .5pt .25pt 0 .25pt;
+  white-space: normal;
+}
+form.filter fieldset.item input[type=radio] + label:first-of-type {
+  border-left: 1pt solid;
+}
+form.filter fieldset.item input[type=radio]:checked + label {
+  position: relative;
+  background-color: #FFF;
+  box-shadow: .125em -.125em .125em #888;
+  z-index: 1;
+}
+form.filter fieldset.item input[type=text],
+form.filter fieldset.item fieldset.courses,
+form.filter fieldset.item fieldset.categories {
+  position: relative;
+  display: block;
+  width: 100%;
+  margin-top: -1pt;
+  padding: .25em .75em;
+  border: 1pt solid;
+  box-shadow: .125em .125em .25em #888;
+}
+form.filter fieldset.item fieldset.courses,
+form.filter fieldset.item fieldset.categories { display: none; }
+form.filter fieldset.item input[value=course]:checked ~ input[type=text],
+form.filter fieldset.item input[value=CATEGORIES]:checked ~ input[type=text] { display: none; }
+form.filter fieldset.item input[value=course]:checked ~ fieldset.courses,
+form.filter fieldset.item input[value=CATEGORIES]:checked ~ fieldset.categories { display: block; }
+
+form.filter fieldset.order legend {
+  float: left; margin-right: 1em;
+}
+
+form.filter fieldset label,
+form.filter fieldset a { white-space: pre;}
+form.filter button[type=submit] {
+  margin-top: .5em; margin-bottom: .5em;
+}
+
+form.filter button[value=export_csv] { margin-left: 1em; }
+
+body.courses form .order { display: inline-block; margin-right: 2em;}
+
+body.cards form.newcard { display: flex; }
+body.cards form.newcard input[name=seed] { flex: 1; }
+
+
+/* ============ LIST ITEMS, Generic ============= */
+
+body > form,
+div.card,
+div.course {
+  position: relative;
+  width: 98%; width: calc(100% - 2em);
+  margin-left: auto; margin-right: auto;
+  margin-bottom: 1em;
+  box-shadow: .125em .125em .25em;
+  z-index: 1;
+}
+
+/* HACK: put anchor point 10em above card and highlight target element */
+    div:target { box-shadow: none; z-index: 0; }
+    div:target:before {
+      content: '';
+      display: block;
+      margin-top: -10em;
+      height: 10em;
+      visibility: hidden;
+    }
+    div:target:after {
+      content: '';
+      display: block;
+      position: absolute;
+      left: 0; right: 0;
+      top: 10em; bottom: 0;
+      box-shadow: .125em .125em .25em;
+      animation: highlight 4s;
+      z-index: -1;
+    }
+    @keyframes highlight { from { background-color: #FF0; } to { background-color: transparent; } }
+/**/
+
+div .section, form .section {
+  display: block;
+  vertical-align: top;
+  padding: 0 1em;
+  overflow: hidden;
+  word-break: break-word;
+}
+
+div .section :last-child, form .section :last-child {
+  margin-bottom: 1em;
+}
+
+div .section h2, form .section h2,
+div .section h3, form .section h3 { 
+  border-bottom: 1pt solid #EEE;
+}
+div .control, form .control {
+  background-color: #EEE;
+  padding: .25em;
+  text-align: right;
+}
+
+@media(min-width: 60em) {
+  div .section, form .section {
+    display: table-cell;
+    width: calc(100% / 10);
+  }
+  div .section :last-child { margin-bottom: 0; }
+  div .control, form .control {
+    background-color: transparent;
+  }
+  div .section:nth-of-type(2n) {
+    background-color: #EEE;
+  }
+}
+@media(min-width: 80em) {
+  div .control, form .control {
+    display: table-cell;
+    width: calc(100% / 10);
+  }
+  div .control .item, form .control .item {
+    display: block;
+    margin-bottom: .25em;
+  }
+}
+
+div .section .item, form .section .item,
+form .section.attendance > label {
+  display: block;
+  width: 100%;
+}
+
+div .section .item.NOTE {
+  white-space: pre-wrap;
+}
+
+form .section .item {
+  margin-bottom: .25em;
+}
+
+form .section button[value^=addfield] {
+  font-size: .75em;
+  margin-top: .5em; padding: 0 .375em;
+}
+
+/* HACK: "responsive" Delete Button above each field */
+    form input.delete { display: none; }
+    form input.delete + label {
+      float: right;
+      font-size: .75em;
+      line-height: 1;
+      max-width: 1.75em; height: 1.125em; overflow: hidden;
+      color: #FBB; background-color: #444;
+      margin: 0; padding: .125em .5em 0 .5em;
+      border-radius: 4pt 4pt 0 0;
+      transition: max-width .3s;
+    }
+    form input.delete + label:before  { content: '\274c '; margin-right: .5em; }
+    form input.delete + label:hover   { max-width: 10em; }
+    -form input.delete + label:hover:before { content: ''; }
+    -form input.delete + label:hover:after  { content: ' \274c'; }
+    form input.delete:checked + label,
+    form input.delete:checked + label + *,
+    form input.delete:checked + label + .teltype + .TEL {
+      display: none;
+    }
+/**/
+
+
+/* ====== right hand Control Buttons on list items ====== */
+
+form .control {
+  position: relative;
+  padding-left: 11em;
+  padding-top: 1.5em;
+}
+form .control .item {
+  display: inline-block;
+  margin-bottom: .25em;
+  vertical-align: text-bottom;
+}
+
+/* Combined Select/Submit Box */
+    form .control .item.newfield { box-shadow: .125em .125em .25em; }
+    form .control .item.newfield select { margin-right: -1pt; }
+    form .control .item.newfield button { box-shadow: none; }
+/**/
+
+/* HACK: Delete Checkbox before delete Button */
+    form .control .item.delete {
+      position: absolute;
+      bottom: .375em; left: .25em; width: auto;
+      padding-bottom: calc(2.25em + 2pt);
+    }
+    
+    form .control .item.delete input + label + button {
+      display: none;
+      position: absolute;
+      bottom: 0; width: 100%;
+      color: #800;
+      background-color: #FEE;
+      z-index: 1;
+    }
+    form .control .item.delete:after {
+      content: attr(label);
+      display: block;
+      position: absolute;
+      bottom: 0; width: 100%;
+      text-align: center;
+      color: #BAA;
+      padding: .25em 0;
+      border: 1pt solid;
+      box-shadow: .125em .125em .25em;
+    }
+    form .control .item.delete input:checked + label + button { display: block; }
+/**/
+
+@media(min-width: 80em) {
+  form .control { padding: .25em; min-height: 16em; }
+  form .control .item { width: 100%; }
+  form .control .item.newfield select { width: calc(100% - 2.5em); }
+  form .control .item.delete { bottom: .125em; right: .25em; }
+}
+
+/* ======= LIST ITEMS, Courses ======= */
+
+form.course .dtstart input[name=DTS_YEAR],
+form.course .dtstart select[name=DTS_MONTH] { width: calc(50% - 1.25em); }
+form.course .dtstart input[name=DTS_YEAR] { margin-right: -.375em; }
+form.course .dtstart table { width: 100%; margin: 1em 0; }
+form.course .dtstart table td { text-align: right; -border: .5pt solid; }
+form.course .dtstart table input[type=radio] { display: none; }
+form.course .dtstart table input[type=radio] + label {
+  display: block;
+  width: 100%;
+  margin: 0; padding: 0 3pt;
+}
+form.course .dtstart table input[type=radio]:checked + label {
+  font-weight: bold;
+  padding: 0 2pt;
+  box-shadow: .125em .125em .25em;
+}
+
+form.course .dtstart label.DTSTIME {
+  display: inline-block;
+  font-weight: bold;
+  margin: 0;
+  width: calc(100% - 7.875em);
+}
+form.course .dtstart input[name=DTS_HOUR],
+form.course .dtstart input[name=DTS_MINUTE] {
+  vertical-align: baseline;
+  width: 3.5em;
+  margin-bottom: 0;
+}
+
+form.course .recur .item { white-space: nowrap; }
+form.course .recur .item > * { margin-bottom: 0; vertical-align: baseline; }
+form.course .recur input[name=RRULE_INTERVAL],
+form.course .recur input[name=RRULE_COUNT],
+form.course .recur input[name=RRULE_UMONTH],
+form.course .recur input[name=RRULE_UDAY] { width: 3.5em; }
+form.course .recur input[name=RRULE_UYEAR] { width: 4.5em; }
+form.course .recur input[name=RRULE_UYEAR],
+form.course .recur input[name=RRULE_UMONTH],
+form.course .recur input[name=RRULE_UDAY] {
+  margin-right: -.375em;
+}
+
+form.course .attendance div.attendance {
+  max-height: 16em;
+  overflow-y: scroll;
+}
+form.course .attendance label {
+  display: inline-block;
+  max-width: calc(100% - 2em);
+  vertical-align: top;
+  margin-bottom: 0;
+}
+form.course .attendance input { margin-top: .375em; }
+
+/* ======== Categories Page ======== */
+
+body.categories form ul { list-style: none; margin: 0; }
+
+form.categories li {
+  display: inline-block;
+  background-color: #EEE;
+  margin-right: .5em; margin-bottom: .5em;
+  padding-left: .5em;
+  box-shadow: .125em .125em .25em;
+}
+form.categories li button[name=remove] {
+  font-size: .75em;
+  width: 2.5em;
+  background-color: #FBB;
+  overflow: hidden;
+  white-space: pre;
+}
+form.categories li button[name=remove]:before {
+  content: '\274C ';
+  margin-right: 3em;
+}
+
+form.categories li:last-child { padding-left: 0 }
+
+body.categories form.namelist ul.namelist > li:nth-of-type(2n + 1) { background-color: #EEE; }
+body.categories form.namelist ul.namelist > li h2,
+body.categories form.namelist ul.namelist > li ul {
+  display: inline-block;
+}
+body.categories form.namelist ul.namelist > li h2 {
+  width: 20%;
+  min-width: 10em;
+}
+body.categories form.namelist ul.namelist > li ul li {
+  display: inline-block;
+}