commit 5ebc506921a86f3413deef20631f53f44b8fcb4b
Author: Matthieu Bessat
Date: Fri Jan 19 16:39:49 2024 +0100
initial commit
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1bfaa3e
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,10 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+charset = utf-8
+indent_style = tab
+indent_size = tab
+tab_width = 4
diff --git a/.fossil-settings/allow-symlinks b/.fossil-settings/allow-symlinks
new file mode 100644
index 0000000..e8fd903
--- /dev/null
+++ b/.fossil-settings/allow-symlinks
@@ -0,0 +1 @@
+on
\ No newline at end of file
diff --git a/.fossil-settings/ignore-glob b/.fossil-settings/ignore-glob
new file mode 100644
index 0000000..b0917d5
--- /dev/null
+++ b/.fossil-settings/ignore-glob
@@ -0,0 +1,14 @@
+src/data/*
+src/*.tar.gz
+src/*.asc
+src/*.log
+src/include/lib/KD2
+src/debug_sql.sqlite
+src/modules/*
+src/config.local.php
+build/windows/*.exe
+build/windows/php.zip
+build/windows/install_dir
+build/*.tar.gz*
+build/debian/*.deb
+src/psalm.phar
\ No newline at end of file
diff --git a/.fossil-settings/manifest b/.fossil-settings/manifest
new file mode 100644
index 0000000..e8fd903
--- /dev/null
+++ b/.fossil-settings/manifest
@@ -0,0 +1 @@
+on
\ No newline at end of file
diff --git a/.fslckout b/.fslckout
new file mode 100644
index 0000000..747eed6
Binary files /dev/null and b/.fslckout differ
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..43df021
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+src/data
diff --git a/.php-version b/.php-version
new file mode 100644
index 0000000..5029165
--- /dev/null
+++ b/.php-version
@@ -0,0 +1 @@
+8.1.26
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..73fc10c
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,21 @@
+language: php
+php:
+ - '7.2'
+ - '7.3'
+ - '7.4'
+
+install:
+ - make -C src deps
+
+script:
+ - php tests/run.php
+
+notifications:
+ irc:
+ channels:
+ - "chat.freenode.net#garradin"
+ template:
+ - "%{build_number} by %{author} on %{branch}: %{message} "
+ - "Build details: %{build_url}"
+ use_notice: false
+ skip_join: true
\ No newline at end of file
diff --git a/COPYING b/COPYING
new file mode 100644
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.
+ 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.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..76246de
--- /dev/null
+++ b/README.md
@@ -0,0 +1,32 @@
+# Paheko - Le gestionnaire d'association
+
+Paheko est un logiciel de gestion d'association.
+
+Plus d'infos sur le site de développement ici : [fossil.kd2.org/paheko](https://fossil.kd2.org/paheko/)
+
+[Documentation développeuse⋅développeur](https://fossil.kd2.org/paheko/wiki?name=Documentation+d%C3%A9veloppeur)
+
+Il est possible d'essayer gratuitement sur la plateforme [Paheko.cloud](https://paheko.cloud/).
+
+Le code sur Github n'est qu'un miroir, le développement principal se passe sur Fossil, mais les PR sont quand même possibles sur Github.
+
+**PR/Patch :** sauf si c'est une correction de bug, le mieux est de discuter de la modification sur dev(arobase)paheko.cloud avant de proposer le patch, pour qu'il ait plus de chances d'être accepté.
+
+## Licence
+
+GNU Affero GPL v3 (voir fichier COPYING)
+
+Cette licence permet la libre redistribution, utilisation et modification du logiciel.
+
+La seule condition est de re-partager les éventuelles modifications apportées.
+
+Cette clause s'applique même si le logiciel n'est pas distribué et simplement installé sur un serveur.
+
+## Code utilisé
+
+Inclus les bibliothèques suivantes :
+
+* [KD2fw](https://fossil.kd2.org/kd2fw/) - Copyright : 2001-2022+ BohwaZ - Licence : GNU AGPL v3
+* [Gibberish AES](https://github.com/mdp/gibberish-aes) - Copyright : Mark Percival 2008 - http://markpercival.us - Licence : MIT
+* [Parsedown](https://github.com/erusev/parsedown) - Copyright Emanuil Rusev - License MIT
+* [Unzipit.js](https://github.com/greggman/unzipit) - Copyright (c) 2019 Gregg Tavares 2014 Josh Wolfe - License MIT
\ No newline at end of file
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..8f2b727
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,15 @@
+# Security Policy
+
+We take the security of Garradin very seriously.
+
+Nous prenons la sécurité de Garradin au sérieux.
+
+## Supported Versions
+
+Only the latest stable branch is supported.
+
+## Reporting a Vulnerability
+
+If you find a security issue, please contact us: security@garradin.eu
+
+Vous pouvez nous contacter à l'adresse e-mail ci-dessus si vous trouvez un problème de sécurité.
diff --git a/archives/0.7.0_migration.sql b/archives/0.7.0_migration.sql
new file mode 100644
index 0000000..903b4f3
--- /dev/null
+++ b/archives/0.7.0_migration.sql
@@ -0,0 +1,64 @@
+CREATE TABLE plugins_signaux
+-- Association entre plugins et signaux (hooks)
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (id),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE compta_rapprochement
+-- Rapprochement entre compta et relevés de comptes
+(
+ operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id),
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ auteur INTEGER NOT NULL REFERENCES membres (id)
+);
+
+CREATE TABLE fichiers
+-- Données sur les fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
+ type TEXT NULL, -- Type MIME
+ image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
+ datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Date d'ajout ou mise à jour du fichier
+ id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id)
+);
+
+CREATE INDEX fichiers_date ON fichiers (datetime);
+
+CREATE TABLE fichiers_contenu
+-- Contenu des fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
+ taille INTEGER NOT NULL, -- Taille en octets
+ contenu BLOB NULL
+);
+
+CREATE UNIQUE INDEX fichiers_hash ON fichiers_contenu (hash);
+
+CREATE TABLE fichiers_membres
+-- Associations entre fichiers et membres (photo de profil par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES membres (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE fichiers_wiki_pages
+-- Associations entre fichiers et pages du wiki
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES wiki_pages (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE fichiers_compta_journal
+-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES compta_journal (id),
+ PRIMARY KEY(fichier, id)
+);
\ No newline at end of file
diff --git a/archives/0.7.2_migration.sql b/archives/0.7.2_migration.sql
new file mode 100644
index 0000000..9aa7dc1
--- /dev/null
+++ b/archives/0.7.2_migration.sql
@@ -0,0 +1,24 @@
+-- Colonne manquante
+ALTER TABLE rappels_envoyes ADD COLUMN id_rappel INTEGER NULL REFERENCES rappels (id);
+
+-- Un bug a permis d'insérer des comptes avec des lettres minuscules, créant des problèmes
+-- corrigeons donc les comptes pour les mettre en majuscules.
+
+UPDATE compta_comptes SET id = UPPER(id);
+
+-- Le champ id_auteur était à NOT NULL, il faut corriger ça pour pouvoir avoir un rapprochement anonyme
+-- une fois que le membre a été supprimé
+
+CREATE TABLE compta_rapprochement2
+-- Rapprochement entre compta et relevés de comptes
+(
+ id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id),
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ id_auteur INTEGER NULL REFERENCES membres (id)
+);
+
+INSERT INTO compta_rapprochement2 SELECT operation, date, auteur FROM compta_rapprochement;
+
+DROP TABLE compta_rapprochement;
+
+ALTER TABLE compta_rapprochement2 RENAME TO compta_rapprochement;
\ No newline at end of file
diff --git a/archives/0.8.0_migration.sql b/archives/0.8.0_migration.sql
new file mode 100644
index 0000000..2be5f6e
--- /dev/null
+++ b/archives/0.8.0_migration.sql
@@ -0,0 +1,85 @@
+-- Ajouter champ pour OTP
+ALTER TABLE membres ADD COLUMN secret_otp TEXT NULL;
+
+-- Ajouter champ clé PGP
+ALTER TABLE membres ADD COLUMN clef_pgp TEXT NULL;
+
+--------------------------------------------------------------------------------
+-- Mise à jour des tables contenant un champ date pour ajouter la contrainte --
+-- Ceci afin de forcer les champs à contenir un format de date correct --
+-- On en profite pour ajouter les ON DELETE nécessaires --
+--------------------------------------------------------------------------------
+
+-- Convertir les dates UNIX en date Y-m-d, apparemment il y en a encore parfois ?
+UPDATE wiki_pages SET date_creation = datetime(date_creation, "unixepoch") WHERE CAST(date_creation AS INT) = date_creation;
+UPDATE wiki_pages SET date_creation = datetime(date_creation) WHERE datetime(date_creation) != date_creation;
+
+-- Renommage des tables qu'il faut mettre à jour
+ALTER TABLE cotisations_membres RENAME TO cotisations_membres_old;
+ALTER TABLE rappels RENAME TO rappels_old;
+ALTER TABLE rappels_envoyes RENAME TO rappels_envoyes_old;
+ALTER TABLE wiki_pages RENAME TO wiki_pages_old;
+ALTER TABLE wiki_revisions RENAME TO wiki_revisions_old;
+ALTER TABLE compta_categories RENAME TO compta_categories_old;
+ALTER TABLE compta_comptes_bancaires RENAME TO compta_comptes_bancaires_old;
+ALTER TABLE compta_exercices RENAME TO compta_exercices_old;
+ALTER TABLE compta_journal RENAME TO compta_journal_old;
+ALTER TABLE compta_rapprochement RENAME TO compta_rapprochement_old;
+ALTER TABLE fichiers RENAME TO fichiers_old;
+ALTER TABLE membres_operations RENAME TO membres_operations_old;
+ALTER TABLE membres_categories RENAME TO membres_categories_old;
+
+-- Suppression des index pour que les nouveaux soient liés aux nouvelles tables
+DROP INDEX cm_unique;
+DROP INDEX wiki_uri;
+DROP INDEX wiki_revisions_id_page;
+DROP INDEX wiki_revisions_id_auteur;
+DROP INDEX compta_operations_exercice;
+DROP INDEX compta_operations_date;
+DROP INDEX compta_operations_comptes;
+DROP INDEX compta_operations_auteur;
+DROP INDEX fichiers_date;
+
+-- Suppression ancienne table recherche
+DROP TABLE wiki_recherche;
+
+-- Suppression des triggers
+-- Sinon les nouveaux ne seront pas créés sur la nouvelle table
+DROP TRIGGER wiki_recherche_delete;
+DROP TRIGGER wiki_recherche_update;
+DROP TRIGGER wiki_recherche_contenu_insert;
+DROP TRIGGER wiki_recherche_contenu_chiffre;
+
+-- Création des tables mises à jour (et de leurs index)
+.read 0.8.0_schema.sql
+
+-- Copie des données
+INSERT INTO cotisations_membres SELECT * FROM cotisations_membres_old;
+INSERT INTO rappels SELECT * FROM rappels_old;
+INSERT INTO rappels_envoyes SELECT id, id_membre, id_cotisation, id_rappel, date, media FROM rappels_envoyes_old;
+INSERT INTO wiki_pages SELECT * FROM wiki_pages_old;
+INSERT INTO wiki_revisions SELECT * FROM wiki_revisions_old;
+INSERT INTO compta_categories SELECT * FROM compta_categories_old;
+INSERT INTO compta_comptes_bancaires SELECT * FROM compta_comptes_bancaires_old;
+INSERT INTO compta_exercices SELECT * FROM compta_exercices_old;
+INSERT INTO compta_journal SELECT *, NULL FROM compta_journal_old;
+INSERT INTO compta_rapprochement SELECT * FROM compta_rapprochement_old;
+INSERT INTO fichiers SELECT * FROM fichiers_old;
+INSERT INTO membres_operations SELECT * FROM membres_operations_old;
+INSERT INTO membres_categories SELECT id, nom, droit_wiki, droit_membres, droit_compta,
+ droit_inscription, droit_connexion, droit_config, cacher, id_cotisation_obligatoire FROM membres_categories_old;
+
+-- Suppression des anciennes tables
+DROP TABLE cotisations_membres_old;
+DROP TABLE rappels_old;
+DROP TABLE rappels_envoyes_old;
+DROP TABLE wiki_pages_old;
+DROP TABLE wiki_revisions_old;
+DROP TABLE compta_categories_old;
+DROP TABLE compta_comptes_bancaires_old;
+DROP TABLE compta_exercices_old;
+DROP TABLE compta_journal_old;
+DROP TABLE compta_rapprochement_old;
+DROP TABLE fichiers_old;
+DROP TABLE membres_operations_old;
+DROP TABLE membres_categories_old;
diff --git a/archives/0.8.0_schema.sql b/archives/0.8.0_schema.sql
new file mode 100644
index 0000000..f1f2434
--- /dev/null
+++ b/archives/0.8.0_schema.sql
@@ -0,0 +1,390 @@
+CREATE TABLE IF NOT EXISTS config (
+-- Configuration de Garradin
+ cle TEXT PRIMARY KEY NOT NULL,
+ valeur TEXT
+);
+
+-- On stocke ici les ID de catégorie de compta correspondant aux types spéciaux
+-- compta_categorie_cotisations => id_categorie
+-- compta_categorie_dons => id_categorie
+
+CREATE TABLE IF NOT EXISTS membres_categories
+-- Catégories de membres
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+
+ droit_wiki INTEGER NOT NULL DEFAULT 1,
+ droit_membres INTEGER NOT NULL DEFAULT 1,
+ droit_compta INTEGER NOT NULL DEFAULT 1,
+ droit_inscription INTEGER NOT NULL DEFAULT 0,
+ droit_connexion INTEGER NOT NULL DEFAULT 1,
+ droit_config INTEGER NOT NULL DEFAULT 0,
+ cacher INTEGER NOT NULL DEFAULT 0,
+
+ id_cotisation_obligatoire INTEGER NULL REFERENCES cotisations (id) ON DELETE SET NULL
+);
+
+-- Membres de l'asso
+-- Table dynamique générée par l'application
+-- voir Garradin\Membres\Champs.php
+
+CREATE TABLE IF NOT EXISTS membres_sessions
+-- Sessions
+(
+ selecteur TEXT NOT NULL,
+ hash TEXT NOT NULL,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ expire INT NOT NULL,
+
+ PRIMARY KEY (selecteur, id_membre)
+);
+
+CREATE TABLE IF NOT EXISTS cotisations
+-- Types de cotisations et activités
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ id_categorie_compta INTEGER NULL, -- NULL si le type n'est pas associé automatiquement à la compta
+
+ intitule TEXT NOT NULL,
+ description TEXT NULL,
+ montant REAL NOT NULL,
+
+ duree INTEGER NULL, -- En jours
+ debut TEXT NULL, -- timestamp
+ fin TEXT NULL,
+
+ FOREIGN KEY (id_categorie_compta) REFERENCES compta_categories (id)
+);
+
+CREATE TABLE IF NOT EXISTS cotisations_membres
+-- Enregistrement des cotisations et activités
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS cm_unique ON cotisations_membres (id_membre, id_cotisation, date);
+
+CREATE TABLE IF NOT EXISTS membres_operations
+-- Liaision des enregistrement des paiements en compta
+(
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_operation INTEGER NOT NULL REFERENCES compta_journal (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NULL REFERENCES cotisations_membres (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_membre, id_operation)
+);
+
+CREATE TABLE IF NOT EXISTS rappels
+-- Rappels de devoir renouveller une cotisation
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+
+ delai INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel
+
+ sujet TEXT NOT NULL,
+ texte TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS rappels_envoyes
+-- Enregistrement des rappels envoyés à qui et quand
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+ id_rappel INTEGER NULL REFERENCES rappels (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ media INTEGER NOT NULL -- Média utilisé pour le rappel : 1 = email, 2 = courrier, 3 = autre
+);
+
+--
+-- WIKI
+--
+
+CREATE TABLE IF NOT EXISTS wiki_pages
+-- Pages du wiki
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki)
+ titre TEXT NOT NULL,
+ date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation),
+ date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification),
+ parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent
+ revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte)
+ droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe)
+ droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4
+-- Table dupliquée pour chercher une page
+(
+ id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire
+ titre TEXT NOT NULL,
+ contenu TEXT NULL, -- Contenu de la dernière révision
+ FOREIGN KEY (id) REFERENCES wiki_pages(id)
+);
+
+CREATE TABLE IF NOT EXISTS wiki_revisions
+-- Révisions du contenu des pages
+(
+ id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
+ revision INTEGER NULL,
+
+ id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL,
+
+ contenu TEXT NOT NULL,
+ modification TEXT NULL, -- Description des modifications effectuées
+ chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+
+ PRIMARY KEY(id_page, revision)
+);
+
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page);
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur);
+
+-- Triggers pour synchro avec table wiki_pages
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages
+ BEGIN
+ DELETE FROM wiki_recherche WHERE id = old.id;
+ END;
+
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages
+ BEGIN
+ UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id;
+ END;
+
+-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page;
+ END;
+
+-- Si le contenu est chiffré, la recherche n'affiche pas de contenu
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page;
+ END;
+
+/*
+CREATE TABLE wiki_suivi
+-- Suivi des pages
+(
+ id_membre INTEGER NOT NULL,
+ id_page INTEGER NOT NULL,
+
+ PRIMARY KEY (id_membre, id_page),
+
+ FOREIGN KEY (id_page) REFERENCES wiki_pages (id), -- Clé externe obligatoire
+ FOREIGN KEY (id_membre) REFERENCES membres (id) -- Clé externe obligatoire
+);
+*/
+
+--
+-- COMPTA
+--
+
+CREATE TABLE IF NOT EXISTS compta_exercices
+-- Exercices
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ libelle TEXT NOT NULL,
+
+ debut TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(debut) IS NOT NULL AND date(debut) = debut),
+ fin TEXT NULL DEFAULT NULL CHECK (fin IS NULL OR (date(fin) IS NOT NULL AND date(fin) = fin)),
+
+ cloture INTEGER NOT NULL DEFAULT 0
+);
+
+
+CREATE TABLE IF NOT EXISTS compta_comptes
+-- Plan comptable
+(
+ id TEXT NOT NULL PRIMARY KEY, -- peut contenir des lettres, eg. 53A, 53B, etc.
+ parent TEXT NOT NULL DEFAULT 0,
+
+ libelle TEXT NOT NULL,
+
+ position INTEGER NOT NULL, -- position actif/passif/charge/produit
+ plan_comptable INTEGER NOT NULL DEFAULT 1, -- 1 = fait partie du plan comptable, 0 = a été ajouté par l'utilisateur
+ desactive INTEGER NOT NULL DEFAULT 0 -- 1 = compte historique désactivé
+);
+
+CREATE INDEX IF NOT EXISTS compta_comptes_parent ON compta_comptes (parent);
+
+CREATE TABLE IF NOT EXISTS compta_comptes_bancaires
+-- Comptes bancaires
+(
+ id TEXT NOT NULL PRIMARY KEY,
+
+ banque TEXT NOT NULL,
+
+ iban TEXT NULL,
+ bic TEXT NULL,
+
+ FOREIGN KEY(id) REFERENCES compta_comptes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS compta_projets
+-- Projets (compta analytique)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ libelle TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS compta_journal
+-- Journal des opérations comptables
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ libelle TEXT NOT NULL,
+ remarques TEXT NULL,
+ numero_piece TEXT NULL, -- N° de pièce comptable
+
+ montant REAL NOT NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ moyen_paiement TEXT NULL,
+ numero_cheque TEXT NULL,
+
+ compte_debit TEXT NULL, -- N° du compte dans le plan, NULL est utilisé pour une opération qui vient d'un exercice précédent
+ compte_credit TEXT NULL, -- N° du compte dans le plan
+
+ id_exercice INTEGER NULL DEFAULT NULL, -- En cas de compta simple, l'exercice est permanent (NULL)
+ id_auteur INTEGER NULL,
+ id_categorie INTEGER NULL, -- Numéro de catégorie (en mode simple)
+ id_projet INTEGER NULL,
+
+ FOREIGN KEY(moyen_paiement) REFERENCES compta_moyens_paiement(code),
+ FOREIGN KEY(compte_debit) REFERENCES compta_comptes(id),
+ FOREIGN KEY(compte_credit) REFERENCES compta_comptes(id),
+ FOREIGN KEY(id_exercice) REFERENCES compta_exercices(id),
+ FOREIGN KEY(id_auteur) REFERENCES membres(id) ON DELETE SET NULL,
+ FOREIGN KEY(id_categorie) REFERENCES compta_categories(id) ON DELETE SET NULL,
+ FOREIGN KEY(id_projet) REFERENCES compta_projets(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS compta_operations_exercice ON compta_journal (id_exercice);
+CREATE INDEX IF NOT EXISTS compta_operations_date ON compta_journal (date);
+CREATE INDEX IF NOT EXISTS compta_operations_comptes ON compta_journal (compte_debit, compte_credit);
+CREATE INDEX IF NOT EXISTS compta_operations_auteur ON compta_journal (id_auteur);
+
+CREATE TABLE IF NOT EXISTS compta_moyens_paiement
+-- Moyens de paiement
+(
+ code TEXT NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL
+);
+
+--INSERT INTO compta_moyens_paiement (code, nom) VALUES ('AU', 'Autre');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CB', 'Carte bleue');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CH', 'Chèque');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('ES', 'Espèces');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('PR', 'Prélèvement');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('TI', 'TIP');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('VI', 'Virement');
+
+CREATE TABLE IF NOT EXISTS compta_categories
+-- Catégories pour simplifier le plan comptable
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ type INTEGER NOT NULL DEFAULT 1, -- 1 = recette, -1 = dépense, 0 = autre (utilisé uniquement pour l'interface)
+
+ intitule TEXT NOT NULL,
+ description TEXT NULL,
+
+ compte TEXT NOT NULL, -- Compte affecté par cette catégorie
+
+ FOREIGN KEY(compte) REFERENCES compta_comptes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ officiel INTEGER NOT NULL DEFAULT 0,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+ auteur TEXT NULL,
+ url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INTEGER NOT NULL DEFAULT 0,
+ config TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS plugins_signaux
+-- Association entre plugins et signaux (hooks)
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (id),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS compta_rapprochement
+-- Rapprochement entre compta et relevés de comptes
+(
+ id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id) ON DELETE CASCADE,
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+ id_auteur INTEGER NULL REFERENCES membres (id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers
+-- Données sur les fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
+ type TEXT NULL, -- Type MIME
+ image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
+ datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier
+ id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime);
+
+CREATE TABLE IF NOT EXISTS fichiers_contenu
+-- Contenu des fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
+ taille INTEGER NOT NULL, -- Taille en octets
+ contenu BLOB NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash);
+
+CREATE TABLE IF NOT EXISTS fichiers_membres
+-- Associations entre fichiers et membres (photo de profil par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES membres (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_wiki_pages
+-- Associations entre fichiers et pages du wiki
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES wiki_pages (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_compta_journal
+-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES compta_journal (id),
+ PRIMARY KEY(fichier, id)
+);
diff --git a/archives/0.8.3_migration.sql b/archives/0.8.3_migration.sql
new file mode 100644
index 0000000..393340b
--- /dev/null
+++ b/archives/0.8.3_migration.sql
@@ -0,0 +1,11 @@
+-- Ajout d'une clause ON DELETE SET NULL sur la table cotisations
+ALTER TABLE cotisations_membres RENAME TO cotisations_membres_old;
+
+-- Création des tables mises à jour (et de leurs index)
+.read 0.8.3_schema.sql
+
+-- Copie des données
+INSERT INTO cotisations_membres SELECT * FROM cotisations_membres_old;
+
+-- Suppression des anciennes tables
+DROP TABLE cotisations_membres_old;
diff --git a/archives/0.8.3_schema.sql b/archives/0.8.3_schema.sql
new file mode 100644
index 0000000..0e2cd1b
--- /dev/null
+++ b/archives/0.8.3_schema.sql
@@ -0,0 +1,388 @@
+CREATE TABLE IF NOT EXISTS config (
+-- Configuration de Garradin
+ cle TEXT PRIMARY KEY NOT NULL,
+ valeur TEXT
+);
+
+-- On stocke ici les ID de catégorie de compta correspondant aux types spéciaux
+-- compta_categorie_cotisations => id_categorie
+-- compta_categorie_dons => id_categorie
+
+CREATE TABLE IF NOT EXISTS membres_categories
+-- Catégories de membres
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+
+ droit_wiki INTEGER NOT NULL DEFAULT 1,
+ droit_membres INTEGER NOT NULL DEFAULT 1,
+ droit_compta INTEGER NOT NULL DEFAULT 1,
+ droit_inscription INTEGER NOT NULL DEFAULT 0,
+ droit_connexion INTEGER NOT NULL DEFAULT 1,
+ droit_config INTEGER NOT NULL DEFAULT 0,
+ cacher INTEGER NOT NULL DEFAULT 0,
+
+ id_cotisation_obligatoire INTEGER NULL REFERENCES cotisations (id) ON DELETE SET NULL
+);
+
+-- Membres de l'asso
+-- Table dynamique générée par l'application
+-- voir Garradin\Membres\Champs.php
+
+CREATE TABLE IF NOT EXISTS membres_sessions
+-- Sessions
+(
+ selecteur TEXT NOT NULL,
+ hash TEXT NOT NULL,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ expire INT NOT NULL,
+
+ PRIMARY KEY (selecteur, id_membre)
+);
+
+CREATE TABLE IF NOT EXISTS cotisations
+-- Types de cotisations et activités
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ id_categorie_compta INTEGER NULL REFERENCES compta_categories (id) ON DELETE SET NULL, -- NULL si le type n'est pas associé automatiquement à la compta
+
+ intitule TEXT NOT NULL,
+ description TEXT NULL,
+ montant REAL NOT NULL,
+
+ duree INTEGER NULL, -- En jours
+ debut TEXT NULL, -- timestamp
+ fin TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS cotisations_membres
+-- Enregistrement des cotisations et activités
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS cm_unique ON cotisations_membres (id_membre, id_cotisation, date);
+
+CREATE TABLE IF NOT EXISTS membres_operations
+-- Liaison des enregistrement des paiements en compta
+(
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_operation INTEGER NOT NULL REFERENCES compta_journal (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NULL REFERENCES cotisations_membres (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_membre, id_operation)
+);
+
+CREATE TABLE IF NOT EXISTS rappels
+-- Rappels de devoir renouveller une cotisation
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+
+ delai INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel
+
+ sujet TEXT NOT NULL,
+ texte TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS rappels_envoyes
+-- Enregistrement des rappels envoyés à qui et quand
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+ id_rappel INTEGER NULL REFERENCES rappels (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ media INTEGER NOT NULL -- Média utilisé pour le rappel : 1 = email, 2 = courrier, 3 = autre
+);
+
+--
+-- WIKI
+--
+
+CREATE TABLE IF NOT EXISTS wiki_pages
+-- Pages du wiki
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki)
+ titre TEXT NOT NULL,
+ date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation),
+ date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification),
+ parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent
+ revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte)
+ droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe)
+ droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4
+-- Table dupliquée pour chercher une page
+(
+ id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire
+ titre TEXT NOT NULL,
+ contenu TEXT NULL, -- Contenu de la dernière révision
+ FOREIGN KEY (id) REFERENCES wiki_pages(id)
+);
+
+CREATE TABLE IF NOT EXISTS wiki_revisions
+-- Révisions du contenu des pages
+(
+ id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
+ revision INTEGER NULL,
+
+ id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL,
+
+ contenu TEXT NOT NULL,
+ modification TEXT NULL, -- Description des modifications effectuées
+ chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+
+ PRIMARY KEY(id_page, revision)
+);
+
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page);
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur);
+
+-- Triggers pour synchro avec table wiki_pages
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages
+ BEGIN
+ DELETE FROM wiki_recherche WHERE id = old.id;
+ END;
+
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages
+ BEGIN
+ UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id;
+ END;
+
+-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page;
+ END;
+
+-- Si le contenu est chiffré, la recherche n'affiche pas de contenu
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page;
+ END;
+
+/*
+CREATE TABLE wiki_suivi
+-- Suivi des pages
+(
+ id_membre INTEGER NOT NULL,
+ id_page INTEGER NOT NULL,
+
+ PRIMARY KEY (id_membre, id_page),
+
+ FOREIGN KEY (id_page) REFERENCES wiki_pages (id), -- Clé externe obligatoire
+ FOREIGN KEY (id_membre) REFERENCES membres (id) -- Clé externe obligatoire
+);
+*/
+
+--
+-- COMPTA
+--
+
+CREATE TABLE IF NOT EXISTS compta_exercices
+-- Exercices
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ libelle TEXT NOT NULL,
+
+ debut TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(debut) IS NOT NULL AND date(debut) = debut),
+ fin TEXT NULL DEFAULT NULL CHECK (fin IS NULL OR (date(fin) IS NOT NULL AND date(fin) = fin)),
+
+ cloture INTEGER NOT NULL DEFAULT 0
+);
+
+
+CREATE TABLE IF NOT EXISTS compta_comptes
+-- Plan comptable
+(
+ id TEXT NOT NULL PRIMARY KEY, -- peut contenir des lettres, eg. 53A, 53B, etc.
+ parent TEXT NOT NULL DEFAULT 0,
+
+ libelle TEXT NOT NULL,
+
+ position INTEGER NOT NULL, -- position actif/passif/charge/produit
+ plan_comptable INTEGER NOT NULL DEFAULT 1, -- 1 = fait partie du plan comptable, 0 = a été ajouté par l'utilisateur
+ desactive INTEGER NOT NULL DEFAULT 0 -- 1 = compte historique désactivé
+);
+
+CREATE INDEX IF NOT EXISTS compta_comptes_parent ON compta_comptes (parent);
+
+CREATE TABLE IF NOT EXISTS compta_comptes_bancaires
+-- Comptes bancaires
+(
+ id TEXT NOT NULL PRIMARY KEY,
+
+ banque TEXT NOT NULL,
+
+ iban TEXT NULL,
+ bic TEXT NULL,
+
+ FOREIGN KEY(id) REFERENCES compta_comptes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS compta_projets
+-- Projets (compta analytique)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ libelle TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS compta_journal
+-- Journal des opérations comptables
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ libelle TEXT NOT NULL,
+ remarques TEXT NULL,
+ numero_piece TEXT NULL, -- N° de pièce comptable
+
+ montant REAL NOT NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ moyen_paiement TEXT NULL,
+ numero_cheque TEXT NULL,
+
+ compte_debit TEXT NULL, -- N° du compte dans le plan, NULL est utilisé pour une opération qui vient d'un exercice précédent
+ compte_credit TEXT NULL, -- N° du compte dans le plan
+
+ id_exercice INTEGER NULL DEFAULT NULL, -- En cas de compta simple, l'exercice est permanent (NULL)
+ id_auteur INTEGER NULL,
+ id_categorie INTEGER NULL, -- Numéro de catégorie (en mode simple)
+ id_projet INTEGER NULL,
+
+ FOREIGN KEY(moyen_paiement) REFERENCES compta_moyens_paiement(code),
+ FOREIGN KEY(compte_debit) REFERENCES compta_comptes(id),
+ FOREIGN KEY(compte_credit) REFERENCES compta_comptes(id),
+ FOREIGN KEY(id_exercice) REFERENCES compta_exercices(id),
+ FOREIGN KEY(id_auteur) REFERENCES membres(id) ON DELETE SET NULL,
+ FOREIGN KEY(id_categorie) REFERENCES compta_categories(id) ON DELETE SET NULL,
+ FOREIGN KEY(id_projet) REFERENCES compta_projets(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS compta_operations_exercice ON compta_journal (id_exercice);
+CREATE INDEX IF NOT EXISTS compta_operations_date ON compta_journal (date);
+CREATE INDEX IF NOT EXISTS compta_operations_comptes ON compta_journal (compte_debit, compte_credit);
+CREATE INDEX IF NOT EXISTS compta_operations_auteur ON compta_journal (id_auteur);
+
+CREATE TABLE IF NOT EXISTS compta_moyens_paiement
+-- Moyens de paiement
+(
+ code TEXT NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL
+);
+
+--INSERT INTO compta_moyens_paiement (code, nom) VALUES ('AU', 'Autre');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CB', 'Carte bleue');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CH', 'Chèque');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('ES', 'Espèces');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('PR', 'Prélèvement');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('TI', 'TIP');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('VI', 'Virement');
+
+CREATE TABLE IF NOT EXISTS compta_categories
+-- Catégories pour simplifier le plan comptable
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ type INTEGER NOT NULL DEFAULT 1, -- 1 = recette, -1 = dépense, 0 = autre (utilisé uniquement pour l'interface)
+
+ intitule TEXT NOT NULL,
+ description TEXT NULL,
+
+ compte TEXT NOT NULL, -- Compte affecté par cette catégorie
+
+ FOREIGN KEY(compte) REFERENCES compta_comptes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ officiel INTEGER NOT NULL DEFAULT 0,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+ auteur TEXT NULL,
+ url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INTEGER NOT NULL DEFAULT 0,
+ config TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS plugins_signaux
+-- Association entre plugins et signaux (hooks)
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (id),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS compta_rapprochement
+-- Rapprochement entre compta et relevés de comptes
+(
+ id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id) ON DELETE CASCADE,
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+ id_auteur INTEGER NULL REFERENCES membres (id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers
+-- Données sur les fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
+ type TEXT NULL, -- Type MIME
+ image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
+ datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier
+ id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime);
+
+CREATE TABLE IF NOT EXISTS fichiers_contenu
+-- Contenu des fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
+ taille INTEGER NOT NULL, -- Taille en octets
+ contenu BLOB NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash);
+
+CREATE TABLE IF NOT EXISTS fichiers_membres
+-- Associations entre fichiers et membres (photo de profil par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES membres (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_wiki_pages
+-- Associations entre fichiers et pages du wiki
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES wiki_pages (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_compta_journal
+-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES compta_journal (id),
+ PRIMARY KEY(fichier, id)
+);
diff --git a/archives/0.8.4_migration.sql b/archives/0.8.4_migration.sql
new file mode 100644
index 0000000..993159d
--- /dev/null
+++ b/archives/0.8.4_migration.sql
@@ -0,0 +1,3 @@
+-- Mise à jour des URI du wiki pour ne pas inclure les tirets en début et fin de chaîne
+-- (problème de concordance entre API PHP et données SQLite)
+UPDATE wiki_pages SET uri = trim(uri, '-') WHERE uri != trim(uri, '-');
diff --git a/archives/0.9.0_migration.sql b/archives/0.9.0_migration.sql
new file mode 100644
index 0000000..11eb5dd
--- /dev/null
+++ b/archives/0.9.0_migration.sql
@@ -0,0 +1,35 @@
+-- Désactivation de l'accès aux membres, pour les groupes qui n'avaient que le droit de lecture
+-- car maintenant ce droit permet de voir les fiches de membres complètes
+UPDATE membres_categories SET droit_membres = 0 WHERE droit_membres = 1;
+
+-- Suppression de la colonne description des catégories
+ALTER TABLE membres_categories RENAME TO membres_categories_old;
+
+-- Mise à jour table compta_rapprochement: la foreign key sur membres est passée
+-- à ON DELETE SET NULL
+ALTER TABLE compta_rapprochement RENAME TO compta_rapprochement_old;
+
+-- Re-créer la table
+-- Créer également les nouvelles tables email
+.read 0.9.0_schema.sql
+
+-- Copie des données, sauf la colonne description
+INSERT INTO membres_categories SELECT id, nom, droit_wiki,
+ droit_membres, droit_compta, droit_inscription,
+ droit_connexion, droit_config, cacher,
+ id_cotisation_obligatoire FROM membres_categories_old;
+
+-- Suppression des anciennes tables
+DROP TABLE membres_categories_old;
+
+-- Migration des données
+INSERT INTO compta_rapprochement SELECT * FROM compta_rapprochement_old;
+DROP TABLE compta_rapprochement_old;
+
+-- Cette variable n'est plus utilisée
+DELETE FROM config WHERE cle = 'email_envoi_automatique';
+
+ALTER TABLE plugins ADD COLUMN menu_condition TEXT NULL;
+
+-- Supprimer le début dans le nom des plugins
+UPDATE plugins_signaux SET callback = replace(callback, 'Garradin\Plugin\', '');
\ No newline at end of file
diff --git a/archives/0.9.0_schema.sql b/archives/0.9.0_schema.sql
new file mode 100644
index 0000000..a6138c0
--- /dev/null
+++ b/archives/0.9.0_schema.sql
@@ -0,0 +1,35 @@
+-- Désactivation de l'accès aux membres, pour les groupes qui n'avaient que le droit de lecture
+-- car maintenant ce droit permet de voir les fiches de membres complètes
+UPDATE membres_categories SET droit_membres = 0 WHERE droit_membres = 1;
+
+-- Suppression de la colonne description des catégories
+ALTER TABLE membres_categories RENAME TO membres_categories_old;
+
+-- Mise à jour table compta_rapprochement: la foreign key sur membres est passée
+-- à ON DELETE SET NULL
+ALTER TABLE compta_rapprochement RENAME TO compta_rapprochement_old;
+
+-- Re-créer la table
+-- Créer également les nouvelles tables email
+.read schema.sql
+
+-- Copie des données, sauf la colonne description
+INSERT INTO membres_categories SELECT id, nom, droit_wiki,
+ droit_membres, droit_compta, droit_inscription,
+ droit_connexion, droit_config, cacher,
+ id_cotisation_obligatoire FROM membres_categories_old;
+
+-- Suppression des anciennes tables
+DROP TABLE membres_categories_old;
+
+-- Migration des données
+INSERT INTO compta_rapprochement SELECT * FROM compta_rapprochement_old;
+DROP TABLE compta_rapprochement_old;
+
+-- Cette variable n'est plus utilisée
+DELETE FROM config WHERE cle = 'email_envoi_automatique';
+
+ALTER TABLE plugins ADD COLUMN menu_condition TEXT NULL;
+
+-- Supprimer le début dans le nom des plugins
+UPDATE plugins_signaux SET callback = replace(callback, 'Garradin\Plugin\', '');
\ No newline at end of file
diff --git a/archives/0.9.1_migration.sql b/archives/0.9.1_migration.sql
new file mode 100644
index 0000000..1c852c7
--- /dev/null
+++ b/archives/0.9.1_migration.sql
@@ -0,0 +1,14 @@
+-- Il manquait une clause ON DELETE SET NULL sur la foreign key
+-- de cotisations quand on faisait une mise à jour depuis une
+-- ancienne version
+ALTER TABLE cotisations RENAME TO cotisations_old;
+
+.read 0.9.1_schema.sql
+
+INSERT INTO cotisations SELECT * FROM cotisations_old;
+
+DROP TABLE cotisations_old;
+
+-- Changer le compte des reports automatiques
+UPDATE compta_journal SET compte_debit = '890' WHERE compte_debit IS NULL;
+UPDATE compta_journal SET compte_credit = '890' WHERE compte_credit IS NULL;
diff --git a/archives/0.9.1_schema.sql b/archives/0.9.1_schema.sql
new file mode 100644
index 0000000..e94e5d7
--- /dev/null
+++ b/archives/0.9.1_schema.sql
@@ -0,0 +1,400 @@
+CREATE TABLE IF NOT EXISTS config (
+-- Configuration de Garradin
+ cle TEXT PRIMARY KEY NOT NULL,
+ valeur TEXT
+);
+
+-- On stocke ici les ID de catégorie de compta correspondant aux types spéciaux
+-- compta_categorie_cotisations => id_categorie
+-- compta_categorie_dons => id_categorie
+
+CREATE TABLE IF NOT EXISTS membres_categories
+-- Catégories de membres
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ nom TEXT NOT NULL,
+
+ droit_wiki INTEGER NOT NULL DEFAULT 1,
+ droit_membres INTEGER NOT NULL DEFAULT 1,
+ droit_compta INTEGER NOT NULL DEFAULT 1,
+ droit_inscription INTEGER NOT NULL DEFAULT 0,
+ droit_connexion INTEGER NOT NULL DEFAULT 1,
+ droit_config INTEGER NOT NULL DEFAULT 0,
+ cacher INTEGER NOT NULL DEFAULT 0,
+
+ id_cotisation_obligatoire INTEGER NULL REFERENCES cotisations (id) ON DELETE SET NULL
+);
+
+-- Membres de l'asso
+-- Table dynamique générée par l'application
+-- voir Garradin\Membres\Champs.php
+
+CREATE TABLE IF NOT EXISTS membres_sessions
+-- Sessions
+(
+ selecteur TEXT NOT NULL,
+ hash TEXT NOT NULL,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ expire INT NOT NULL,
+
+ PRIMARY KEY (selecteur, id_membre)
+);
+
+CREATE TABLE IF NOT EXISTS cotisations
+-- Types de cotisations et activités
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ id_categorie_compta INTEGER NULL REFERENCES compta_categories (id) ON DELETE SET NULL, -- NULL si le type n'est pas associé automatiquement à la compta
+
+ intitule TEXT NOT NULL,
+ description TEXT NULL,
+ montant REAL NOT NULL,
+
+ duree INTEGER NULL, -- En jours
+ debut TEXT NULL, -- timestamp
+ fin TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS cotisations_membres
+-- Enregistrement des cotisations et activités
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS cm_unique ON cotisations_membres (id_membre, id_cotisation, date);
+
+CREATE TABLE IF NOT EXISTS membres_operations
+-- Liaison des enregistrement des paiements en compta
+(
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_operation INTEGER NOT NULL REFERENCES compta_journal (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NULL REFERENCES cotisations_membres (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_membre, id_operation)
+);
+
+CREATE TABLE IF NOT EXISTS rappels
+-- Rappels de devoir renouveller une cotisation
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+
+ delai INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel
+
+ sujet TEXT NOT NULL,
+ texte TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS rappels_envoyes
+-- Enregistrement des rappels envoyés à qui et quand
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+ id_rappel INTEGER NULL REFERENCES rappels (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ media INTEGER NOT NULL -- Média utilisé pour le rappel : 1 = email, 2 = courrier, 3 = autre
+);
+
+--
+-- WIKI
+--
+
+CREATE TABLE IF NOT EXISTS wiki_pages
+-- Pages du wiki
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki)
+ titre TEXT NOT NULL,
+ date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation),
+ date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification),
+ parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent
+ revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte)
+ droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe)
+ droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4
+-- Table dupliquée pour chercher une page
+(
+ id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire
+ titre TEXT NOT NULL,
+ contenu TEXT NULL, -- Contenu de la dernière révision
+ FOREIGN KEY (id) REFERENCES wiki_pages(id)
+);
+
+CREATE TABLE IF NOT EXISTS wiki_revisions
+-- Révisions du contenu des pages
+(
+ id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
+ revision INTEGER NULL,
+
+ id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL,
+
+ contenu TEXT NOT NULL,
+ modification TEXT NULL, -- Description des modifications effectuées
+ chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+
+ PRIMARY KEY(id_page, revision)
+);
+
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page);
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur);
+
+-- Triggers pour synchro avec table wiki_pages
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages
+ BEGIN
+ DELETE FROM wiki_recherche WHERE id = old.id;
+ END;
+
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages
+ BEGIN
+ UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id;
+ END;
+
+-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page;
+ END;
+
+-- Si le contenu est chiffré, la recherche n'affiche pas de contenu
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page;
+ END;
+
+/*
+CREATE TABLE wiki_suivi
+-- Suivi des pages
+(
+ id_membre INTEGER NOT NULL,
+ id_page INTEGER NOT NULL,
+
+ PRIMARY KEY (id_membre, id_page),
+
+ FOREIGN KEY (id_page) REFERENCES wiki_pages (id), -- Clé externe obligatoire
+ FOREIGN KEY (id_membre) REFERENCES membres (id) -- Clé externe obligatoire
+);
+*/
+
+--
+-- COMPTA
+--
+
+CREATE TABLE IF NOT EXISTS compta_exercices
+-- Exercices
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ libelle TEXT NOT NULL,
+
+ debut TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(debut) IS NOT NULL AND date(debut) = debut),
+ fin TEXT NULL DEFAULT NULL CHECK (fin IS NULL OR (date(fin) IS NOT NULL AND date(fin) = fin)),
+
+ cloture INTEGER NOT NULL DEFAULT 0
+);
+
+
+CREATE TABLE IF NOT EXISTS compta_comptes
+-- Plan comptable
+(
+ id TEXT NOT NULL PRIMARY KEY, -- peut contenir des lettres, eg. 53A, 53B, etc.
+ parent TEXT NOT NULL DEFAULT 0,
+
+ libelle TEXT NOT NULL,
+
+ position INTEGER NOT NULL, -- position actif/passif/charge/produit
+ plan_comptable INTEGER NOT NULL DEFAULT 1, -- 1 = fait partie du plan comptable, 0 = a été ajouté par l'utilisateur
+ desactive INTEGER NOT NULL DEFAULT 0 -- 1 = compte historique désactivé
+);
+
+CREATE INDEX IF NOT EXISTS compta_comptes_parent ON compta_comptes (parent);
+
+CREATE TABLE IF NOT EXISTS compta_comptes_bancaires
+-- Comptes bancaires
+(
+ id TEXT NOT NULL PRIMARY KEY,
+
+ banque TEXT NOT NULL,
+
+ iban TEXT NULL,
+ bic TEXT NULL,
+
+ FOREIGN KEY(id) REFERENCES compta_comptes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS compta_projets
+-- Projets (compta analytique)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ libelle TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS compta_journal
+-- Journal des opérations comptables
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ libelle TEXT NOT NULL,
+ remarques TEXT NULL,
+ numero_piece TEXT NULL, -- N° de pièce comptable
+
+ montant REAL NOT NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ moyen_paiement TEXT NULL,
+ numero_cheque TEXT NULL,
+
+ compte_debit TEXT NULL, -- N° du compte dans le plan, NULL est utilisé pour une opération qui vient d'un exercice précédent
+ compte_credit TEXT NULL, -- N° du compte dans le plan
+
+ id_exercice INTEGER NULL DEFAULT NULL, -- En cas de compta simple, l'exercice est permanent (NULL)
+ id_auteur INTEGER NULL,
+ id_categorie INTEGER NULL, -- Numéro de catégorie (en mode simple)
+ id_projet INTEGER NULL,
+
+ FOREIGN KEY(moyen_paiement) REFERENCES compta_moyens_paiement(code),
+ FOREIGN KEY(compte_debit) REFERENCES compta_comptes(id),
+ FOREIGN KEY(compte_credit) REFERENCES compta_comptes(id),
+ FOREIGN KEY(id_exercice) REFERENCES compta_exercices(id),
+ FOREIGN KEY(id_auteur) REFERENCES membres(id) ON DELETE SET NULL,
+ FOREIGN KEY(id_categorie) REFERENCES compta_categories(id) ON DELETE SET NULL,
+ FOREIGN KEY(id_projet) REFERENCES compta_projets(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS compta_operations_exercice ON compta_journal (id_exercice);
+CREATE INDEX IF NOT EXISTS compta_operations_date ON compta_journal (date);
+CREATE INDEX IF NOT EXISTS compta_operations_comptes ON compta_journal (compte_debit, compte_credit);
+CREATE INDEX IF NOT EXISTS compta_operations_auteur ON compta_journal (id_auteur);
+
+CREATE TABLE IF NOT EXISTS compta_moyens_paiement
+-- Moyens de paiement
+(
+ code TEXT NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL
+);
+
+--INSERT INTO compta_moyens_paiement (code, nom) VALUES ('AU', 'Autre');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CB', 'Carte bleue');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CH', 'Chèque');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('ES', 'Espèces');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('PR', 'Prélèvement');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('TI', 'TIP');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('VI', 'Virement');
+
+CREATE TABLE IF NOT EXISTS compta_categories
+-- Catégories pour simplifier le plan comptable
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ type INTEGER NOT NULL DEFAULT 1, -- 1 = recette, -1 = dépense, 0 = autre (utilisé uniquement pour l'interface)
+
+ intitule TEXT NOT NULL,
+ description TEXT NULL,
+
+ compte TEXT NOT NULL, -- Compte affecté par cette catégorie
+
+ FOREIGN KEY(compte) REFERENCES compta_comptes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ officiel INTEGER NOT NULL DEFAULT 0,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+ auteur TEXT NULL,
+ url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INTEGER NOT NULL DEFAULT 0,
+ menu_condition TEXT NULL,
+ config TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS plugins_signaux
+-- Association entre plugins et signaux (hooks)
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (id),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS compta_rapprochement
+-- Rapprochement entre compta et relevés de comptes
+(
+ id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id) ON DELETE CASCADE,
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+ id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL
+);
+
+CREATE TABLE IF NOT EXISTS fichiers
+-- Données sur les fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
+ type TEXT NULL, -- Type MIME
+ image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
+ datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier
+ id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime);
+
+CREATE TABLE IF NOT EXISTS fichiers_contenu
+-- Contenu des fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
+ taille INTEGER NOT NULL, -- Taille en octets
+ contenu BLOB NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash);
+
+CREATE TABLE IF NOT EXISTS fichiers_membres
+-- Associations entre fichiers et membres (photo de profil par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES membres (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_wiki_pages
+-- Associations entre fichiers et pages du wiki
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES wiki_pages (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_compta_journal
+-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES compta_journal (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS recherches
+-- Recherches enregistrées
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
+ intitule TEXT NOT NULL,
+ creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
+ cible TEXT NOT NULL, -- "membres" ou "compta_journal"
+ type TEXT NOT NULL, -- "json" ou "sql"
+ contenu TEXT NOT NULL
+);
diff --git a/archives/0.9.5_schema.sql b/archives/0.9.5_schema.sql
new file mode 100644
index 0000000..0226f92
--- /dev/null
+++ b/archives/0.9.5_schema.sql
@@ -0,0 +1,414 @@
+CREATE TABLE IF NOT EXISTS config (
+-- Configuration de Garradin
+ cle TEXT PRIMARY KEY NOT NULL,
+ valeur TEXT
+);
+
+-- On stocke ici les ID de catégorie de compta correspondant aux types spéciaux
+-- compta_categorie_cotisations => id_categorie
+-- compta_categorie_dons => id_categorie
+
+CREATE TABLE IF NOT EXISTS membres_categories
+-- Catégories de membres
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ nom TEXT NOT NULL,
+
+ droit_wiki INTEGER NOT NULL DEFAULT 1,
+ droit_membres INTEGER NOT NULL DEFAULT 1,
+ droit_compta INTEGER NOT NULL DEFAULT 1,
+ droit_inscription INTEGER NOT NULL DEFAULT 0,
+ droit_connexion INTEGER NOT NULL DEFAULT 1,
+ droit_config INTEGER NOT NULL DEFAULT 0,
+ cacher INTEGER NOT NULL DEFAULT 0,
+
+ id_cotisation_obligatoire INTEGER NULL REFERENCES cotisations (id) ON DELETE SET NULL
+);
+
+-- Membres de l'asso
+-- Table dynamique générée par l'application
+-- voir Garradin\Membres\Champs.php
+
+CREATE TABLE IF NOT EXISTS membres_sessions
+-- Sessions
+(
+ selecteur TEXT NOT NULL,
+ hash TEXT NOT NULL,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ expire INT NOT NULL,
+
+ PRIMARY KEY (selecteur, id_membre)
+);
+
+CREATE TABLE IF NOT EXISTS cotisations
+-- Types de cotisations et activités
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ id_categorie_compta INTEGER NULL REFERENCES compta_categories (id) ON DELETE SET NULL, -- NULL si le type n'est pas associé automatiquement à la compta
+
+ intitule TEXT NOT NULL,
+ description TEXT NULL,
+ montant REAL NOT NULL,
+
+ duree INTEGER NULL, -- En jours
+ debut TEXT NULL, -- timestamp
+ fin TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS cotisations_membres
+-- Enregistrement des cotisations et activités
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS cm_unique ON cotisations_membres (id_membre, id_cotisation, date);
+
+CREATE TABLE IF NOT EXISTS membres_operations
+-- Liaison des enregistrement des paiements en compta
+(
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_operation INTEGER NOT NULL REFERENCES compta_journal (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NULL REFERENCES cotisations_membres (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_membre, id_operation)
+);
+
+CREATE TABLE IF NOT EXISTS rappels
+-- Rappels de devoir renouveller une cotisation
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+
+ delai INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel
+
+ sujet TEXT NOT NULL,
+ texte TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS rappels_envoyes
+-- Enregistrement des rappels envoyés à qui et quand
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_cotisation INTEGER NOT NULL REFERENCES cotisations (id) ON DELETE CASCADE,
+ id_rappel INTEGER NULL REFERENCES rappels (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ media INTEGER NOT NULL -- Média utilisé pour le rappel : 1 = email, 2 = courrier, 3 = autre
+);
+
+--
+-- WIKI
+--
+
+CREATE TABLE IF NOT EXISTS wiki_pages
+-- Pages du wiki
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki)
+ titre TEXT NOT NULL,
+ date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation),
+ date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification),
+ parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent
+ revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte)
+ droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe)
+ droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4
+-- Table dupliquée pour chercher une page
+(
+ id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire
+ titre TEXT NOT NULL,
+ contenu TEXT NULL, -- Contenu de la dernière révision
+ FOREIGN KEY (id) REFERENCES wiki_pages(id)
+);
+
+CREATE TABLE IF NOT EXISTS wiki_revisions
+-- Révisions du contenu des pages
+(
+ id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
+ revision INTEGER NULL,
+
+ id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL,
+
+ contenu TEXT NOT NULL,
+ modification TEXT NULL, -- Description des modifications effectuées
+ chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+
+ PRIMARY KEY(id_page, revision)
+);
+
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page);
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur);
+
+-- Triggers pour synchro avec table wiki_pages
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages
+ BEGIN
+ DELETE FROM wiki_recherche WHERE id = old.id;
+ END;
+
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages
+ BEGIN
+ UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id;
+ END;
+
+-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page;
+ END;
+
+-- Si le contenu est chiffré, la recherche n'affiche pas de contenu
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page;
+ END;
+
+/*
+CREATE TABLE wiki_suivi
+-- Suivi des pages
+(
+ id_membre INTEGER NOT NULL,
+ id_page INTEGER NOT NULL,
+
+ PRIMARY KEY (id_membre, id_page),
+
+ FOREIGN KEY (id_page) REFERENCES wiki_pages (id), -- Clé externe obligatoire
+ FOREIGN KEY (id_membre) REFERENCES membres (id) -- Clé externe obligatoire
+);
+*/
+
+--
+-- COMPTA
+--
+
+CREATE TABLE IF NOT EXISTS compta_exercices
+-- Exercices
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ libelle TEXT NOT NULL,
+
+ debut TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(debut) IS NOT NULL AND date(debut) = debut),
+ fin TEXT NULL DEFAULT NULL CHECK (fin IS NULL OR (date(fin) IS NOT NULL AND date(fin) = fin)),
+
+ cloture INTEGER NOT NULL DEFAULT 0
+);
+
+
+CREATE TABLE IF NOT EXISTS compta_comptes
+-- Plan comptable
+(
+ id TEXT NOT NULL PRIMARY KEY, -- peut contenir des lettres, eg. 53A, 53B, etc.
+ parent TEXT NOT NULL DEFAULT 0,
+
+ libelle TEXT NOT NULL,
+
+ position INTEGER NOT NULL, -- position actif/passif/charge/produit
+ plan_comptable INTEGER NOT NULL DEFAULT 1, -- 1 = fait partie du plan comptable, 0 = a été ajouté par l'utilisateur
+ desactive INTEGER NOT NULL DEFAULT 0 -- 1 = compte historique désactivé
+);
+
+CREATE INDEX IF NOT EXISTS compta_comptes_parent ON compta_comptes (parent);
+
+CREATE TABLE IF NOT EXISTS compta_comptes_bancaires
+-- Comptes bancaires
+(
+ id TEXT NOT NULL PRIMARY KEY,
+
+ banque TEXT NOT NULL,
+
+ iban TEXT NULL,
+ bic TEXT NULL,
+
+ FOREIGN KEY(id) REFERENCES compta_comptes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS compta_projets
+-- Projets (compta analytique)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ libelle TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS compta_journal
+-- Journal des opérations comptables
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ libelle TEXT NOT NULL,
+ remarques TEXT NULL,
+ numero_piece TEXT NULL, -- N° de pièce comptable
+
+ montant REAL NOT NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ moyen_paiement TEXT NULL,
+ numero_cheque TEXT NULL,
+
+ compte_debit TEXT NULL, -- N° du compte dans le plan, NULL est utilisé pour une opération qui vient d'un exercice précédent
+ compte_credit TEXT NULL, -- N° du compte dans le plan
+
+ id_exercice INTEGER NULL DEFAULT NULL, -- En cas de compta simple, l'exercice est permanent (NULL)
+ id_auteur INTEGER NULL,
+ id_categorie INTEGER NULL, -- Numéro de catégorie (en mode simple)
+ id_projet INTEGER NULL,
+
+ FOREIGN KEY(moyen_paiement) REFERENCES compta_moyens_paiement(code),
+ FOREIGN KEY(compte_debit) REFERENCES compta_comptes(id),
+ FOREIGN KEY(compte_credit) REFERENCES compta_comptes(id),
+ FOREIGN KEY(id_exercice) REFERENCES compta_exercices(id),
+ FOREIGN KEY(id_auteur) REFERENCES membres(id) ON DELETE SET NULL,
+ FOREIGN KEY(id_categorie) REFERENCES compta_categories(id) ON DELETE SET NULL,
+ FOREIGN KEY(id_projet) REFERENCES compta_projets(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS compta_operations_exercice ON compta_journal (id_exercice);
+CREATE INDEX IF NOT EXISTS compta_operations_date ON compta_journal (date);
+CREATE INDEX IF NOT EXISTS compta_operations_comptes ON compta_journal (compte_debit, compte_credit);
+CREATE INDEX IF NOT EXISTS compta_operations_auteur ON compta_journal (id_auteur);
+
+CREATE TABLE IF NOT EXISTS compta_moyens_paiement
+-- Moyens de paiement
+(
+ code TEXT NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL
+);
+
+--INSERT INTO compta_moyens_paiement (code, nom) VALUES ('AU', 'Autre');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CB', 'Carte bleue');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('CH', 'Chèque');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('ES', 'Espèces');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('PR', 'Prélèvement');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('TI', 'TIP');
+INSERT OR IGNORE INTO compta_moyens_paiement (code, nom) VALUES ('VI', 'Virement');
+
+CREATE TABLE IF NOT EXISTS compta_categories
+-- Catégories pour simplifier le plan comptable
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ type INTEGER NOT NULL DEFAULT 1, -- 1 = recette, -1 = dépense, 0 = autre (utilisé uniquement pour l'interface)
+
+ intitule TEXT NOT NULL,
+ description TEXT NULL,
+
+ compte TEXT NOT NULL, -- Compte affecté par cette catégorie
+
+ FOREIGN KEY(compte) REFERENCES compta_comptes(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ officiel INTEGER NOT NULL DEFAULT 0,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+ auteur TEXT NULL,
+ url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INTEGER NOT NULL DEFAULT 0,
+ menu_condition TEXT NULL,
+ config TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS plugins_signaux
+-- Association entre plugins et signaux (hooks)
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (id),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS compta_rapprochement
+-- Rapprochement entre compta et relevés de comptes
+(
+ id_operation INTEGER NOT NULL PRIMARY KEY REFERENCES compta_journal (id) ON DELETE CASCADE,
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+ id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL
+);
+
+CREATE TABLE IF NOT EXISTS fichiers
+-- Données sur les fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
+ type TEXT NULL, -- Type MIME
+ image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
+ datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier
+ id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime);
+
+CREATE TABLE IF NOT EXISTS fichiers_contenu
+-- Contenu des fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
+ taille INTEGER NOT NULL, -- Taille en octets
+ contenu BLOB NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash);
+
+CREATE TABLE IF NOT EXISTS fichiers_membres
+-- Associations entre fichiers et membres (photo de profil par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES membres (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_wiki_pages
+-- Associations entre fichiers et pages du wiki
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES wiki_pages (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_compta_journal
+-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id),
+ id INTEGER NOT NULL REFERENCES compta_journal (id),
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS recherches
+-- Recherches enregistrées
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
+ intitule TEXT NOT NULL,
+ creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
+ cible TEXT NOT NULL, -- "membres" ou "compta_journal"
+ type TEXT NOT NULL, -- "json" ou "sql"
+ contenu TEXT NOT NULL
+);
+
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache
+-- Cache des hash de mots de passe compromis
+(
+ hash TEXT NOT NULL PRIMARY KEY
+);
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
+-- Cache des préfixes de mots de passe compromis
+(
+ prefix TEXT NOT NULL PRIMARY KEY,
+ date INTEGER NOT NULL
+);
\ No newline at end of file
diff --git a/archives/1.0.0_schema.sql b/archives/1.0.0_schema.sql
new file mode 100644
index 0000000..bc0c986
--- /dev/null
+++ b/archives/1.0.0_schema.sql
@@ -0,0 +1,406 @@
+CREATE TABLE IF NOT EXISTS config (
+-- Configuration de Garradin
+ cle TEXT PRIMARY KEY NOT NULL,
+ valeur TEXT
+);
+
+CREATE TABLE IF NOT EXISTS membres_categories
+-- Catégories de membres
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ nom TEXT NOT NULL,
+
+ droit_wiki INTEGER NOT NULL DEFAULT 1,
+ droit_membres INTEGER NOT NULL DEFAULT 1,
+ droit_compta INTEGER NOT NULL DEFAULT 1,
+ droit_inscription INTEGER NOT NULL DEFAULT 0,
+ droit_connexion INTEGER NOT NULL DEFAULT 1,
+ droit_config INTEGER NOT NULL DEFAULT 0,
+ cacher INTEGER NOT NULL DEFAULT 0
+);
+
+-- Membres de l'asso
+-- Table dynamique générée par l'application
+-- voir Garradin\Membres\Champs.php
+
+CREATE TABLE IF NOT EXISTS membres_sessions
+-- Sessions
+(
+ selecteur TEXT NOT NULL,
+ hash TEXT NOT NULL,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ expire INT NOT NULL,
+
+ PRIMARY KEY (selecteur, id_membre)
+);
+
+CREATE TABLE IF NOT EXISTS services
+-- Types de services (cotisations)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
+ start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
+ end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
+);
+
+CREATE TABLE IF NOT EXISTS services_fees
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ amount INTEGER NULL,
+ formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)
+
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL si le type n'est pas associé automatiquement à la compta
+ id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL si le type n'est pas associé automatiquement à la compta
+);
+
+CREATE TABLE IF NOT EXISTS services_users
+-- Enregistrement des cotisations et activités
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE,
+
+ paid INTEGER NOT NULL DEFAULT 0,
+ expected_amount INTEGER NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, date);
+
+CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
+CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
+CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
+CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date);
+
+CREATE TABLE IF NOT EXISTS services_reminders
+-- Rappels de devoir renouveller une cotisation
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+
+ delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel
+
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS services_reminders_sent
+-- Enregistrement des rappels envoyés à qui et quand
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, date);
+
+CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
+CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);
+
+--
+-- WIKI
+--
+
+CREATE TABLE IF NOT EXISTS wiki_pages
+-- Pages du wiki
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki)
+ titre TEXT NOT NULL,
+ date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation),
+ date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification),
+ parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent
+ revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte)
+ droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe)
+ droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4
+-- Table dupliquée pour chercher une page
+(
+ id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire
+ titre TEXT NOT NULL,
+ contenu TEXT NULL, -- Contenu de la dernière révision
+ FOREIGN KEY (id) REFERENCES wiki_pages(id)
+);
+
+CREATE TABLE IF NOT EXISTS wiki_revisions
+-- Révisions du contenu des pages
+(
+ id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
+ revision INTEGER NULL,
+
+ id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL,
+
+ contenu TEXT NOT NULL,
+ modification TEXT NULL, -- Description des modifications effectuées
+ chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+
+ PRIMARY KEY(id_page, revision)
+);
+
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page);
+CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur);
+
+-- Triggers pour synchro avec table wiki_pages
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages
+ BEGIN
+ DELETE FROM wiki_recherche WHERE id = old.id;
+ END;
+
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages
+ BEGIN
+ UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id;
+ END;
+
+-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page;
+ END;
+
+-- Si le contenu est chiffré, la recherche n'affiche pas de contenu
+CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1
+ BEGIN
+ UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page;
+ END;
+
+--
+-- COMPTA
+--
+
+CREATE TABLE IF NOT EXISTS acc_charts
+-- Plans comptables : il peut y en avoir plusieurs
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ country TEXT NOT NULL,
+ code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur
+ label TEXT NOT NULL,
+ archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable
+);
+
+CREATE TABLE IF NOT EXISTS acc_accounts
+-- Comptes des plans comptables
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE,
+
+ code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc.
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ position INTEGER NOT NULL, -- position actif/passif/charge/produit
+ type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
+ user INTEGER NOT NULL DEFAULT 1 -- 1 = fait partie du plan comptable original, 0 = a été ajouté par l'utilisateur
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
+CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
+CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);
+
+CREATE TABLE IF NOT EXISTS acc_years
+-- Exercices
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ label TEXT NOT NULL,
+
+ start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
+ end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),
+
+ closed INTEGER NOT NULL DEFAULT 0,
+
+ id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
+);
+
+CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
+ UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
+END;
+
+CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);
+
+CREATE TABLE IF NOT EXISTS acc_transactions
+-- Opérations comptables
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
+ status INTEGER NOT NULL DEFAULT 0, -- Statut (bitmask)
+
+ label TEXT NOT NULL,
+ notes TEXT NULL,
+ reference TEXT NULL, -- N° de pièce comptable
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable
+
+ hash TEXT NULL,
+ prev_hash TEXT NULL,
+
+ id_year INTEGER NOT NULL REFERENCES acc_years(id),
+ id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL,
+ id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
+CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related);
+CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_lines
+-- Lignes d'écritures d'une opération
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_account INTEGER NOT NULL REFERENCES acc_accounts (id), -- N° du compte dans le plan comptable
+
+ credit INTEGER NOT NULL,
+ debit INTEGER NOT NULL,
+
+ reference TEXT NULL, -- Référence de paiement, eg. numéro de chèque
+ label TEXT NULL,
+
+ reconciled INTEGER NOT NULL DEFAULT 0,
+
+ id_analytical INTEGER NULL REFERENCES acc_accounts(id) ON DELETE SET NULL,
+
+ CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
+ CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_analytical ON acc_transactions_lines (id_analytical);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_users
+-- Liaison des écritures et des membres
+(
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_user, id_transaction)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ officiel INTEGER NOT NULL DEFAULT 0,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+ auteur TEXT NULL,
+ url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INTEGER NOT NULL DEFAULT 0,
+ menu_condition TEXT NULL,
+ config TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS plugins_signaux
+-- Association entre plugins et signaux (hooks)
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (id),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers
+-- Données sur les fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
+ type TEXT NULL, -- Type MIME
+ image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
+ datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier
+ id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime);
+
+CREATE TABLE IF NOT EXISTS fichiers_contenu
+-- Contenu des fichiers
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
+ taille INTEGER NOT NULL, -- Taille en octets
+ contenu BLOB NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash);
+
+CREATE TABLE IF NOT EXISTS fichiers_membres
+-- Associations entre fichiers et membres (photo de profil par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
+ id INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_wiki_pages
+-- Associations entre fichiers et pages du wiki
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
+ id INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS fichiers_acc_transactions
+-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
+(
+ fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
+ id INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ PRIMARY KEY(fichier, id)
+);
+
+CREATE TABLE IF NOT EXISTS recherches
+-- Recherches enregistrées
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
+ intitule TEXT NOT NULL,
+ creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
+ cible TEXT NOT NULL, -- "membres" ou "compta"
+ type TEXT NOT NULL, -- "json" ou "sql"
+ contenu TEXT NOT NULL
+);
+
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache
+-- Cache des hash de mots de passe compromis
+(
+ hash TEXT NOT NULL PRIMARY KEY
+);
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
+-- Cache des préfixes de mots de passe compromis
+(
+ prefix TEXT NOT NULL PRIMARY KEY,
+ date INTEGER NOT NULL
+);
\ No newline at end of file
diff --git a/archives/plan_comptable.json b/archives/plan_comptable.json
new file mode 100644
index 0000000..541150f
--- /dev/null
+++ b/archives/plan_comptable.json
@@ -0,0 +1,1760 @@
+{
+ "1": {
+ "code": 1,
+ "nom": "Classe 1 \u2014 Comptes de capitaux (Fonds propres, emprunts et dettes assimil\u00e9s)",
+ "parent": 0,
+ "position": 1
+ },
+ "10": {
+ "code": 10,
+ "nom": "FONDS ASSOCIATIFS ET R\u00c9SERVES",
+ "parent": 1,
+ "position": 1
+ },
+ "102": {
+ "code": 102,
+ "nom": "Fonds associatif sans droit de reprise",
+ "parent": 10,
+ "position": 1
+ },
+ "1021": {
+ "code": 1021,
+ "nom": "Valeur du patrimoine int\u00e9gr\u00e9",
+ "parent": 102,
+ "position": 1
+ },
+ "1022": {
+ "code": 1022,
+ "nom": "Fonds statutaire",
+ "parent": 102,
+ "position": 1
+ },
+ "1024": {
+ "code": 1024,
+ "nom": "Apports sans droit de reprise",
+ "parent": 102,
+ "position": 1
+ },
+ "103": {
+ "code": 103,
+ "nom": "Fonds associatif avec droit de reprise",
+ "parent": 10,
+ "position": 1
+ },
+ "1034": {
+ "code": 1034,
+ "nom": "Apports avec droit de reprise",
+ "parent": 103,
+ "position": 1
+ },
+ "105": {
+ "code": 105,
+ "nom": "\u00c9carts de r\u00e9\u00e9valuation",
+ "parent": 10,
+ "position": 1
+ },
+ "106": {
+ "code": 106,
+ "nom": "R\u00e9serves",
+ "parent": 10,
+ "position": 1
+ },
+ "1063": {
+ "code": 1063,
+ "nom": "R\u00e9serves statutaires ou contractuelles",
+ "parent": 106,
+ "position": 1
+ },
+ "1064": {
+ "code": 1064,
+ "nom": "R\u00e9serves r\u00e9glement\u00e9es",
+ "parent": 106,
+ "position": 1
+ },
+ "1068": {
+ "code": 1068,
+ "nom": "Autres r\u00e9serves (dont r\u00e9serves pour projet associatif)",
+ "parent": 106,
+ "position": 1
+ },
+ "11": {
+ "code": 11,
+ "nom": "REPORT \u00c0 NOUVEAU",
+ "parent": 1,
+ "position": 1
+ },
+ "110": {
+ "code": 110,
+ "nom": "Report \u00e0 nouveau (Solde cr\u00e9diteur)",
+ "parent": 11,
+ "position": 1
+ },
+ "119": {
+ "code": 119,
+ "nom": "Report \u00e0 nouveau (Solde d\u00e9biteur)",
+ "parent": 11,
+ "position": 1
+ },
+ "12": {
+ "code": 12,
+ "nom": "R\u00c9SULTAT NET DE L'EXERCICE",
+ "parent": 1,
+ "position": 1
+ },
+ "120": {
+ "code": 120,
+ "nom": "R\u00e9sultat de l'exercice (exc\u00e9dent)",
+ "parent": 12,
+ "position": 1
+ },
+ "129": {
+ "code": 129,
+ "nom": "R\u00e9sultat de l'exercice (d\u00e9ficit)",
+ "parent": 12,
+ "position": 1
+ },
+ "13": {
+ "code": 13,
+ "nom": "SUBVENTIONS D'INVESTISSEMENT AFFECT\u00c9ES A DES BIENS NON RENOUVELABLES",
+ "parent": 1,
+ "position": 1
+ },
+ "131": {
+ "code": 131,
+ "nom": "Subventions d'investissement (renouvelables)",
+ "parent": 13,
+ "position": 1
+ },
+ "139": {
+ "code": 139,
+ "nom": "Subventions d'investissement inscrites au compte de r\u00e9sultat",
+ "parent": 13,
+ "position": 1
+ },
+ "14": {
+ "code": 14,
+ "nom": "PROVISIONS REGLEMENT\u00c9ES",
+ "parent": 1,
+ "position": 1
+ },
+ "15": {
+ "code": 15,
+ "nom": "PROVISIONS",
+ "parent": 1,
+ "position": 1
+ },
+ "151": {
+ "code": 151,
+ "nom": "Provisions pour risques",
+ "parent": 15,
+ "position": 1
+ },
+ "157": {
+ "code": 157,
+ "nom": "Provisions pour charges \u00e0 r\u00e9partir sur plusieurs exercices",
+ "parent": 15,
+ "position": 1
+ },
+ "158": {
+ "code": 158,
+ "nom": "Autres provisions pour charges",
+ "parent": 15,
+ "position": 1
+ },
+ "16": {
+ "code": 16,
+ "nom": "EMPRUNTS ET DETTES ASSIMIL\u00c9ES",
+ "parent": 1,
+ "position": 1
+ },
+ "164": {
+ "code": 164,
+ "nom": "Emprunts aupr\u00e8s des \u00e9tablissements de cr\u00e9dits",
+ "parent": 16,
+ "position": 1
+ },
+ "165": {
+ "code": 165,
+ "nom": "D\u00e9p\u00f4ts et cautionnements re\u00e7us",
+ "parent": 16,
+ "position": 1
+ },
+ "167": {
+ "code": 167,
+ "nom": "Emprunts et dettes assorties de conditions particuli\u00e8res",
+ "parent": 16,
+ "position": 1
+ },
+ "168": {
+ "code": 168,
+ "nom": "Autres emprunts et dettes assimil\u00e9s",
+ "parent": 16,
+ "position": 1
+ },
+ "17": {
+ "code": 17,
+ "nom": "DETTES RATTACH\u00c9ES \u00c0 DES PARTICIPATIONS",
+ "parent": 1,
+ "position": 1
+ },
+ "18": {
+ "code": 18,
+ "nom": "COMPTES DE LIAISON DES \u00c9TABLISSEMENTS",
+ "parent": 1,
+ "position": 1
+ },
+ "181": {
+ "code": 181,
+ "nom": "Apports permanents entre si\u00e8ge social et \u00e9tablissements",
+ "parent": 18,
+ "position": 1
+ },
+ "185": {
+ "code": 185,
+ "nom": "Biens et prestations de services \u00e9chang\u00e9s entre \u00e9tablissements et si\u00e8ge social",
+ "parent": 18,
+ "position": 1
+ },
+ "186": {
+ "code": 186,
+ "nom": "Biens et prestations de services \u00e9chang\u00e9s entre \u00e9tablissements (charges)",
+ "parent": 18,
+ "position": 1
+ },
+ "187": {
+ "code": 187,
+ "nom": "Biens et prestations de services \u00e9chang\u00e9s entre \u00e9tablissements (produits)",
+ "parent": 18,
+ "position": 1
+ },
+ "19": {
+ "code": 19,
+ "nom": "FONDS D\u00c9DI\u00c9S",
+ "parent": 1,
+ "position": 1
+ },
+ "194": {
+ "code": 194,
+ "nom": "Fonds d\u00e9di\u00e9s sur subventions de fonctionnement",
+ "parent": 19,
+ "position": 1
+ },
+ "195": {
+ "code": 195,
+ "nom": "Fonds d\u00e9di\u00e9s sur dons manuels affect\u00e9s",
+ "parent": 19,
+ "position": 1
+ },
+ "197": {
+ "code": 197,
+ "nom": "Fonds d\u00e9di\u00e9s sur legs et donations affect\u00e9s",
+ "parent": 19,
+ "position": 1
+ },
+ "198": {
+ "code": 198,
+ "nom": "Exc\u00e9dent disponible apr\u00e8s affectation au projet associatif",
+ "parent": 19,
+ "position": 1
+ },
+ "199": {
+ "code": 199,
+ "nom": "Reprise des fonds affect\u00e9s au projet associatif",
+ "parent": 19,
+ "position": 1
+ },
+ "2": {
+ "code": 2,
+ "nom": "Classe 2 \u2014 Comptes d'immobilisations",
+ "parent": 0,
+ "position": 2
+ },
+ "20": {
+ "code": 20,
+ "nom": "IMMOBILISATIONS INCORPORELLES",
+ "parent": 2,
+ "position": 2
+ },
+ "200": {
+ "code": 200,
+ "nom": "Immobilisations incorporelles",
+ "parent": 20,
+ "position": 2
+ },
+ "21": {
+ "code": 21,
+ "nom": "IMMOBILISATIONS CORPORELLES",
+ "parent": 2,
+ "position": 2
+ },
+ "210": {
+ "code": 210,
+ "nom": "Investissements",
+ "parent": 21,
+ "position": 2
+ },
+ "22": {
+ "code": 22,
+ "nom": "IMMOBILISATIONS GREV\u00c9ES DE DROITS",
+ "parent": 2,
+ "position": 2
+ },
+ "228": {
+ "code": 228,
+ "nom": "Immobilisations grev\u00e9es de droits",
+ "parent": 22,
+ "position": 2
+ },
+ "229": {
+ "code": 229,
+ "nom": "Droits des propri\u00e9taires",
+ "parent": 22,
+ "position": 2
+ },
+ "23": {
+ "code": 23,
+ "nom": "IMMOBILISATIONS EN COURS",
+ "parent": 2,
+ "position": 2
+ },
+ "231": {
+ "code": 231,
+ "nom": "Immobilisations corporelles en cours",
+ "parent": 23,
+ "position": 2
+ },
+ "238": {
+ "code": 238,
+ "nom": "Avances et acomptes vers\u00e9s sur commande d'immobilisations corporelles",
+ "parent": 23,
+ "position": 2
+ },
+ "26": {
+ "code": 26,
+ "nom": "PARTICIPATIONS ET CR\u00c9ANCES RATTACH\u00c9ES A DES PARTICIPATIONS",
+ "parent": 2,
+ "position": 2
+ },
+ "261": {
+ "code": 261,
+ "nom": "Titres de participation",
+ "parent": 26,
+ "position": 2
+ },
+ "27": {
+ "code": 27,
+ "nom": "AUTRES IMMOBILISATIONS FINANCI\u00c8RES",
+ "parent": 2,
+ "position": 2
+ },
+ "270": {
+ "code": 270,
+ "nom": "Participations financi\u00e8res",
+ "parent": 27,
+ "position": 2
+ },
+ "275": {
+ "code": 275,
+ "nom": "D\u00e9p\u00f4ts et cautionnements vers\u00e9s",
+ "parent": 27,
+ "position": 2
+ },
+ "28": {
+ "code": 28,
+ "nom": "AMORTISSEMENTS DES IMMOBILISATIONS",
+ "parent": 2,
+ "position": 2
+ },
+ "280": {
+ "code": 280,
+ "nom": "Amortissements des immobilisations incorporelles",
+ "parent": 28,
+ "position": 2
+ },
+ "281": {
+ "code": 281,
+ "nom": "Amortissements des immobilisations corporelles",
+ "parent": 28,
+ "position": 2
+ },
+ "29": {
+ "code": 29,
+ "nom": "D\u00c9PR\u00c9CIATION DES IMMOBILISATIONS",
+ "parent": 2,
+ "position": 2
+ },
+ "290": {
+ "code": 290,
+ "nom": "D\u00e9pr\u00e9ciation des immobilisations incorporelles",
+ "parent": 29,
+ "position": 2
+ },
+ "291": {
+ "code": 291,
+ "nom": "D\u00e9pr\u00e9ciation des immobilisations corporelles",
+ "parent": 29,
+ "position": 2
+ },
+ "3": {
+ "code": 3,
+ "nom": "Classe 3 \u2014 Comptes de stocks",
+ "parent": 0,
+ "position": 2
+ },
+ "31": {
+ "code": 31,
+ "nom": "MATIERES PREMIERES ET FOURNITURES",
+ "parent": 3,
+ "position": 2
+ },
+ "311": {
+ "code": 311,
+ "nom": "Mati\u00e8res",
+ "parent": 31,
+ "position": 2
+ },
+ "317": {
+ "code": 317,
+ "nom": "Fournitures",
+ "parent": 31,
+ "position": 2
+ },
+ "32": {
+ "code": 32,
+ "nom": "AUTRES APPROVISIONNEMENTS",
+ "parent": 3,
+ "position": 2
+ },
+ "321": {
+ "code": 321,
+ "nom": "Mati\u00e8res consommables",
+ "parent": 32,
+ "position": 2
+ },
+ "322": {
+ "code": 322,
+ "nom": "Fournitures consommables",
+ "parent": 32,
+ "position": 2
+ },
+ "33": {
+ "code": 33,
+ "nom": "EN-COURS DE PRODUCTION DE BIENS",
+ "parent": 3,
+ "position": 2
+ },
+ "331": {
+ "code": 331,
+ "nom": "Produits en cours",
+ "parent": 33,
+ "position": 2
+ },
+ "335": {
+ "code": 335,
+ "nom": "Travaux en cours",
+ "parent": 33,
+ "position": 2
+ },
+ "34": {
+ "code": 34,
+ "nom": "EN-COURS DE PRODUCTION DE SERVICES",
+ "parent": 3,
+ "position": 2
+ },
+ "35": {
+ "code": 35,
+ "nom": "STOCKS DE PRODUITS",
+ "parent": 3,
+ "position": 2
+ },
+ "351": {
+ "code": 351,
+ "nom": "Produits interm\u00e9diaires",
+ "parent": 35,
+ "position": 2
+ },
+ "355": {
+ "code": 355,
+ "nom": "Produits finis",
+ "parent": 35,
+ "position": 2
+ },
+ "358": {
+ "code": 358,
+ "nom": "Produits r\u00e9siduels",
+ "parent": 35,
+ "position": 2
+ },
+ "3581": {
+ "code": 3581,
+ "nom": "D\u00e9chets",
+ "parent": 358,
+ "position": 2
+ },
+ "3585": {
+ "code": 3585,
+ "nom": "Rebuts",
+ "parent": 358,
+ "position": 2
+ },
+ "3586": {
+ "code": 3586,
+ "nom": "Mati\u00e8re de r\u00e9cup\u00e9ration",
+ "parent": 358,
+ "position": 2
+ },
+ "37": {
+ "code": 37,
+ "nom": "STOCKS DE MARCHANDISES",
+ "parent": 3,
+ "position": 2
+ },
+ "370": {
+ "code": 370,
+ "nom": "Autres stocks de marchandises",
+ "parent": 37,
+ "position": 2
+ },
+ "39": {
+ "code": 39,
+ "nom": "PROVISIONS POUR DEPRECIATION DES STOCKS ET EN-COURS",
+ "parent": 3,
+ "position": 2
+ },
+ "391": {
+ "code": 391,
+ "nom": "Provisions pour d\u00e9pr\u00e9ciation des mati\u00e8res premi\u00e8res et fournitures",
+ "parent": 39,
+ "position": 2
+ },
+ "4": {
+ "code": 4,
+ "nom": "Classe 4 \u2014 Comptes de tiers",
+ "parent": 0,
+ "position": 3
+ },
+ "40": {
+ "code": 40,
+ "nom": "FOURNISSEURS ET COMPTES RATTACH\u00c9S",
+ "parent": 4,
+ "position": 1
+ },
+ "401": {
+ "code": 401,
+ "nom": "Fournisseurs",
+ "parent": 40,
+ "position": 1
+ },
+ "4010": {
+ "code": 4010,
+ "nom": "Autres fournisseurs",
+ "parent": 401,
+ "position": 1
+ },
+ "408": {
+ "code": 408,
+ "nom": "Fournisseurs - Factures non parvenues",
+ "parent": 40,
+ "position": 1
+ },
+ "409": {
+ "code": 409,
+ "nom": "Avances aux fournisseurs",
+ "parent": 40,
+ "position": 2
+ },
+ "41": {
+ "code": 41,
+ "nom": "USAGERS ET COMPTES RATTACH\u00c9S",
+ "parent": 4,
+ "position": 2
+ },
+ "411": {
+ "code": 411,
+ "nom": "Usagers",
+ "parent": 41,
+ "position": 2
+ },
+ "4110": {
+ "code": 4110,
+ "nom": "Autres usagers",
+ "parent": 411,
+ "position": 2
+ },
+ "419": {
+ "code": 419,
+ "nom": "Avances aux usagers",
+ "parent": 41,
+ "position": 1
+ },
+ "42": {
+ "code": 42,
+ "nom": "PERSONNEL ET COMPTES RATTACH\u00c9S",
+ "parent": 4,
+ "position": 1
+ },
+ "421": {
+ "code": 421,
+ "nom": "Personnel - R\u00e9mun\u00e9rations dues",
+ "parent": 42,
+ "position": 1
+ },
+ "4210": {
+ "code": 4210,
+ "nom": "Autres membres du personnel",
+ "parent": 421,
+ "position": 1
+ },
+ "425": {
+ "code": 425,
+ "nom": "Personnel - Avances et acomptes",
+ "parent": 42,
+ "position": 2
+ },
+ "428": {
+ "code": 428,
+ "nom": "Personnel - Charges \u00e0 payer et produits \u00e0 recevoir",
+ "parent": 42,
+ "position": 1
+ },
+ "43": {
+ "code": 43,
+ "nom": "S\u00c9CURIT\u00c9 SOCIALE ET AUTRES ORGANISMES SOCIAUX",
+ "parent": 4,
+ "position": 1
+ },
+ "430": {
+ "code": 430,
+ "nom": "Dettes et cr\u00e9dits envers les organismes sociaux",
+ "parent": 43,
+ "position": 1
+ },
+ "431": {
+ "code": 431,
+ "nom": "S\u00e9curit\u00e9 sociale",
+ "parent": 43,
+ "position": 1
+ },
+ "437": {
+ "code": 437,
+ "nom": "Autres organismes sociaux",
+ "parent": 43,
+ "position": 1
+ },
+ "4372": {
+ "code": 4372,
+ "nom": "Mutuelles",
+ "parent": 437,
+ "position": 1
+ },
+ "4373": {
+ "code": 4373,
+ "nom": "Caisse de retraite et de pr\u00e9voyance",
+ "parent": 437,
+ "position": 1
+ },
+ "4374": {
+ "code": 4374,
+ "nom": "Caisse d'allocations de ch\u00f4mage - P\u00f4le emploi",
+ "parent": 437,
+ "position": 1
+ },
+ "4375": {
+ "code": 4375,
+ "nom": "AGESSA",
+ "parent": 437,
+ "position": 1
+ },
+ "4378": {
+ "code": 4378,
+ "nom": "Autres organismes sociaux - Divers",
+ "parent": 437,
+ "position": 1
+ },
+ "438": {
+ "code": 438,
+ "nom": "Organismes sociaux - Charges \u00e0 payer et produits \u00e0 recevoir",
+ "parent": 43,
+ "position": 1
+ },
+ "4382": {
+ "code": 4382,
+ "nom": "Charges sociales sur cong\u00e9s \u00e0 payer",
+ "parent": 438,
+ "position": 1
+ },
+ "4386": {
+ "code": 4386,
+ "nom": "Autres charges \u00e0 payer",
+ "parent": 438,
+ "position": 1
+ },
+ "4387": {
+ "code": 4387,
+ "nom": "Produits \u00e0 recevoir",
+ "parent": 438,
+ "position": 2
+ },
+ "439": {
+ "code": 439,
+ "nom": "Avances aupr\u00e8s des organismes sociaux",
+ "parent": 43,
+ "position": 1
+ },
+ "44": {
+ "code": 44,
+ "nom": "\u00c9TAT ET AUTRES COLLECTIVIT\u00c9S PUBLIQUES",
+ "parent": 4,
+ "position": 2
+ },
+ "441": {
+ "code": 441,
+ "nom": "\u00c9tat - Subventions \u00e0 recevoir",
+ "parent": 44,
+ "position": 2
+ },
+ "4411": {
+ "code": 4411,
+ "nom": "Subventions d'investissement",
+ "parent": 441,
+ "position": 2
+ },
+ "4417": {
+ "code": 4417,
+ "nom": "Subventions d'exploitation",
+ "parent": 441,
+ "position": 2
+ },
+ "4418": {
+ "code": 4418,
+ "nom": "Subventions d'\u00e9quilibre",
+ "parent": 441,
+ "position": 2
+ },
+ "4419": {
+ "code": 4419,
+ "nom": "Avances sur subventions",
+ "parent": 441,
+ "position": 2
+ },
+ "442": {
+ "code": 442,
+ "nom": "\u00c9tat - Imp\u00f4ts et taxes recouvrables sur des tiers",
+ "parent": 44,
+ "position": 1
+ },
+ "444": {
+ "code": 444,
+ "nom": "\u00c9tat - Imp\u00f4ts sur les b\u00e9n\u00e9fices",
+ "parent": 44,
+ "position": 2
+ },
+ "445": {
+ "code": 445,
+ "nom": "\u00c9tat - Taxes sur le chiffre d'affaires",
+ "parent": 44,
+ "position": 2
+ },
+ "4455": {
+ "code": 4455,
+ "nom": "Taxes sur le chiffre d'affaires \u00e0 d\u00e9caisser",
+ "parent": 445,
+ "position": 2
+ },
+ "44551": {
+ "code": 44551,
+ "nom": "TVA \u00e0 d\u00e9caisser",
+ "parent": 4455,
+ "position": 2
+ },
+ "44558": {
+ "code": 44558,
+ "nom": "Taxes assimil\u00e9es \u00e0 la TVA",
+ "parent": 4455,
+ "position": 2
+ },
+ "4456": {
+ "code": 4456,
+ "nom": "Taxes sur le chiffre d'affaires d\u00e9ductibles",
+ "parent": 445,
+ "position": 2
+ },
+ "44562": {
+ "code": 44562,
+ "nom": "TVA sur immobilisations",
+ "parent": 4456,
+ "position": 2
+ },
+ "44566": {
+ "code": 44566,
+ "nom": "TVA sur autres biens et services",
+ "parent": 4456,
+ "position": 2
+ },
+ "4457": {
+ "code": 4457,
+ "nom": "Taxes sur le chiffre d'affaires collect\u00e9es par l'association",
+ "parent": 445,
+ "position": 2
+ },
+ "4458": {
+ "code": 4458,
+ "nom": "Taxes sur le chiffre d'affaires \u00e0 r\u00e9gulariser ou en attente",
+ "parent": 445,
+ "position": 2
+ },
+ "44581": {
+ "code": 44581,
+ "nom": "Acomptes - R\u00e9gime simplifi\u00e9 d'imposition",
+ "parent": 4458,
+ "position": 2
+ },
+ "44582": {
+ "code": 44582,
+ "nom": "Acomptes - R\u00e9gime du forfait",
+ "parent": 4458,
+ "position": 2
+ },
+ "44583": {
+ "code": 44583,
+ "nom": "Remboursement de taxes sur le chiffre d'affaires demand\u00e9",
+ "parent": 4458,
+ "position": 2
+ },
+ "44584": {
+ "code": 44584,
+ "nom": "TVA r\u00e9cup\u00e9r\u00e9e d'avance",
+ "parent": 4458,
+ "position": 2
+ },
+ "44586": {
+ "code": 44586,
+ "nom": "Taxes sur le chiffre d'affaires sur factures non parvenues",
+ "parent": 4458,
+ "position": 2
+ },
+ "44587": {
+ "code": 44587,
+ "nom": "Taxes sur le chiffre d'affaires sur factures \u00e0 \u00e9tablir",
+ "parent": 4458,
+ "position": 2
+ },
+ "447": {
+ "code": 447,
+ "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s",
+ "parent": 44,
+ "position": 1
+ },
+ "4471": {
+ "code": 4471,
+ "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s sur r\u00e9mun\u00e9rations (Administration des imp\u00f4ts)",
+ "parent": 447,
+ "position": 1
+ },
+ "44711": {
+ "code": 44711,
+ "nom": "Taxe sur les salaires",
+ "parent": 4471,
+ "position": 1
+ },
+ "44713": {
+ "code": 44713,
+ "nom": "Participation des employeurs \u00e0 la formation professionnelle continue",
+ "parent": 4471,
+ "position": 1
+ },
+ "44714": {
+ "code": 44714,
+ "nom": "Cotisation par d\u00e9faut d'investissement obligatoire dans la construction",
+ "parent": 4471,
+ "position": 1
+ },
+ "44718": {
+ "code": 44718,
+ "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s",
+ "parent": 4471,
+ "position": 1
+ },
+ "4473": {
+ "code": 4473,
+ "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s sur r\u00e9mun\u00e9rations (Autres organismes)",
+ "parent": 447,
+ "position": 1
+ },
+ "44733": {
+ "code": 44733,
+ "nom": "Participation des employeurs \u00e0 la formation professionnelle continue",
+ "parent": 4473,
+ "position": 1
+ },
+ "44734": {
+ "code": 44734,
+ "nom": "Participation des employeurs \u00e0 l'effort de construction (versements \u00e0 fonds perdus)",
+ "parent": 4473,
+ "position": 1
+ },
+ "4475": {
+ "code": 4475,
+ "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s (Administration des imp\u00f4ts)",
+ "parent": 447,
+ "position": 1
+ },
+ "4477": {
+ "code": 4477,
+ "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s (Autres organismes)",
+ "parent": 447,
+ "position": 1
+ },
+ "448": {
+ "code": 448,
+ "nom": "\u00c9tat - Charges \u00e0 payer et produits \u00e0 recevoir",
+ "parent": 44,
+ "position": 1
+ },
+ "4482": {
+ "code": 4482,
+ "nom": "Charges fiscales sur cong\u00e9s \u00e0 payer",
+ "parent": 448,
+ "position": 1
+ },
+ "4486": {
+ "code": 4486,
+ "nom": "Autres charges \u00e0 payer",
+ "parent": 448,
+ "position": 1
+ },
+ "4487": {
+ "code": 4487,
+ "nom": "Produits \u00e0 recevoir",
+ "parent": 448,
+ "position": 2
+ },
+ "449": {
+ "code": 449,
+ "nom": "Avances aupr\u00e8s de l'\u00e9tat et des collectivit\u00e9s publiques",
+ "parent": 44,
+ "position": 1
+ },
+ "45": {
+ "code": 45,
+ "nom": "CONF\u00c9D\u00c9RATION, F\u00c9D\u00c9RATION, UNIONS ET ASSOCIATIONS AFFILI\u00c9ES",
+ "parent": 4,
+ "position": 3
+ },
+ "451": {
+ "code": 451,
+ "nom": "Conf\u00e9d\u00e9ration, f\u00e9d\u00e9ration et associations affili\u00e9es - Compte courant",
+ "parent": 45,
+ "position": 3
+ },
+ "455": {
+ "code": 455,
+ "nom": "Soci\u00e9taires - Comptes courants",
+ "parent": 45,
+ "position": 3
+ },
+ "46": {
+ "code": 46,
+ "nom": "D\u00c9BITEURS DIVERS ET CR\u00c9DITEURS DIVERS",
+ "parent": 4,
+ "position": 3
+ },
+ "467": {
+ "code": 467,
+ "nom": "Autres comptes d\u00e9biteurs et cr\u00e9diteurs",
+ "parent": 46,
+ "position": 3
+ },
+ "468": {
+ "code": 468,
+ "nom": "Divers - Charges \u00e0 payer et produits \u00e0 recevoir",
+ "parent": 46,
+ "position": 3
+ },
+ "4686": {
+ "code": 4686,
+ "nom": "Charges \u00e0 payer",
+ "parent": 468,
+ "position": 1
+ },
+ "4687": {
+ "code": 4687,
+ "nom": "Produits \u00e0 recevoir",
+ "parent": 468,
+ "position": 2
+ },
+ "47": {
+ "code": 47,
+ "nom": "COMPTES TRANSITOIRES OU D'ATTENTE",
+ "parent": 4,
+ "position": 3
+ },
+ "471": {
+ "code": 471,
+ "nom": "Recettes \u00e0 classer",
+ "parent": 47,
+ "position": 1
+ },
+ "472": {
+ "code": 472,
+ "nom": "D\u00e9penses \u00e0 classer et \u00e0 r\u00e9gulariser",
+ "parent": 47,
+ "position": 2
+ },
+ "48": {
+ "code": 48,
+ "nom": "COMPTES DE R\u00c9GULARISATION",
+ "parent": 4,
+ "position": 3
+ },
+ "481": {
+ "code": 481,
+ "nom": "Charges \u00e0 r\u00e9partir sur plusieurs exercices",
+ "parent": 48,
+ "position": 2
+ },
+ "486": {
+ "code": 486,
+ "nom": "Charges constat\u00e9es d'avance",
+ "parent": 48,
+ "position": 2
+ },
+ "487": {
+ "code": 487,
+ "nom": "Produits constat\u00e9s d'avance",
+ "parent": 48,
+ "position": 1
+ },
+ "49": {
+ "code": 49,
+ "nom": "DEPRECIATION DES COMPTES DE TIERS",
+ "parent": 4,
+ "position": 2
+ },
+ "491": {
+ "code": 491,
+ "nom": "D\u00e9pr\u00e9ciation des comptes clients",
+ "parent": 49,
+ "position": 2
+ },
+ "496": {
+ "code": 496,
+ "nom": "D\u00e9pr\u00e9ciation des comptes d\u00e9biteurs divers",
+ "parent": 49,
+ "position": 2
+ },
+ "5": {
+ "code": 5,
+ "nom": "Classe 5 \u2014 Comptes financiers",
+ "parent": 0,
+ "position": 2
+ },
+ "50": {
+ "code": 50,
+ "nom": "VALEURS MOBILI\u00c8RES DE PLACEMENT",
+ "parent": 5,
+ "position": 2
+ },
+ "51": {
+ "code": 51,
+ "nom": "BANQUES, \u00c9TABLISSEMENTS FINANCIERS ET ASSIMIL\u00c9S",
+ "parent": 5,
+ "position": 2
+ },
+ "511": {
+ "code": 511,
+ "nom": "Valeurs à l'encaissement",
+ "parent": 51,
+ "position": 2
+ },
+ "5112": {
+ "code": 5112,
+ "nom": "Chèques à encaisser",
+ "parent": 511,
+ "position": 2
+ },
+ "5115": {
+ "code": 5115,
+ "nom": "Paiements par carte à encaisser",
+ "parent": 511,
+ "position": 2
+ },
+ "512": {
+ "code": 512,
+ "nom": "Banques",
+ "parent": 51,
+ "position": 2
+ },
+ "53": {
+ "code": 53,
+ "nom": "CAISSE",
+ "parent": 5,
+ "position": 2
+ },
+ "530": {
+ "code": 530,
+ "nom": "Caisse",
+ "parent": 53,
+ "position": 2
+ },
+ "54": {
+ "code": 54,
+ "nom": "R\u00c9GIES D'AVANCES ET ACCR\u00c9DITIFS",
+ "parent": 5,
+ "position": 2
+ },
+ "58": {
+ "code": 58,
+ "nom": "VIREMENTS INTERNES",
+ "parent": 5,
+ "position": 2
+ },
+ "59": {
+ "code": 59,
+ "nom": "PROVISIONS POUR D\u00c9PR\u00c9CIATION DES COMPTES FINANCIERS",
+ "parent": 5,
+ "position": 2
+ },
+ "6": {
+ "code": 6,
+ "nom": "Classe 6 \u2014 Comptes de charges",
+ "parent": 0,
+ "position": 8
+ },
+ "60": {
+ "code": 60,
+ "nom": "ACHATS",
+ "parent": 6,
+ "position": 8
+ },
+ "601": {
+ "code": 601,
+ "nom": "Achats stock\u00e9s - Mati\u00e8res premi\u00e8res et fournitures",
+ "parent": 60,
+ "position": 8
+ },
+ "602": {
+ "code": 602,
+ "nom": "Achats stock\u00e9s - Autres approvisionnements",
+ "parent": 60,
+ "position": 8
+ },
+ "604": {
+ "code": 604,
+ "nom": "Achat d'\u00e9tudes et prestations de services",
+ "parent": 60,
+ "position": 8
+ },
+ "606": {
+ "code": 606,
+ "nom": "Achats non stock\u00e9s de mati\u00e8res et fournitures",
+ "parent": 60,
+ "position": 8
+ },
+ "6061": {
+ "code": 6061,
+ "nom": "Fournitures non stockables (eau, \u00e9nergie...)",
+ "parent": 606,
+ "position": 8
+ },
+ "6063": {
+ "code": 6063,
+ "nom": "Fournitures d'entretien et de petit \u00e9quipement",
+ "parent": 606,
+ "position": 8
+ },
+ "6064": {
+ "code": 6064,
+ "nom": "Fournitures administratives",
+ "parent": 606,
+ "position": 8
+ },
+ "6068": {
+ "code": 6068,
+ "nom": "Autres mati\u00e8res et fournitures",
+ "parent": 606,
+ "position": 8
+ },
+ "607": {
+ "code": 607,
+ "nom": "Achats de marchandises",
+ "parent": 60,
+ "position": 8
+ },
+ "61": {
+ "code": 61,
+ "nom": "SERVICES EXT\u00c9RIEURS",
+ "parent": 6,
+ "position": 8
+ },
+ "611": {
+ "code": 611,
+ "nom": "Sous-traitance g\u00e9n\u00e9rale",
+ "parent": 61,
+ "position": 8
+ },
+ "612": {
+ "code": 612,
+ "nom": "Redevances de cr\u00e9dit-bail",
+ "parent": 61,
+ "position": 8
+ },
+ "613": {
+ "code": 613,
+ "nom": "Locations",
+ "parent": 61,
+ "position": 8
+ },
+ "614": {
+ "code": 614,
+ "nom": "Charges locatives et de co-propri\u00e9t\u00e9",
+ "parent": 61,
+ "position": 8
+ },
+ "615": {
+ "code": 615,
+ "nom": "Entretiens et r\u00e9parations",
+ "parent": 61,
+ "position": 8
+ },
+ "616": {
+ "code": 616,
+ "nom": "Primes d'assurance",
+ "parent": 61,
+ "position": 8
+ },
+ "618": {
+ "code": 618,
+ "nom": "Divers",
+ "parent": 61,
+ "position": 8
+ },
+ "62": {
+ "code": 62,
+ "nom": "AUTRES SERVICES EXT\u00c9RIEURS",
+ "parent": 6,
+ "position": 8
+ },
+ "621": {
+ "code": 621,
+ "nom": "Personnel ext\u00e9rieur \u00e0 l'association",
+ "parent": 62,
+ "position": 8
+ },
+ "622": {
+ "code": 622,
+ "nom": "R\u00e9mun\u00e9rations d'interm\u00e9diaires et honoraires",
+ "parent": 62,
+ "position": 8
+ },
+ "6226": {
+ "code": 6226,
+ "nom": "Honoraires",
+ "parent": 622,
+ "position": 8
+ },
+ "6227": {
+ "code": 6227,
+ "nom": "Frais d'actes et de contentieux",
+ "parent": 622,
+ "position": 8
+ },
+ "6228": {
+ "code": 6228,
+ "nom": "Divers",
+ "parent": 622,
+ "position": 8
+ },
+ "623": {
+ "code": 623,
+ "nom": "Publicit\u00e9, publications, relations publiques",
+ "parent": 62,
+ "position": 8
+ },
+ "624": {
+ "code": 624,
+ "nom": "Transports de biens et transports collectifs du personnel",
+ "parent": 62,
+ "position": 8
+ },
+ "625": {
+ "code": 625,
+ "nom": "D\u00e9placements, missions et r\u00e9ceptions",
+ "parent": 62,
+ "position": 8
+ },
+ "626": {
+ "code": 626,
+ "nom": "Frais postaux et de t\u00e9l\u00e9communications",
+ "parent": 62,
+ "position": 8
+ },
+ "627": {
+ "code": 627,
+ "nom": "Services bancaires et assimil\u00e9s",
+ "parent": 62,
+ "position": 8
+ },
+ "628": {
+ "code": 628,
+ "nom": "Divers",
+ "parent": 62,
+ "position": 8
+ },
+ "63": {
+ "code": 63,
+ "nom": "IMP\u00d4TS, TAXES ET VERSEMENTS ASSIMIL\u00c9S",
+ "parent": 6,
+ "position": 8
+ },
+ "631": {
+ "code": 631,
+ "nom": "Imp\u00f4ts, taxes et versements assimil\u00e9s sur r\u00e9mun\u00e9rations (Administration des imp\u00f4ts)",
+ "parent": 63,
+ "position": 8
+ },
+ "6311": {
+ "code": 6311,
+ "nom": "Taxes sur les salaires",
+ "parent": 631,
+ "position": 8
+ },
+ "6313": {
+ "code": 6313,
+ "nom": "Participations des employeurs \u00e0 la formation professionnelle continue",
+ "parent": 631,
+ "position": 8
+ },
+ "635": {
+ "code": 635,
+ "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s (Administration des imp\u00f4ts)",
+ "parent": 63,
+ "position": 8
+ },
+ "6351": {
+ "code": 6351,
+ "nom": "Imp\u00f4ts directs (sauf imp\u00f4ts sur les b\u00e9n\u00e9fices)",
+ "parent": 635,
+ "position": 8
+ },
+ "6353": {
+ "code": 6353,
+ "nom": "Imp\u00f4ts indirects",
+ "parent": 635,
+ "position": 8
+ },
+ "637": {
+ "code": 637,
+ "nom": "Autres imp\u00f4ts, taxes et versements assimil\u00e9s (Autres organismes)",
+ "parent": 63,
+ "position": 8
+ },
+ "64": {
+ "code": 64,
+ "nom": "CHARGES DE PERSONNEL",
+ "parent": 6,
+ "position": 8
+ },
+ "641": {
+ "code": 641,
+ "nom": "R\u00e9mun\u00e9rations du personnel",
+ "parent": 64,
+ "position": 8
+ },
+ "643": {
+ "code": 643,
+ "nom": "R\u00e9mun\u00e9rations du personnel artistique et assimil\u00e9s",
+ "parent": 64,
+ "position": 8
+ },
+ "645": {
+ "code": 645,
+ "nom": "Charges de s\u00e9curit\u00e9 sociale et de pr\u00e9voyance",
+ "parent": 64,
+ "position": 8
+ },
+ "647": {
+ "code": 647,
+ "nom": "Autres charges sociales",
+ "parent": 64,
+ "position": 8
+ },
+ "648": {
+ "code": 648,
+ "nom": "Autres charges de personnel",
+ "parent": 64,
+ "position": 8
+ },
+ "65": {
+ "code": 65,
+ "nom": "AUTRES CHARGES DE GESTION COURANTE",
+ "parent": 6,
+ "position": 8
+ },
+ "652": {
+ "code": 652,
+ "nom": "Licences fédérales",
+ "parent": 652,
+ "position": 8
+ },
+ "658": {
+ "code": 658,
+ "nom": "Charges diverses de gestion courante",
+ "parent": 65,
+ "position": 8
+ },
+ "66": {
+ "code": 66,
+ "nom": "CHARGES FINANCI\u00c8RES",
+ "parent": 6,
+ "position": 8
+ },
+ "661": {
+ "code": 661,
+ "nom": "Charges d'int\u00e9r\u00eats",
+ "parent": 66,
+ "position": 8
+ },
+ "67": {
+ "code": 67,
+ "nom": "CHARGES EXCEPTIONNELLES",
+ "parent": 6,
+ "position": 8
+ },
+ "671": {
+ "code": 671,
+ "nom": "Charges exceptionnelles sur op\u00e9rations de gestion",
+ "parent": 67,
+ "position": 8
+ },
+ "6713": {
+ "code": 6713,
+ "nom": "Dons, lib\u00e9ralit\u00e9s",
+ "parent": 671,
+ "position": 8
+ },
+ "678": {
+ "code": 678,
+ "nom": "Autres charges exceptionnelles",
+ "parent": 67,
+ "position": 8
+ },
+ "6788": {
+ "code": 6788,
+ "nom": "Charges exceptionnelles diverses",
+ "parent": 678,
+ "position": 8
+ },
+ "68": {
+ "code": 68,
+ "nom": "DOTATIONS AUX AMORTISSEMENTS, D\u00c9PR\u00c9CIATIONS, PROVISIONS ET ENGAGEMENTS",
+ "parent": 6,
+ "position": 8
+ },
+ "681": {
+ "code": 681,
+ "nom": "Dotations aux amortissements, d\u00e9pr\u00e9ciations et provisions - Charges d'exploitation",
+ "parent": 68,
+ "position": 8
+ },
+ "6811": {
+ "code": 6811,
+ "nom": "Dotations aux amortissements des immobilisations incorporelles et corporelles",
+ "parent": 681,
+ "position": 8
+ },
+ "68111": {
+ "code": 68111,
+ "nom": "Immobilisations incorporelles",
+ "parent": 6811,
+ "position": 8
+ },
+ "68112": {
+ "code": 68112,
+ "nom": "Immobilisations corporelles",
+ "parent": 6811,
+ "position": 8
+ },
+ "686": {
+ "code": 686,
+ "nom": "Dotations aux amortissements, d\u00e9pr\u00e9ciations et provisions - Charges financi\u00e8res",
+ "parent": 68,
+ "position": 8
+ },
+ "69": {
+ "code": 69,
+ "nom": "PARTICIPATION DES SALARI\u00c9S - IMP\u00d4TS SUR LES B\u00c9N\u00c9FICES ET ASSIMIL\u00c9S",
+ "parent": 6,
+ "position": 8
+ },
+ "695": {
+ "code": 695,
+ "nom": "Imp\u00f4ts sur les soci\u00e9t\u00e9s (y compris imp\u00f4ts sur les soci\u00e9t\u00e9s des personnes morales non lucratives)",
+ "parent": 69,
+ "position": 8
+ },
+ "7": {
+ "code": 7,
+ "nom": "Classe 7 \u2014 Comptes de produits",
+ "parent": 0,
+ "position": 4
+ },
+ "70": {
+ "code": 70,
+ "nom": "VENTES DE PRODUITS FINIS, PRESTATIONS DE SERVICES, MARCHANDISES",
+ "parent": 7,
+ "position": 4
+ },
+ "701": {
+ "code": 701,
+ "nom": "Ventes de produits finis",
+ "parent": 70,
+ "position": 4
+ },
+ "706": {
+ "code": 706,
+ "nom": "Prestations de services",
+ "parent": 70,
+ "position": 4
+ },
+ "707": {
+ "code": 707,
+ "nom": "Ventes de marchandises",
+ "parent": 70,
+ "position": 4
+ },
+ "708": {
+ "code": 708,
+ "nom": "Produits des activit\u00e9s annexes",
+ "parent": 70,
+ "position": 4
+ },
+ "71": {
+ "code": 71,
+ "nom": "PRODUCTION STOCK\u00c9E (OU D\u00c9STOCKAGE)",
+ "parent": 7,
+ "position": 4
+ },
+ "72": {
+ "code": 72,
+ "nom": "PRODUCTION IMMOBILIS\u00c9E",
+ "parent": 7,
+ "position": 4
+ },
+ "74": {
+ "code": 74,
+ "nom": "SUBVENTIONS D'EXPLOITATION",
+ "parent": 7,
+ "position": 4
+ },
+ "740": {
+ "code": 740,
+ "nom": "Subventions re\u00e7ues",
+ "parent": 74,
+ "position": 4
+ },
+ "75": {
+ "code": 75,
+ "nom": "AUTRES PRODUITS DE GESTION COURANTE",
+ "parent": 7,
+ "position": 4
+ },
+ "754": {
+ "code": 754,
+ "nom": "Collectes",
+ "parent": 75,
+ "position": 4
+ },
+ "756": {
+ "code": 756,
+ "nom": "Cotisations",
+ "parent": 75,
+ "position": 4
+ },
+ "758": {
+ "code": 758,
+ "nom": "Produits divers de gestion courante",
+ "parent": 75,
+ "position": 4
+ },
+ "7587": {
+ "code": 7587,
+ "nom": "Ventes de dons en nature",
+ "parent": 758,
+ "position": 4
+ },
+ "7588": {
+ "code": 7588,
+ "nom": "Autres produits de la g\u00e9n\u00e9rosit\u00e9 du public",
+ "parent": 758,
+ "position": 4
+ },
+ "76": {
+ "code": 76,
+ "nom": "PRODUITS FINANCIERS",
+ "parent": 7,
+ "position": 4
+ },
+ "760": {
+ "code": 760,
+ "nom": "Produits financiers",
+ "parent": 76,
+ "position": 4
+ },
+ "77": {
+ "code": 77,
+ "nom": "PRODUITS EXCEPTIONNELS",
+ "parent": 7,
+ "position": 4
+ },
+ "771": {
+ "code": 771,
+ "nom": "Produits exceptionnels sur op\u00e9rations de gestion",
+ "parent": 77,
+ "position": 4
+ },
+ "7713": {
+ "code": 7713,
+ "nom": "Lib\u00e9ralit\u00e9s re\u00e7ues",
+ "parent": 771,
+ "position": 4
+ },
+ "7715": {
+ "code": 7715,
+ "nom": "Subventions d'\u00e9quilibre",
+ "parent": 771,
+ "position": 4
+ },
+ "775": {
+ "code": 775,
+ "nom": "Produits des cessions d'\u00e9l\u00e9ments d'actifs",
+ "parent": 77,
+ "position": 4
+ },
+ "778": {
+ "code": 778,
+ "nom": "Autres produits exceptionnels",
+ "parent": 77,
+ "position": 4
+ },
+ "7780": {
+ "code": 7780,
+ "nom": "Manifestations diverses",
+ "parent": 778,
+ "position": 4
+ },
+ "7788": {
+ "code": 7788,
+ "nom": "Produits exceptionnels divers",
+ "parent": 778,
+ "position": 4
+ },
+ "78": {
+ "code": 78,
+ "nom": "REPRISES SUR AMORTISSEMENTS ET PROVISIONS",
+ "parent": 7,
+ "position": 4
+ },
+ "79": {
+ "code": 79,
+ "nom": "TRANSFERT DE CHARGES",
+ "parent": 7,
+ "position": 4
+ },
+ "791": {
+ "code": 791,
+ "nom": "Transferts de charges d'exploitation",
+ "parent": 79,
+ "position": 4
+ },
+ "796": {
+ "code": 796,
+ "nom": "Transferts de charges financi\u00e8res",
+ "parent": 79,
+ "position": 4
+ },
+ "797": {
+ "code": 797,
+ "nom": "Transferts de charges exceptionnels",
+ "parent": 79,
+ "position": 4
+ },
+ "8": {
+ "code": 8,
+ "nom": "Classe 8 \u00ad\u2014 Comptes sp\u00e9ciaux",
+ "parent": 0,
+ "position": 12
+ },
+ "86": {
+ "code": 86,
+ "nom": "R\u00c9PARTITION PAR NATURE DE CHARGES",
+ "parent": 8,
+ "position": 8
+ },
+ "861": {
+ "code": 861,
+ "nom": "Mise \u00e0 dispositions gratuites de biens",
+ "parent": 86,
+ "position": 8
+ },
+ "862": {
+ "code": 862,
+ "nom": "Prestations",
+ "parent": 86,
+ "position": 8
+ },
+ "864": {
+ "code": 864,
+ "nom": "Personnel b\u00e9n\u00e9vole",
+ "parent": 86,
+ "position": 8
+ },
+ "87": {
+ "code": 87,
+ "nom": "R\u00c9PARTITION PAR NATURE DE RESSOURCES",
+ "parent": 8,
+ "position": 4
+ },
+ "870": {
+ "code": 870,
+ "nom": "B\u00e9n\u00e9volat",
+ "parent": 87,
+ "position": 4
+ },
+ "871": {
+ "code": 871,
+ "nom": "Prestations en nature",
+ "parent": 87,
+ "position": 4
+ },
+ "875": {
+ "code": 875,
+ "nom": "Dons en nature",
+ "parent": 87,
+ "position": 4
+ },
+ "89": {
+ "code": 89,
+ "nom": "BILAN",
+ "parent": 8,
+ "position": 3
+ },
+ "890": {
+ "code": 890,
+ "nom": "Bilan d'ouverture",
+ "parent": 89,
+ "position": 3
+ },
+ "891": {
+ "code": 891,
+ "nom": "Bilan de clôture",
+ "parent": 89,
+ "position": 3
+ },
+ "9": {
+ "code": 9,
+ "nom": "Classe 9 \u2014 Comptes analytiques",
+ "parent": 0,
+ "position": 12
+ }
+}
\ No newline at end of file
diff --git a/build/debian/config.debian.php b/build/debian/config.debian.php
new file mode 100644
index 0000000..14664fc
--- /dev/null
+++ b/build/debian/config.debian.php
@@ -0,0 +1,141 @@
+ DEBIAN/md5sums
+
+true && {
+ echo "Generating Debian-specific files..."
+ cp ${THISDIR}/../../COPYING ${DEBLOCALPREFIX}/share/doc/${PACKAGE_DEBNAME}/copyright
+} || {
+ echo "Fail."
+ exit 1
+}
+
+true && {
+ cat < DEBIAN/postinst
+#!/bin/sh
+
+chown www-data:www-data /var/lib/paheko /var/cache/paheko
+chown root:www-data /etc/paheko
+chmod g=rX,o= /etc/paheko
+chmod ug=rwX,o= /var/lib/paheko /var/cache/paheko
+EOF
+
+ chmod +x DEBIAN/postinst
+
+}
+
+true && {
+ CHANGELOG=${DEBLOCALPREFIX}/share/doc/${PACKAGE_DEBNAME}/changelog.gz
+ cat < ${CHANGELOG}
+${PACKAGE_DEBNAME} ${PACKAGE_DEB_VERSION}; urgency=low
+
+This release has no changes over the core source distribution. It has
+simply been Debianized.
+
+Packaged by ${USER} on
+${PACKAGE_TIME}.
+
+EOF
+
+}
+
+# doc.
+DOCDIR=${DEBLOCALPREFIX}/share/doc/${PACKAGE_DEBNAME}
+
+true && {
+ echo "Generating doc..."
+ cp ${THISDIR}/../../README.md ${DOCDIR}
+ a2x --doctype manpage --format manpage ${THISDIR}/manpage.txt
+ mkdir -p ${DEBLOCALPREFIX}/share/man/man1
+ gzip -c ${THISDIR}/paheko.1 > ${DEBLOCALPREFIX}/share/man/man1/${PACKAGE_DEBNAME}.1.gz
+ rm -f ${THISDIR}/paheko.1
+} || {
+ echo "Fail."
+ exit 1
+}
+
+true && {
+ CONTROL=DEBIAN/control
+ echo "Generating ${CONTROL}..."
+ cat < ${CONTROL}
+Package: ${PACKAGE_DEBNAME}
+Section: web
+Priority: optional
+Maintainer: Paheko
+Architecture: ${DEB_ARCH_NAME}
+Depends: dash | bash, php-cli (>=7.4), php-sqlite3, php-intl, php-mbstring, sensible-utils
+Version: ${PACKAGE_DEB_VERSION}
+Suggests: php-imagick
+Replaces: garradin (<< 1.2.3~)
+Breaks: garradin (<< 1.2.3~)
+Homepage: https://fossil.kd2.org/paheko/
+Description: Paheko is a tool to manage non-profit organizations.
+ It's only available in french.
+Description-fr: Gestionnaire d'association en interface web ou CLI.
+ Paheko est un gestionnaire d'association à but non lucratif.
+ Il permet de gérer les membres, leur adhésion et leurs contributions financières.
+ Les membres peuvent se connecter eux-même et modifier leurs informations
+ ou communiquer entre eux par e-mail. Une gestion précise des droits et
+ autorisations est possible. Il est également possible de faire des
+ envois de mails en groupe.
+ .
+ Un module de comptabilité à double entrée assure une gestion financière
+ complète digne d'un vrai logiciel de comptabilité : suivi des opérations,
+ graphiques, bilan annuel, compte de résultat, exercices, etc.
+ .
+ Il y a également la possibilité de publier un site web simple,
+ et un gestionnaire de documents permettant de gérer les fichiers de
+ l'association.
+
+EOF
+
+}
+
+
+true && {
+ fakeroot dpkg-deb -b ${DEBROOT} ${DEBFILE}
+ echo "Package file created:"
+ ls -la ${DEBFILE}
+ dpkg-deb --info ${DEBFILE}
+}
+
+cd - >/dev/null
+true && {
+ echo "Cleaning up..."
+ rm -fr ${DEBROOT}
+ rm -rf ${SRCDIR}
+}
+
+echo "Done :)"
diff --git a/build/debian/manpage.txt b/build/debian/manpage.txt
new file mode 100644
index 0000000..5600198
--- /dev/null
+++ b/build/debian/manpage.txt
@@ -0,0 +1,106 @@
+PAHEKO(1)
+=========
+:doctype: manpage
+
+
+NAME
+----
+paheko - Gestionnaire d'association à but non lucratif
+
+
+SYNOPSIS
+--------
+*paheko* ['OPTIONS'] ['COMMANDE'] ['BASE']
+
+
+DESCRIPTION
+-----------
+Lancer paheko(1) sans argument lance le serveur web intégré sur
+l'adresse localhost:8081 et le navigateur web par défaut.
+
+*BASE* défini le chemin de la base de données (fichier .sqlite) à
+utiliser. Par défaut, si aucune base n'est spécifiée, c'est le fichier
+'association.sqlite' situé dans le répertoire des données qui est
+utilisé.
+
+OPTIONS
+-------
+*-p, --port*='PORT'::
+Défini le port utilisé par le serveur web.
+Par défaut c'est le port 8081 qui est utilisé.
+
+*-b, --bind*='IP'::
+Adresse IP où sera exposé le serveur web.
+Par défaut c'est 127.0.0.1 qui est utilisé.
+
+Utiliser 0.0.0.0 pour que le serveur web soit accessible d'autres
+machines. (Attention cela peut présenter un risque de sécurité.)
+
+*-v, --verbose*::
+Affiche les messages du serveur web.
+
+*-h, --help*::
+Affiche un message d'aide sur l'utilisation de la commande.
+
+COMMANDES
+---------
+*server*::
+Lance le serveur web autonome de Paheko sans lancer de navigateur
+web.
+
+*ui*::
+Lance le serveur web autonome et le navigateur par défaut.
+
+EXIT STATUS
+-----------
+*0*::
+Succès
+
+*1*::
+Erreur
+
+EMPLACEMENTS DE STOCKAGE
+------------------------
+Les données sont stockées dans $XDG_DATA_HOME/paheko.
+Généralement c'est ~/.local/share/paheko
+
+CONFIGURATION
+-------------
+Il est possible de créer un fichier de configuration dans
+$XDG_CONFIG_HOME/paheko/config.local.php
+
+Voir la documentation pour plus de détails sur les constantes
+de configuration acceptées.
+
+INSTALLATION SERVEUR WEB
+------------------------
+Il est possible d'utiliser ce package avec Apache pour héberger
+une instance Paheko.
+
+La procédure est détaillée ici :
+https://fossil.kd2.org/paheko/wiki?name=Installation%20sous%20Debian-Ubuntu
+
+Les données et plugins seront stockés dans le répertoire
+/var/lib/paheko
+
+BUGS
+----
+Voir https://fossil.kd2.org/paheko/ pour un accès au bugtracker.
+
+
+AUTEUR
+------
+Paheko est développé par bohwaz et d'autres contributeurs.
+
+
+RESSOURCES
+----------
+
+Site principal :
+
+
+COPYING
+-------
+Copyright \(C) 2011-2023 BohwaZ. Free use of this software is
+granted under the terms of the GNU Affero General Public License v3
+(AGPL).
diff --git a/build/debian/paheko b/build/debian/paheko
new file mode 100755
index 0000000..e5cdad5
--- /dev/null
+++ b/build/debian/paheko
@@ -0,0 +1,123 @@
+#!/bin/sh
+
+ROOT=/usr/share/paheko/www
+#ROOT=~/fossil/paheko/src/www
+ROUTER=${ROOT}/_route.php
+PORT=8081
+ADDRESS="127.0.0.1"
+VERBOSE=0
+PID_FILE="${XDG_RUNTIME_DIR}/paheko/pid"
+
+[ ! -d `dirname $PID_FILE` ] && mkdir -p `dirname $PID_FILE`
+
+# Execute getopt
+ARGS=`getopt -o "pb:vh" -l "port:,bind:,verbose,help" -n "paheko" -- "$@"`
+
+# Bad arguments
+if [ $? -ne 0 ];
+then
+ exit 1
+fi
+
+# A little magic
+eval set -- "$ARGS"
+
+# Now go through all the options
+while true;
+do
+ case "$1" in
+ -p|--port)
+ PORT=$2
+ shift;;
+
+ -b|--bind)
+ ADDRESS=$2
+ shift;;
+
+ -v|--verbose)
+ VERBOSE=1
+ shift;;
+
+ -h|--help)
+ cat < /dev/null 2>&1 && rm -f $PID_FILE
+
+PHP_CLI_SERVER_WORKER=2
+
+[ $VERBOSE = 1 ] && {
+ php -S ${ADDRESS}:${PORT} -t ${ROOT} -d variables_order=EGPCS ${ROUTER} &
+} || {
+ php -S ${ADDRESS}:${PORT} -t ${ROOT} -d variables_order=EGPCS ${ROUTER} > /dev/null 2>&1 &
+}
+
+php_pid=$!
+
+echo $php_pid > $PID_FILE
+
+sleep .5
+
+[ "$CMD" = "ui" ] && {
+ URL="http://${ADDRESS}:${PORT}/admin/"
+ [ "$DISPLAY" != "" ] && {
+ sensible-browser ${URL} &
+ } || {
+ www-browser ${URL} &
+ }
+} || {
+ wait $php_pid
+}
diff --git a/build/debian/paheko.desktop b/build/debian/paheko.desktop
new file mode 100644
index 0000000..0768d67
--- /dev/null
+++ b/build/debian/paheko.desktop
@@ -0,0 +1,6 @@
+[Desktop Entry]
+Name=Paheko
+Exec=paheko
+Icon=paheko
+Type=Application
+Categories=Office;Finance;Database
\ No newline at end of file
diff --git a/build/debian/paheko.menu b/build/debian/paheko.menu
new file mode 100644
index 0000000..cf09772
--- /dev/null
+++ b/build/debian/paheko.menu
@@ -0,0 +1,3 @@
+?package(paheko):needs="X11" section="Applications/Office"\
+ title="Paheko" command="/usr/bin/paheko"\
+ icon="/usr/share/paheko/paheko.png"
diff --git a/build/debian/paheko.png b/build/debian/paheko.png
new file mode 100644
index 0000000..7d57a9e
Binary files /dev/null and b/build/debian/paheko.png differ
diff --git a/build/windows/Makefile b/build/windows/Makefile
new file mode 100644
index 0000000..18659ce
--- /dev/null
+++ b/build/windows/Makefile
@@ -0,0 +1,81 @@
+.PHONY := php installer clean publish
+PHP_ARCHIVE := https://windows.php.net/downloads/releases/php-8.2.10-nts-Win32-vs16-x64.zip
+
+all: installer
+
+php.zip:
+ wget ${PHP_ARCHIVE} -O php.zip
+
+php: php.zip
+ mkdir -p install_dir/php
+ unzip -o php.zip -d install_dir/php > /dev/null
+
+ # Remove unused files
+ @cd install_dir/php && rm -rf \
+ phpdbg.exe \
+ php8phpdbg.dll \
+ php8embed.lib \
+ php-cgi.exe \
+ php.ini-* \
+ dev \
+ phar* \
+ nghttp2.dll \
+ libpq.dll \
+ libenchant
+
+ # Remove unused extensions
+ @cd install_dir/php/ext && rm -f \
+ php_bz2.dll \
+ php_com_dotnet.dll \
+ php_curl.dll \
+ php_dba.dll \
+ php_dl_test.dll \
+ php_enchant.dll \
+ php_exif.dll \
+ php_ffi.dll \
+ php_ftp.dll \
+ php_gmp.dll \
+ php_imap.dll \
+ php_ldap.dll \
+ php_mysqli.dll \
+ php_oci8_19.dll \
+ php_odbc.dll \
+ php_opcache.dll \
+ php_pdo_firebird.dll \
+ php_pdo_mysql.dll \
+ php_pdo_oci.dll \
+ php_pdo_odbc.dll \
+ php_pdo_pgsql.dll \
+ php_pdo_sqlite.dll \
+ php_pgsql.dll \
+ php_shmop.dll \
+ php_snmp.dll \
+ php_soap.dll \
+ php_sysvshm.dll \
+ php_xsl.dll \
+ php_zend_test.dll
+
+ du -hs install_dir/php
+
+installer: clean php
+ $(eval VERSION=$(shell cat ../../src/VERSION))
+ # NSIS only accepts numbers as version
+ $(eval NSIS_VERSION=$(shell sed -E 's/-(alpha|beta|rc)[0-9]+//' ../../src/VERSION))
+ mkdir -p install_dir
+ cp ../paheko-${VERSION}.tar.gz install_dir/
+ cd install_dir && tar xzf paheko-${VERSION}.tar.gz && mv paheko-${VERSION} paheko
+ cp config.local.php install_dir/paheko/
+ cp php.ini install_dir/php
+ cp launch.bat install_dir
+ cp paheko.ico install_dir
+ rm -f install_dir/paheko-${VERSION}.tar.gz
+ makensis -V3 -DNVERSION=${NSIS_VERSION} -DVERSION=${VERSION} paheko.nsis
+
+clean:
+ rm -rf install_dir
+
+publish:
+ $(eval VERSION=$(shell cat ../../src/VERSION))
+ fossil uv ls | grep '^paheko-.*\.exe' | xargs fossil uv rm
+ fossil uv add paheko-${VERSION}.exe
+ fossil uv sync
diff --git a/build/windows/README.md b/build/windows/README.md
new file mode 100644
index 0000000..4ea144e
--- /dev/null
+++ b/build/windows/README.md
@@ -0,0 +1,5 @@
+# Paheko Windows build
+
+## Requirements
+
+NSIS: `apt install nsis`
\ No newline at end of file
diff --git a/build/windows/config.local.php b/build/windows/config.local.php
new file mode 100644
index 0000000..c370400
--- /dev/null
+++ b/build/windows/config.local.php
@@ -0,0 +1,34 @@
+ NUL
diff --git a/build/windows/paheko.ico b/build/windows/paheko.ico
new file mode 100644
index 0000000..1132a9a
Binary files /dev/null and b/build/windows/paheko.ico differ
diff --git a/build/windows/paheko.nsis b/build/windows/paheko.nsis
new file mode 100644
index 0000000..a00dab2
--- /dev/null
+++ b/build/windows/paheko.nsis
@@ -0,0 +1,175 @@
+# From https://www.conjur.org/blog/building-a-windows-installer-from-a-linux-ci-pipeline/
+!define APP_NAME "Paheko"
+!define COMP_NAME "Paheko.cloud"
+#!define WEB_SITE "https://paheko.cloud/"
+#!define VERSION "0.0.0.1"
+!define COPYRIGHT "Paheko"
+!define DESCRIPTION "Gestion d'association simple et efficace"
+!define INSTALLER_NAME "paheko-${VERSION}.exe"
+!define MAIN_APP_EXE "launch.bat"
+!define ICON "paheko.ico"
+#!define BANNER "[CHANGEME Installer Banner Filename .bmp]"
+#!define LICENSE_TXT "[CHANGEME License Text Document]"
+
+!define INSTALL_DIR "$PROGRAMFILES64\${APP_NAME}"
+!define INSTALL_TYPE "SetShellVarContext all"
+!define REG_ROOT "HKLM"
+!define REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${MAIN_APP_EXE}"
+!define UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}"
+!define REG_START_MENU "Start Menu Folder"
+
+var SM_Folder
+
+######################################################################
+
+VIProductVersion "${NVERSION}.0"
+VIAddVersionKey "ProductName" "${APP_NAME}"
+VIAddVersionKey "CompanyName" "${COMP_NAME}"
+VIAddVersionKey "LegalCopyright" "${COPYRIGHT}"
+VIAddVersionKey "FileDescription" "${DESCRIPTION}"
+VIAddVersionKey "FileVersion" "${VERSION}"
+
+######################################################################
+
+SetCompressor /SOLID Lzma
+Name "${APP_NAME}"
+Caption "${APP_NAME}"
+OutFile "${INSTALLER_NAME}"
+BrandingText "${APP_NAME}"
+#InstallDirRegKey "${REG_ROOT}" "${REG_APP_PATH}" ""
+InstallDir "${INSTALL_DIR}"
+
+######################################################################
+
+!define MUI_ICON "${ICON}"
+!define MUI_UNICON "${ICON}"
+Icon "${ICON}"
+
+!ifdef BANNER
+!define MUI_WELCOMEFINISHPAGE_BITMAP "${BANNER}"
+!define MUI_UNWELCOMEFINISHPAGE_BITMAP "${BANNER}"
+!endif
+
+######################################################################
+
+!include "MUI2.nsh"
+
+!define MUI_ABORTWARNING
+!define MUI_UNABORTWARNING
+
+!insertmacro MUI_PAGE_WELCOME
+
+!ifdef LICENSE_TXT
+!insertmacro MUI_PAGE_LICENSE "${LICENSE_TXT}"
+!endif
+
+!insertmacro MUI_PAGE_DIRECTORY
+
+!ifdef REG_START_MENU
+!define MUI_STARTMENUPAGE_DEFAULTFOLDER "${APP_NAME}"
+!define MUI_STARTMENUPAGE_REGISTRY_ROOT "${REG_ROOT}"
+!define MUI_STARTMENUPAGE_REGISTRY_KEY "${UNINSTALL_PATH}"
+!define MUI_STARTMENUPAGE_REGISTRY_VALUENAME "${REG_START_MENU}"
+!insertmacro MUI_PAGE_STARTMENU Application $SM_Folder
+!endif
+
+!insertmacro MUI_PAGE_INSTFILES
+
+!insertmacro MUI_PAGE_FINISH
+
+!insertmacro MUI_UNPAGE_CONFIRM
+
+!insertmacro MUI_UNPAGE_INSTFILES
+
+!insertmacro MUI_UNPAGE_FINISH
+
+!insertmacro MUI_LANGUAGE "French"
+
+######################################################################
+
+Section -MainProgram
+ ${INSTALL_TYPE}
+
+ SetOverwrite ifnewer
+ SetOutPath "$INSTDIR"
+ File /r "install_dir\\"
+
+SectionEnd
+
+######################################################################
+
+Section -Icons_Reg
+SetOutPath "$INSTDIR"
+WriteUninstaller "$INSTDIR\uninstall.exe"
+
+!ifdef REG_START_MENU
+!insertmacro MUI_STARTMENU_WRITE_BEGIN Application
+CreateDirectory "$SMPROGRAMS\$SM_Folder"
+CreateShortCut "$SMPROGRAMS\$SM_Folder\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}" "" "$INSTDIR\paheko.ico"
+CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}" "" "$INSTDIR\paheko.ico"
+CreateShortCut "$SMPROGRAMS\$SM_Folder\Desinstaller ${APP_NAME}.lnk" "$INSTDIR\uninstall.exe"
+
+!ifdef WEB_SITE
+WriteIniStr "$INSTDIR\${APP_NAME} website.url" "InternetShortcut" "URL" "${WEB_SITE}"
+CreateShortCut "$SMPROGRAMS\$SM_Folder\${APP_NAME} Website.lnk" "$INSTDIR\${APP_NAME} website.url"
+!endif
+!insertmacro MUI_STARTMENU_WRITE_END
+!endif
+
+!ifndef REG_START_MENU
+CreateDirectory "$SMPROGRAMS\${APP_NAME}"
+CreateShortCut "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}" "" "$INSTDIR\paheko.ico"
+CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}" "" "$INSTDIR\paheko.ico"
+CreateShortCut "$SMPROGRAMS\${APP_NAME}\Desinstaller ${APP_NAME}.lnk" "$INSTDIR\uninstall.exe"
+
+!ifdef WEB_SITE
+WriteIniStr "$INSTDIR\${APP_NAME} website.url" "InternetShortcut" "URL" "${WEB_SITE}"
+CreateShortCut "$SMPROGRAMS\${APP_NAME}\${APP_NAME} Website.lnk" "$INSTDIR\${APP_NAME} website.url"
+!endif
+!endif
+
+WriteRegStr ${REG_ROOT} "${REG_APP_PATH}" "" "$INSTDIR\${MAIN_APP_EXE}"
+WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "DisplayName" "${APP_NAME}"
+WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "UninstallString" "$INSTDIR\uninstall.exe"
+WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "DisplayIcon" "$INSTDIR\paheko.ico"
+WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "DisplayVersion" "${VERSION}"
+WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
+
+!ifdef WEB_SITE
+WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "URLInfoAbout" "${WEB_SITE}"
+!endif
+SectionEnd
+
+######################################################################
+
+Section Uninstall
+${INSTALL_TYPE}
+
+RmDir /r "$INSTDIR"
+
+!ifdef REG_START_MENU
+!insertmacro MUI_STARTMENU_GETFOLDER "Application" $SM_Folder
+Delete "$SMPROGRAMS\$SM_Folder\${APP_NAME}.lnk"
+Delete "$SMPROGRAMS\$SM_Folder\Desinstaller ${APP_NAME}.lnk"
+!ifdef WEB_SITE
+Delete "$SMPROGRAMS\$SM_Folder\${APP_NAME} Website.lnk"
+!endif
+Delete "$DESKTOP\${APP_NAME}.lnk"
+
+RmDir "$SMPROGRAMS\$SM_Folder"
+!endif
+
+!ifndef REG_START_MENU
+Delete "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk"
+Delete "$SMPROGRAMS\${APP_NAME}\Desinstaller ${APP_NAME}.lnk"
+!ifdef WEB_SITE
+Delete "$SMPROGRAMS\${APP_NAME}\${APP_NAME} Website.lnk"
+!endif
+Delete "$DESKTOP\${APP_NAME}.lnk"
+
+RmDir "$SMPROGRAMS\${APP_NAME}"
+!endif
+
+DeleteRegKey ${REG_ROOT} "${REG_APP_PATH}"
+DeleteRegKey ${REG_ROOT} "${UNINSTALL_PATH}"
+SectionEnd
\ No newline at end of file
diff --git a/build/windows/php.ini b/build/windows/php.ini
new file mode 100644
index 0000000..a227e75
--- /dev/null
+++ b/build/windows/php.ini
@@ -0,0 +1,95 @@
+[PHP]
+engine = On
+short_open_tag = Off
+precision = 14
+output_buffering = 4096
+zlib.output_compression = Off
+implicit_flush = Off
+unserialize_callback_func =
+serialize_precision = -1
+disable_functions =
+disable_classes =
+zend.enable_gc = On
+zend.exception_ignore_args = Off
+zend.exception_string_param_max_len = 15
+expose_php = On
+max_execution_time = 30
+max_input_time = 60
+memory_limit = 128M
+error_reporting = E_ALL
+display_errors = On
+display_startup_errors = On
+log_errors = On
+ignore_repeated_errors = Off
+ignore_repeated_source = Off
+report_memleaks = On
+variables_order = "GPCS"
+request_order = "GP"
+register_argc_argv = Off
+auto_globals_jit = On
+post_max_size = 256M
+auto_prepend_file =
+auto_append_file =
+default_mimetype = "text/html"
+default_charset = "UTF-8"
+doc_root =
+user_dir =
+extension_dir = "ext"
+enable_dl = Off
+file_uploads = On
+upload_max_filesize = 256M
+max_file_uploads = 20
+allow_url_fopen = On
+allow_url_include = Off
+default_socket_timeout = 60
+
+extension=fileinfo
+extension=gd
+extension=gettext
+extension=intl
+extension=mbstring
+extension=openssl
+extension=sodium
+extension=sqlite3
+extension=tidy
+
+[CLI Server]
+cli_server.color = On
+
+[mail function]
+SMTP = localhost
+smtp_port = 25
+mail.add_x_header = Off
+
+[bcmath]
+bcmath.scale = 0
+
+[Session]
+session.save_handler = files
+session.use_strict_mode = 0
+session.use_cookies = 1
+session.use_only_cookies = 1
+session.name = PHPSESSID
+session.auto_start = 0
+session.cookie_lifetime = 0
+session.cookie_path = /
+session.cookie_domain =
+session.cookie_httponly =
+session.cookie_samesite =
+session.serialize_handler = php
+session.gc_probability = 1
+session.gc_divisor = 1000
+session.gc_maxlifetime = 1440
+session.referer_check =
+session.cache_limiter = nocache
+session.cache_expire = 180
+session.use_trans_sid = 0
+session.sid_length = 26
+session.trans_sid_tags = "a=href,area=href,frame=src,form="
+session.sid_bits_per_character = 5
+
+[Assertion]
+zend.assertions = -1
+
+[Tidy]
+tidy.clean_output = Off
diff --git a/doc/admin/api.md b/doc/admin/api.md
new file mode 100644
index 0000000..192572d
--- /dev/null
+++ b/doc/admin/api.md
@@ -0,0 +1,390 @@
+Une API de type REST est disponible dans Paheko.
+
+Pour accéder à l'API il faut un identifiant et un mot de passe, à créer dans le menu ==Configuration==, onglet ==Fonctions avancées==, puis ==API==.
+
+L'API peut ensuite recevoir des requêtes REST sur l'URL `https://adresse_association/api/{chemin}/`.
+
+Remplacer =={chemin}== par un des chemins de l'API (voir ci-dessous). La méthode HTTP à utiliser est spécifiée pour chaque chemin.
+
+Pour les requêtes de type `POST`, les paramètres peuvent être envoyés par le client sous forme de formulaire HTTP classique (`application/x-www-form-urlencoded`) ou sous forme d'objet JSON. Dans ce cas le `Content-Type` doit être positionné sur `application/json`.
+
+Les réponses sont faites en JSON par défaut.
+
+<>
+
+# Utiliser l'API
+
+N'importe quel client HTTP capable de gérer TLS (HTTPS) et l'authentification basique fonctionnera.
+
+En ligne de commande il est possible d'utiliser `curl`. Exemple pour télécharger la base de données :
+
+```
+curl https://test:coucou@[identifiant_association].paheko.cloud/api/download -o association.sqlite
+```
+
+On peut aussi utiliser `wget` en n'oubliant pas l'option `--auth-no-challenge` sinon l'authentification ne fonctionnera pas :
+
+```
+wget https://test:coucou@[identifiant_association].paheko.cloud/api/download --auth-no-challenge -O association.sqlite
+```
+
+Exemple pour créer une écriture sous forme de formulaire :
+
+```
+curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -F id_year=1 -F label=Test -F "date=01/02/2023" …
+```
+
+Ou sous forme d'objet JSON :
+
+```
+curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -H 'Content-Type: application/json' -d '{"id_year":1, "label": "Test écriture", "date": "01/02/2023"}'
+```
+
+
+# Authentification
+
+Il ne faut pas oublier de fournir le nom d'utilisateur et mot de passe en HTTP :
+
+```
+curl http://test:abcd@paheko.monasso.tld/api/download/
+```
+
+# Erreurs
+
+En cas d'erreur un code HTTP 4XX sera fourni, et le contenu sera un objet JSON avec une clé `error` contenant le message d'erreur.
+
+# Chemins
+
+## sql (POST)
+
+Permet d'exécuter une requête SQL `SELECT` (uniquement, pas de requête UPDATE, DELETE, INSERT, etc.) sur la base de données. La requête SQL doit être passée dans le corps de la requête HTTP, ou dans le paramètre `sql`. Le résultat est retourné dans la clé `results` de l'objet JSON.
+
+S'il n'y a pas de limite à la requête, une limite à 1000 résultats sera ajoutée obligatoirement.
+
+```
+curl https://test:abcd@paheko.monasso.tld/api/sql/ -d 'SELECT * FROM membres LIMIT 5;'
+```
+
+**ATTENTION :** Les requêtes en écriture (`INSERT, DELETE, UPDATE, CREATE TABLE`, etc.) ne sont pas acceptées, il n'est pas possible de modifier la base de données directement via Paheko, afin d'éviter les soucis de données corrompues.
+
+Depuis la version 1.2.8, il est possible d'utiliser le paramètre `format` pour choisir le format renvoyé :
+
+* `json` (défaut) : renvoie un objet JSON, dont la clé est `"results"` et contient un tableau de la liste des membres trouvés
+* `csv` : renvoie un fichier CSV
+* `ods` : renvoie un tableau LibreOffice Calc (ODS)
+* `xlsx` : renvoie un tableau Excel (XLSX)
+
+Exemple :
+
+```
+curl https://test:abcd@paheko.monasso.tld/api/sql/ -F sql='SELECT * FROM membres LIMIT 5;' -F format=csv
+```
+
+## Téléchargements
+
+### download (GET)
+
+Télécharger la base de données complète. Renvoie directement le fichier SQLite de la base de données.
+
+Exemple :
+
+```
+curl https://test:abcd@paheko.monasso.tld/api/download -o db.sqlite
+```
+
+### download/files (GET)
+
+*(Depuis la version 1.3.4)*
+
+Télécharger un fichier ZIP contenant tous les fichiers (documents, fichiers des écritures, des membres, modules modifiés, etc.).
+
+Exemple :
+
+```
+curl https://test:abcd@paheko.monasso.tld/api/download/files -o backup_files.zip
+```
+
+## Site web
+
+### web/list (GET)
+
+Renvoie la liste des pages du site web.
+
+### web/attachment/{PAGE_URI}/{FILENAME} (GET)
+
+Renvoie le fichier joint correspondant à la page et nom de fichier indiqués.
+
+### web/page/{PAGE_URI} (GET)
+
+Renvoie un objet JSON avec toutes les infos de la page donnée.
+
+Rajouter le paramètre `?html` à l'URL pour obtenir en plus une clé `html` dans l'objet JSON qui contiendra la page au format HTML.
+
+### web/html/{PAGE_URI} (GET)
+
+Renvoie uniquement le contenu de la page au format HTML.
+
+## Membres
+
+### user/import (PUT)
+
+Permet d'importer un fichier de tableur (CSV/XLSX/ODS) de la liste des membres, comme si c'était fait depuis l'interface de Paheko.
+
+Cette route nécessite une clé d'API ayant les droits d'administration, car importer un fichier peut permettre de modifier l'identifiant de connexion d'un administrateur et donc potentiellement d'obtenir l'accès à l'interface d'administration.
+
+Paheko s'attend à ce que la première est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou à leur nom unique). Par exemple si votre fiche membre contient les champs *Nom et prénom* et *Adresse postale*, alors le fichier fourni devra ressembler à ceci :
+
+| Nom et prénom | Adresse postale |
+| :- | :- |
+| Ada Lovelace | 42 rue du binaire, 21000 DIJON |
+
+Ou à ceci :
+
+| nom_prenom | adresse_postale |
+| :- | :- |
+| Ada Lovelace | 42 rue du binaire, 21000 DIJON |
+
+La méthode renvoie un code HTTP `200 OK` si l'import s'est bien passé, sinon un code 400 et un message d'erreur JSON dans le corps de la réponse.
+
+Utilisez la route `user/import/preview` avant pour vérifier que l'import correspond à ce que vous attendez.
+
+Exemple pour modifier le nom du membre n°42 :
+
+```
+echo 'numero,nom' > membres.csv
+echo '42,"Nouveau nom"' >> membres.csv
+curl https://test:abcd@monpaheko.tld/api/user/import -T membres.csv
+```
+
+#### Paramètres
+
+Les paramètres sont à spécifier dans l'URL, dans la query string.
+
+Depuis la version 1.2.8 il est possible d'utiliser un paramètre supplémentaire `mode` contenant une de ces options pour spécifier le mode d'import :
+
+* `auto` (défaut si le mode n'est pas spécifié) : met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné
+* `create` : ne fait que créer de nouvelles fiches de membre, si le numéro de membre existe déjà une erreur sera produite
+* `update` : ne fait que mettre à jour les fiches de membre en utilisant le numéro de membre comme référence, si le numéro de membre n'existe pas une erreur sera produite
+
+Depuis la version 1.3.0 il est possible de spécifier :
+
+* le nombre de lignes à ignorer avec le paramètre `skip_lines=X` : elles ne seront pas importées. Par défaut la première ligne est ignorée.
+* la correspondance des colonnes avec des paramètres `column[x]` ou `x` est le numéro de la colonne (la numérotation commence à zéro), et la valeur contient le nom unique du champ de la fiche membre.
+
+Exemple :
+
+```
+curl https://test:abcd@monpaheko.tld/api/user/import?mode=create&column[0]=nom_prenom&column[1]=code_postal&skip_lines=0 -T membres.csv
+```
+
+### user/import (POST)
+
+Identique à la même méthode en `PUT`, mais les paramètres sont passés dans le corps de la requête, avec le fichier, dont le nom sera alors `file`.
+
+```
+curl https://test:abcd@monpaheko.tld/api/user/import \
+ -F mode=create \
+ -F 'column[0]=nom_prenom' \
+ -F 'column[1]=code_postal' \
+ -F skip_lines=0 \
+ -F file=@membres.csv
+```
+
+### user/import/preview (PUT)
+
+Identique à `user/import`, mais l'import n'est pas enregistré, et la route renvoie les modifications qui seraient effectuées en important le fichier :
+
+* `errors` : liste des erreurs d'import
+* `created` : liste des membres ajoutés, chaque objet contenant tous les champs de la fiche membre qui serait créée
+* `modified` : liste des membres modifiés, chaque membre aura une clé `id` et une clé `name`, ainsi qu'un objet `changed` contenant la liste des champs modifiés. Chaque champ modifié aura 2 propriétés `old` et `new`, contenant respectivement l'ancienne valeur du champ et la nouvelle.
+* `unchanged` : liste des membres mentionnés dans l'import, mais qui ne seront pas affectés. Pour chaque membre une clé `name` et une clé `id` indiquant le nom et l'identifiant unique numérique du membre
+
+Note : si `errors` n'est pas vide, alors il sera impossible d'importer le fichier avec `user/import`.
+
+Exemple de retour :
+
+```
+{
+ "created": [
+ {
+ "numero": 3434351,
+ "nom": "Bla Bli Blu"
+ }
+ ],
+ "modified": [
+ {
+ "id": 1,
+ "name": "Ada Lovelace",
+ "changed": {
+ "nom": {
+ "old": "Ada Lvelavce",
+ "new": "Ada Lovelace"
+ }
+ }
+ }
+ ],
+ "unchanged": [
+ {
+ "id": 2,
+ "name": "Paul Muad'Dib"
+ }
+ ]
+}
+```
+
+
+### user/import/preview (POST)
+
+Idem quel la méthode en `PUT` mais accepte les paramètres dans le corps de la requête (voir ci-dessus).
+
+## Activités
+
+### services/subscriptions/import (PUT)
+
+_(Depuis Paheko 1.3.2)_
+
+Permet d'importer les inscriptions des membres aux activités à partir d'un fichier CSV. Les activités et tarifs doivent déjà exister avant l'import.
+
+Les colonnes suivantes peuvent être utilisées :
+
+* Numéro de membre`**`
+* Activité`**`
+* Tarif
+* Date d'inscription`**`
+* Date d'expiration
+* Montant à régler
+* Payé ?
+
+Les colonnes suivies de deux astérisques (`**`) sont obligatoires.
+
+Exemple :
+
+```
+echo '"Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?"' > /tmp/inscriptions.csv
+echo '42,"Cours de théâtre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' >> /tmp/inscriptions.csv
+curl https://test:abcd@monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv
+```
+
+## Erreurs
+
+Paheko dispose d'un système dédié à la gestion des erreurs internes, compatible avec les formats des logiciels AirBrake et errbit.
+
+### errors/report (POST)
+
+Permet d'envoyer un rapport d'erreur (au format airbrake/errbit/Paheko), comme si c'était une erreur locale.
+
+### errors/log (GET)
+
+Renvoie le log d'erreurs système, au format airbrake/errbit ([voir la doc AirBrake pour un exemple du format](https://airbrake.io/docs/api/#create-notice-v3))
+
+## Comptabilité
+
+### accounting/years (GET)
+
+Renvoie la liste des exercices.
+
+### accounting/charts (GET)
+
+Renvoie la liste des plans comptables.
+
+### accounting/charts/{ID_CHART}/accounts (GET)
+
+Renvoie la liste des comptes pour le plan comptable indiqué (voir `id_chart` dans la liste des exercices).
+
+### accounting/years/{ID_YEAR}/journal (GET)
+
+Renvoie le journal général des écritures de l'exercice indiqué.
+
+Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors le plus ancien sera choisi.
+
+### accounting/years/{ID_YEAR}/account/journal (GET)
+
+Renvoie le journal des écritures d'un compte pour l'exercice indiqué.
+
+Le compte est spécifié soit via le paramètre `code`, soit via le paramètre `id`. Exemple : `/accounting/years/4/account/journal?code=512A`
+
+Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors le plus ancien sera choisi.
+
+### accounting/transaction/{ID_TRANSACTION} (GET)
+
+Renvoie les détails de l'écriture indiquée.
+
+### accounting/transaction/{ID_TRANSACTION} (POST)
+
+Modifie l'écriture indiquée. Voir plus bas le format attendu.
+
+### accounting/transaction/{ID_TRANSACTION}/users (GET)
+
+Renvoie la liste des membres liés à une écriture.
+
+### accounting/transaction/{ID_TRANSACTION}/users (POST)
+
+Met à jour la liste des membres liés à une écriture, en utilisant les ID de membres passés dans un tableau nommé `users`.
+
+```
+ curl -v "http://…/api/accounting/transaction/9337/users" -F 'users[]=2'
+```
+
+### accounting/transaction/{ID_TRANSACTION}/users (DELETE)
+
+Efface la liste des membres liés à une écriture.
+
+### accounting/transaction/{ID_TRANSACTION}/subscriptions (GET)
+
+(Depuis la version 1.3.6)
+
+Renvoie la liste des inscriptions (aux activités) liées à une écriture.
+
+### accounting/transaction/{ID_TRANSACTION}/subscriptions (POST)
+
+(Depuis la version 1.3.6)
+
+Met à jour la liste des inscriptions liées à une écriture, en utilisant les ID d'inscriptions passés dans un tableau nommé `subscriptions`.
+
+```
+ curl -v "http://…/api/accounting/transaction/9337/subscriptions" -F 'subscriptions[]=2'
+```
+
+### accounting/transaction/{ID_TRANSACTION}/subscriptions (DELETE)
+
+(Depuis la version 1.3.6)
+
+Efface la liste des inscriptions liées à une écriture.
+
+### accounting/transaction (POST)
+
+Crée une nouvelle écriture, renvoie les détails si l'écriture a été créée. Voir plus bas le format attendu.
+
+#### Structure pour créer / modifier une écriture
+
+Les champs à spécifier pour créer ou modifier une écriture sont les suivants :
+
+* `id_year`
+* `date` (format YYYY-MM-DD)
+* `type` peut être un type d'écriture simplifié (2 lignes) : `EXPENSE` (dépense), `REVENUE` (recette), `TRANSFER` (virement), `DEBT` (dette), `CREDIT` (créance), ou `ADVANCED` pour une écriture multi-ligne
+* `amount` (uniquement pour les écritures simplifiées) : contient le montant de l'écriture
+* `credit` (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au crédit
+* `debit` (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au débit
+* `lines` (pour les écritures multi-lignes) : un tableau dont chaque ligne doit contenir :
+ * `account` (numéro du compte) ou `id_account` (ID unique du compte)
+ * `credit` : montant à inscrire au crédit (doit être zéro ou non renseigné si `debit` est renseigné, et vice-versa)
+ * `debit` : montant à inscrire au débit
+ * `label` (facultatif) : libellé de la ligne
+ * `reference` (facultatif) : référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées)
+ * `id_project` : ID unique du projet à affecter
+
+Champs optionnels :
+
+* `reference` : numéro de pièce comptable
+* `notes` : remarques (texte multi ligne)
+* `id_project` : ID unique du projet à affecter (pour les écritures simplifiées uniquement)
+* `payment_reference` (uniquement pour les écritures simplifiées) : référence de paiement
+* `linked_users` : Tableau des IDs des membres à lier à l'écriture *(depuis 1.3.3)*
+* `linked_transactions` : Tableau des IDs des écritures à lier à l'écriture *(depuis 1.3.5)*
+* `linked_subscriptions` : Tableau des IDs des inscriptions à lier à l'écriture *(depuis 1.3.6)*
+
+Exemple :
+
+```
+curl -F 'id_year=12' -F 'label=Test' -F 'date=01/02/2022' -F 'type=EXPENSE' -F 'amount=42' -F 'debit=512A' -F 'credit=601' …
+```
\ No newline at end of file
diff --git a/doc/admin/brindille.md b/doc/admin/brindille.md
new file mode 100644
index 0000000..142fbcc
--- /dev/null
+++ b/doc/admin/brindille.md
@@ -0,0 +1,493 @@
+Title: Documentation du langage Brindille dans Paheko
+
+{{{.nav
+* [Modules](modules.html)
+* **[Documentation Brindille](brindille.html)**
+* [Fonctions](brindille_functions.html)
+* [Sections](brindille_sections.html)
+* [Filtres](brindille_modifiers.html)
+}}}
+
+<>
+
+# Introduction
+
+La syntaxe utilisée dans les squelettes du site web et des modules s'appelle **Brindille**.
+
+Si vous avez déjà fait de la programmation, elle ressemble à un mélange de Mustache, Smarty, Twig et PHP.
+
+Son but est de permettre une grande flexibilité, sans avoir à utiliser un "vrai" langage de programmation, mais en s'en rapprochant suffisamment quand même.
+
+## Fichiers
+
+Un fichier texte contenant du code Brindille est appelé un **squelette**.
+
+Seuls les fichiers ayant une des extensions `.tpl`, `.html`, `.htm`, `.skel` ou `.xml` seront traités par Brindille.
+De même, les fichiers qui n'ont pas d'extension seront également traités par Brindille.
+
+Les autres types de fichiers seront renvoyés sans traitement, comme des fichiers "bruts". En d'autres termes, il n'est pas possible de mettre du code *Brindille* dans des fichiers qui ne sont pas des fichiers textes.
+
+# Syntaxe de base
+
+## Variables
+
+En programmation, une variable est une référence vers une donnée stockée en mémoire.
+
+Dans Brindille, une variable commence par le symbole dollar `$` et suivi d'un nom.
+
+Le nom est composé de lettres minuscules (sans accents), de chiffres et de tirets bas (`[a-z0-9_]`).
+
+Exemples de variables :
+
+```
+$config
+$compte_32
+$nom_de_variable_long
+```
+
+### Types de variables
+
+Dans Brindille, une variable peut avoir un des types suivants :
+
+* `null` : utilisé pour une variable qui n'est pas définie, ou une variable définie qui n'a pas de valeur
+* `boolean` : valeur booléenne, peut seulement avoir `true` ou `false` comme valeur
+* `integer` : nombre entier (sans virgule). Exemple : `4200`.
+* `float` : nombre à virgule flottante, exemple `3.14`. Leur usage est déconseillé, car les erreurs de calcul sont possibles, les ordinateurs ne sachant pas compter de manière précise avec les nombres à virgule flottante. Exemple : `0.2+0.3` est différent de `0.5`.
+* `string` : chaîne de texte (aussi appelé chaîne de caractères, car c'est une suite de caractères). Exemple : `coucou`.
+* `array` : tableau.
+
+### Tableaux
+
+Les tableaux sont une sorte de dictionnaire, ou pour chaque entrée (appelée "clé") on peut associer une valeur. qui peut être de n'importe lequel des types listés ci-dessus, y compris un autre tableau.
+
+Les tableaux peuvent être de deux types :
+
+1. tableau indexé (liste) : dans ce cas les clés ne sont pas choisies, on ajoute simplement des valeurs, et l'ordinateur incrémente le numéro de la clé à chaque nouvelle entrée dans le tableau. La numérotation commence au chiffre zéro.
+2. tableau associatif (dictionnaire) : les clés sont des nombres ou des chaînes de texte, et permettent, comme dans un dictionnaire, de choisir la clé.
+
+Exemple de tableau indexé :
+
+```
+[
+ 0 => 'Texte 1',
+ 1 => 'Texte 2'
+]
+```
+
+Exemple de tableau associatif :
+
+```
+[
+ 'un' => 'Texte 1',
+ 'deux' => 'Texte 2'
+]
+```
+
+## Affichage de variable
+
+Une variable est affichée à l'aide de la syntaxe : `{{$date}}` affichera la valeur brute de la date par exemple : `2020-01-31 16:32:00`.
+
+La variable peut être modifiée à l'aide de filtres de modification, qui sont ajoutés avec le symbole de la barre verticale (pipe `|`) : `{{$date|date_long}}` affichera une date au format long : `jeudi 7 mars 2021`.
+
+Ces filtres peuvent accepter des paramètres, séparés par deux points `:`. Exemple : `{{$date|date:"d/m/Y"}}` affichera `31/01/2020`.
+
+Par défaut la variable sera recherchée dans le contexte actuel de la section, si elle n'est pas trouvée elle sera recherchée dans le contexte parent (section parente), etc. jusqu'à trouver la variable.
+
+### Protection contre le HTML (échappement)
+
+Par défaut le filtre `escape` est appliqué à toutes les variables affichées, pour protéger les variables contre les injections de code HTML.
+
+Ce filtre modifie (on dit qu'il "échappe") les caractères HTML `<>&"'` présents dans une chaîne de texte en entités HTML, évitant que le HTML éventuellement présent dans la chaîne de texte soit interprété par le navigateur.
+
+Ce filtre est appliqué en dernier, après les autres filtres. Il est possible de contourner cet automatisme en rajoutant le filtre `escape` ou `raw` explicitement. `raw` désactive tout échappement, alors que `escape` est utilisé pour changer l'ordre d'échappement. Exemple :
+
+```
+{{:assign text = "Coucou\nça va ?" }}
+{{$text|escape|nl2br}}
+```
+
+Donnera bien `Coucou ça va ?`. Si on n'avait pas indiqué le filtre `escape` le résultat serait `Coucou<br />ça va ?`.
+
+### Variables de tableaux
+
+Il est possible de faire référence à une clé d'un tableau avec la notation à points : `{{$article.date}}` renverra la clé `date` du tableau stocké dans la variable `$article`.
+
+### Échappement des caractères spéciaux dans les chaînes de caractère
+
+Pour inclure un caractère spécial (retour de ligne, guillemets ou apostrophe) dans une chaîne de caractère il suffit d'utiliser un antislash :
+
+```
+{{:assign text="Retour \n à la ligne"}}
+{{:assign text="Utiliser des \"apostrophes\"}}
+```
+
+## Ordre de recherche des variables
+
+Par défaut les variables sont recherchées dans l'ordre inverse, c'est à dire que sont d'abord recherchées les variables avec le nom demandé dans la section courante. Si la variable n'existe pas dans la section courante, alors elle est recherchée dans la section parente, et ainsi de suite jusqu'à ce que la variable soit trouvée, où qu'il n'y ait plus de section parente.
+
+Prenons cet exemple :
+
+```
+{{#articles uri="Actualite"}}
+ {{$title}}
+ {{#images parent=$path limit=1}}
+
+ {{/images}}
+{{/articles}}
+```
+
+Dans la section `articles`, `$title` est une variable de l'article, donc la variable est celle de l'article.
+
+Dans la section `images`, les images n'ayant pas de titre, la variable sera celle de l'article de la section parente, alors que `$thumb_url` sera lié à l'image.
+
+## Conflit de noms de variables
+
+Imaginons que nous voulions mettre un lien vers l'article sur l'image de l'exemple précédent :
+
+```
+{{#articles uri="Actualite"}}
+ {{$title}}
+ {{#images parent=$path limit=1}}
+
+ {{/images}}
+{{/articles}}
+```
+
+Problème, ici `$url` fera référence à l'URL de l'image elle-même, et non pas l'URL de l'article.
+
+La solution est d'ajouter un point au début du nom de variable : `{{$.url}}`.
+
+Un point au début d'un nom de variable signifie que la variable est recherchée à partir de la section précédente. Il est possible d'utiliser plusieurs points, chaque point correspond à un niveau à remonter. Ainsi `$.url` cherchera la variable dans la section parente (et ses sections parentes si elle n'existe pas, etc.). De même, `$..url` cherchera dans la section parente de la section parente.
+
+## Création manuelle de variable
+
+### Variable simple
+
+La création d'une variable se fait via l'appel de la fonction `{{:assign}}`.
+
+Exemple :
+
+```
+{{:assign source='wiki'}}
+{{* est identique à : *}}
+{{:assign var='source' value='wiki'}}
+```
+
+Un deuxième appel à `{{:assign}}` avec le même nom de variable écrase la valeur précédente
+
+```
+{{:assign var='source' value='wiki'}}
+{{:assign var='source' value='documentation'}}
+
+{{$source}}
+{{* => Affiche documentation *}}
+```
+
+### Nom de variable dynamique
+
+Il est possible de créer une variable dont une partie du nom est dynamique.
+
+```
+{{:assign type='user'}}
+{{:assign var='allowed_%s'|args:$type value='jeanne'}}
+{{:assign type='side'}}
+{{:assign var='allowed_%s'|args:$type value='admin'}}
+
+{{$allowed_user}} => jeanne
+{{$allowed_side}} => admin
+```
+
+[Documentation complète de la fonction {{:assign}}](brindille_functions.html#assign).
+
+### Tableaux *(array)*
+
+Pour créer des tableaux, il suffit d'utiliser des points `.` dans le nom de la variable (ex : `colors.yellow`). Il n'y a pas besoin d'initialiser le tableau avant de le remplir.
+
+```
+{{:assign var='colors.admin' value='blue'}}
+{{:assign var='colors.website' value='grey'}}
+{{:assign var='colors.debug' value='yellow'}}
+```
+
+On accède ensuite à la valeur d'un élément du tableau avec la même syntaxe : `{{$colors.website}}`
+
+Méthode rapide de création du même tableau :
+
+```
+{{:assign var='colors' admin='blue' website='grey' debug='yellow'}}
+```
+
+Pour ajouter un élément à la suite du tableau sans spécifier de clef *(push)*, il suffit de terminer le nom de la variable par un point `.` sans suffixe.
+
+Exemple :
+
+```
+{{* Ajouter les valeurs 17, 43 et 214 dans $processed_ids *}}
+
+{{:assign var='processed_ids.' value=17}}
+{{:assign var='processed_ids.' value=43}}
+{{:assign var='processed_ids.' value=214}}
+```
+
+Donnera le tableau suivant :
+
+```
+[
+ 0 => 17,
+ 1 => 43,
+ 2 => 214
+]
+```
+
+#### Clef dynamique de tableau
+
+Il est possible d'accéder dynamiquement à un des éléments d'un tableau de la manière suivante :
+
+```
+{{:assign location='admin'}}
+{{:assign var='location_color' from='colors.%s'|args:$location}}
+
+{{$location_color}} => blue
+```
+
+Exemple plus complexe :
+
+```
+{{:assign var='type_whitelist.text' value=1}}
+{{:assign var='type_whitelist.html' value=1}}
+
+{{#foreach from=$documents item='document'}}
+ {{:assign var='allowed' value='type_whitelist.%s'|args:$document->type}}
+ {{if $allowed !== null}}
+ {{:include file='document/'|cat:$type:'.tpl' keep='document'}}
+ {{/if}}
+{{/foreach}}
+```
+
+Il est également possible de créer un membre dynamique d'un tableau en conjuguant les syntaxes précédentes.
+
+Exemple :
+
+```
+{{:assign var='type_whitelist.%s'|args:$type value=1}}
+```
+
+## Conditions
+
+Il est possible d'utiliser des conditions de type **"si"** (`if`), **"sinon si"** (`elseif`) et **"sinon"** (`else`). Celles-ci sont terminées par un block **"fin si"** (`/if`).
+
+```
+{{if $date|date:"%Y" > 2020}}
+ La date est en 2020
+{{elseif $article.status == 'draft'}}
+ La page est un brouillon
+{{else}}
+ Autre chose.
+{{/if}}
+```
+
+Une condition peut être évaluée comme *vraie*, dans ce cas la partie qui suit le bloc `{{if …}}` sera exécutée.
+
+Si la condition est évaluée comme *fausse*, alors la partie qui suit le bloc `{{if …}}` ne sera pas exécutée. Dans ce cas, soit une condition `{{elseif …}}` suivante est vraie et exécutée, mais sinon c'est le contenu du bloc `{{else}}` qui est exécuté et affiché.
+
+Dans une condition on peut utiliser :
+
+* une variable : `$nom_variable`
+* une variable avec des filtres : `$nom_variable|filtre1:parametre1`
+* une valeur (nombre, constante) : `42`, `-42`, `42.02` `null`, `true`, `false`
+* des opérateurs de comparaison
+* des opérateurs logiques
+
+Attention : on ne peut pas grouper les conditions avec des parenthèses.
+
+Les comparaisons supportées sont les suivantes :
+
+| Opérateur de comparaison | Explication |
+| :- | :- |
+| `==` | égalité faible, ne prend pas en compte le type : `1 == true` et `2 == 2.00` serons évalués comme vrais |
+| `===` | égalité forte, prend en compte le type : `1 === 1` sera vrai, mais `1 === true` sera faux |
+| `!=` | différent de, en comparaison faible |
+| `!==` | différent de, en comparaison forte |
+| `>` | supérieur à |
+| `>=` | supérieur ou égal à |
+| `<` | inférieur à |
+| `<=` | inférieur ou égal à |
+
+Il est aussi possible de précéder une variable de l'opérateur `!`, c'est un raccourci pour `$variable == false`.
+
+Voir [les opérateurs de comparaison PHP pour plus de détails](https://www.php.net/manual/fr/language.operators.comparison.php).
+
+Les opérateurs logiques supportés sont :
+
+| Opérateur | Explication |
+| :- | :- |
+| `&&` | Vrai si les conditions à gauche et à droite sont vraies |
+| `||` | Vrai si une des conditions à gauche ou à droite est vraie |
+
+Exemples :
+
+* `false && true` : sera évalué comme faux
+* `false || true` : sera évalué comme vrai
+
+
+### Tester si une variable existe
+
+Brindille ne fait pas de différences entre une variable qui n'existe pas, et une variable définie à `null`.
+On peut donc tester l'existence d'une variable en la comparant à `null` comme ceci :
+
+```
+{{if $session !== null}}
+ Session en cours pour l'utilisateur/trice {{$session.user.name}}.
+{{else}}
+ Session inexistante.
+{{/if}}
+```
+
+## Fonctions
+
+### Fonctions natives
+
+Une fonction va répondre à certains paramètres et renvoyer un résultat ou réaliser une action.
+
+**Un bloc de fonction commence par le signe deux points `:`.**
+
+```
+{{:http code=404}}
+```
+
+Contrairement aux autres types de blocs, et comme pour les variables, il n'y a pas de bloc fermant (avec un slash `/`).
+
+## Sections
+
+Une section est une partie de la page qui sera répétée une fois, plusieurs fois, ou zéro fois, selon ses paramètres et le résultat (c'est une "boucle"). Une section commence par un bloc avec un signe hash (`#`) et se termine par un bloc avec un slash (`/`).
+
+Un exemple simple avec une section qui n'aura qu'une seule répétition :
+
+```
+{{#categories uri=$_GET.uri}}
+ {{$title}}
+{{/categories}}
+```
+
+Il est possible d'utiliser une condition `{{else}}` avant la fin du bloc pour avoir du contenu alternatif si la section ne se répète pas (dans ce cas si aucune catégorie ne correspond au critère).
+
+Un exemple de sous-section
+
+```
+{{#categories uri=$_GET.uri}}
+ {{$title}}
+
+ {{#articles parent=$path order="published DESC" limit="10"}}
+
+ {{$content|truncate:600:"..."}}
+ {{else}}
+ Aucun article trouvé.
+ {{/articles}}
+
+{{/categories}}
+```
+
+Voir la référence des sections pour voir quelles sont les sections possibles et quel est leur comportement.
+
+## Bloc litéral
+
+Pour qu'une partie du code ne soit pas interprété, pour éviter les conflits avec certaines syntaxes, il est possible d'utiliser un bloc `literal` :
+
+```
+{{literal}}
+
+{{/literal}}
+```
+
+
+## Commentaires
+
+Les commentaires sont figurés dans des blocs qui commencent et se terminent par une étoile (`*`) :
+
+```
+{{* Ceci est un commentaire
+Il sera supprimé du résultat final
+Il peut contenir du code qui ne sera pas interprété :
+{{if $test}}
+OK
+{{/if}}
+*}}
+```
+
+
+# Liste des variables définies par défaut
+
+Ces variables sont définies tout le temps :
+
+| Nom de la variable | Valeur |
+| :- | :- |
+| `$_GET` | Tableau contenant tous les paramètres passés dans la chaîne de requêtre de l'URL. |
+| `$_POST` | Tableau de tous les éléments de formulaire envoyés lors d'une requête POST. |
+| `$root_url` | Adresse racine du site web Paheko. |
+| `$request_url` | Adresse de la page courante. |
+| `$admin_url` | Adresse de la racine de l'administration Paheko. |
+| `$visitor_lang` | Langue préférée du visiteur, sur 2 lettres (exemple : `fr`, `en`, etc.). |
+| `$logged_user` | Informations sur le membre actuellement connecté dans l'administration (vide si non connecté). |
+| `$dialog` | Vaut `TRUE` si la page est dans un dialogue (iframe sous forme de pop-in dans l'administration). |
+| `$now` | Contient la date et heure courante. |
+| `$config.org_name` | Nom de l'association |
+| `$config.org_email` | Adresse e-mail de l'association |
+| `$config.org_phone` | Numéro de téléphone de l'association |
+| `$config.org_address` | Adresse postale de l'association |
+| `$config.org_web` | Adresse du site web de l'association |
+| `$config.files.logo` | Adresse du logo de l'association, si définit dans la personnalisation |
+| `$config.files.favicon` | Adresse de l'icône de favoris de l'association, si défini dans la personnalisation |
+| `$config.files.signature` | Adresse de l'image de signature, si défini dans la personnalisation |
+
+À celles-ci s'ajoutent [les variables spéciales des modules](modules.html#variables_speciales) lorsque le script est chargé dans un module.
+
+# Erreurs
+
+Si une erreur survient dans un squelette, que ça soit au niveau d'une erreur de syntaxe, ou une erreur dans une fonction, filtre ou section, alors elle sera affichée selon les règles suivantes :
+
+* si le membre connecté est administrateur, une erreur est affichée avec le code du squelette ;
+* sinon l'erreur est affichée sans le code.
+
+
+# Avertissement sur la sécurité des requêtes SQL
+
+Attention, en utilisant la section `{{#select ...}}`, ou une des sections SQL (voir plus bas), avec des paramètres qui ne seraient pas protégés, il est possible qu'une personne mal intentionnée ait accès à des parties de la base de données à laquelle vous ne désirez pas donner accès.
+
+Pour protéger contre cela il est essentiel d'utiliser les paramètres nommés.
+
+Exemple de requête dangereuse :
+
+```
+{{#sql select="*" tables="users" where="id = %s"|args:$_GET.id}}
+...
+{{/sql}}
+```
+
+On se dit que la requête finale sera donc : `SELECT * FROM users WHERE id = 42;` si le numéro 42 est passé dans le paramètre `id` de la page.
+
+Imaginons qu'une personne mal-intentionnée indique dans le paramètre `id` de la page la chaîne de caractère suivante : `0 OR 1`. Dans ce cas la requête exécutée sera `SELECT * FROM users WHERE id = 0 OR 1;`. Cela aura pour effet de lister tous les membres, au lieu d'un seul.
+
+Pour protéger contre cela il convient d'utiliser un paramètre nommé :
+
+```
+{{#sql select="*" tables="users" where="id = :id" :id=$_GET.id}}
+```
+
+Dans ce cas la requête malveillante générée sera `SELECT * FROM users WHERE id = '0 OR 1';`. Ce qui aura pour effet de ne lister aucun membre.
+
+## Mesures prises pour la sécurité des données
+
+Dans Brindille, il n'est pas possible de modifier ou supprimer des éléments dans la base de données avec les requêtes SQL directement. Seules les requêtes SQL en lecture (`SELECT`) sont permises.
+
+Cependant certaines fonctions permettent de modifier ou créer des éléments précis (écritures par exemple), ce qui peut avoir un effet de remplir ou modifier des données par une personne mal-intentionnée, donc attention à leur utilisation.
+
+Les autres mesures prises sont :
+
+* impossibilité d'accéder à certaines données sensibles (mot de passe, logs de connexion, etc.)
+* incitation forte à utiliser les paramètres nommés dans la documentation
+* protection automatique des variables dans la section `{{#select}}`
+* fourniture de fonctions pour protéger les chaînes de caractères contre l'injection SQL
\ No newline at end of file
diff --git a/doc/admin/brindille_functions.md b/doc/admin/brindille_functions.md
new file mode 100644
index 0000000..19bcaca
--- /dev/null
+++ b/doc/admin/brindille_functions.md
@@ -0,0 +1,828 @@
+Title: Référence des fonctions Brindille
+
+{{{.nav
+* [Modules](modules.html)
+* [Documentation Brindille](brindille.html)
+* **[Fonctions](brindille_functions.html)**
+* [Sections](brindille_sections.html)
+* [Filtres](brindille_modifiers.html)
+}}}
+
+<>
+
+# Fonctions généralistes
+
+## assign
+
+Permet d'assigner une valeur dans une variable.
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `.` | optionnel | Assigner toutes les variables du contexte (section) actuel |
+| `var` | optionnel | Nom de la variable à créer ou modifier |
+| `value` | optionnel | Valeur de la variable |
+| `from` | optionnel | Recopier la valeur depuis la variable ayant le nom fourni dans ce paramètre. |
+
+Tous les autres paramètres sont considérés comme des variables à assigner.
+
+Exemple :
+
+```
+{{:assign blabla="Coucou"}}
+
+{{$blabla}}
+```
+
+Il est possible d'assigner toutes les variables d'une section dans une variable en utilisant le paramètre point `.` (`.="nom_de_variable"`). Cela permet de capturer le contenu d'une section pour le réutiliser à un autre endroit.
+
+```
+{{#pages uri="Informations" limit=1}}
+{{:assign .="infos"}}
+{{/pages}}
+
+{{$infos.title}}
+```
+
+Il est aussi possible de remonter dans les sections parentes en utilisant plusieurs points. Ainsi deux points remonteront à la section parente, trois points à la section parente de la section parente, etc.
+
+```
+{{#foreach from=$infos item="info"}}
+ {{#foreach from=$info item="sous_info"}}
+ {{if $sous_info.titre == 'Coucou'}}
+ {{:assign ..="info_importante"}}
+ {{/if}}
+ {{/foreach}}
+{{/foreach}}
+
+{{$info_importante.titre}}
+```
+
+En utilisant le paramètre spécial `var`, tous les autres paramètres passés sont ajoutés à la variable donnée en valeur :
+
+```
+{{:assign var="tableau" label="Coucou" name="Pif le chien"}}
+{{$tableau.label}}
+{{$tableau.name}}
+```
+
+De la même manière on peut écraser une variable avec le paramètre spécial `value`:
+
+```
+{{:assign var="tableau" value=$infos}}
+```
+
+Il est également possible de créer des tableaux avec la syntaxe `.` dans le nom de la variable :
+
+```
+{{:assign var="liste.comptes.530" label="Caisse"}}
+{{:assign var="liste.comptes.512" label="Banque"}}
+
+{{#foreach from=$liste.comptes}}
+{{$key}} = {{$value.label}}
+{{/foreach}}
+```
+
+Il est possible de rajouter des éléments à un tableau simplement en utilisant un point seul :
+
+```
+{{:assign var="liste.comptes." label="530 - Caisse"}}
+{{:assign var="liste.comptes." label="512 - Banque"}}
+```
+
+Enfin, il est possible de faire référence à une variable de manière dynamique en utilisant le paramètre spécial `from` :
+
+```
+{{:assign var="tableau" a="Coucou" b="Test !"}}
+{{:assign var="titre" from="tableau.%s"|args:"b"}}
+{{$titre}} -> Affichera "Test !", soit la valeur de {{$tableau.b}}
+```
+
+## break
+
+Interrompt une section.
+
+## continue
+
+Passe à l'itération suivante d'une section. Le code situé entre cette instruction et la fin de la section ne sera pas exécuté.
+
+```
+{{#foreach from=$list item="event"}}
+ {{if $event.date == '2023-01-01'}}
+ {{:continue}}
+ {{/if}}
+ {{$event.title}}
+{{/foreach}}
+```
+
+Il est possible de passer à l'itération suivante d'une section parente en utilisant un chiffre en paramètre :
+
+```
+{{#foreach from=$list item="event"}}
+ {{$event.title}}
+ {{#foreach from=$event.people item="person"}}
+ {{if $person.name == 'bohwaz'}}
+ {{:continue 2}}
+ {{/if}}
+ - {{$person.name}}
+ {{/foreach}}
+{{/foreach}}
+```
+
+## debug
+
+Cette fonction permet d'afficher le contenu d'une ou plusieurs variables :
+
+```
+{{:debug test=$title}}
+```
+
+Affichera :
+
+```
+array(1) {
+ ["test"] => string(6) "coucou"
+}
+```
+
+Si aucun paramètre n'est spécifié, alors toutes les variables définies sont renvoyées. Utile pour découvrir quelles sont les variables accessibles dans une section par exemple.
+
+
+## error
+
+Affiche un message d'erreur et arrête le traitement à cet endroit.
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `message` | **obligatoire** | Message d'erreur à afficher |
+
+Exemple :
+
+```
+{{if $_POST.nombre != 42}}
+ {{:error message="Le nombre indiqué n'est pas 42"}}
+{{/if}}
+```
+
+## form_errors
+
+Affiche les erreurs du formulaire courant (au format HTML).
+
+## http
+
+Permet de modifier les entêtes HTTP renvoyés par la page. Cette fonction doit être appelée au tout début du squelette, avant tout autre code ou ligne vide.
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `code` | *optionnel* | Modifie le code HTTP renvoyé. [Liste des codes HTTP](https://fr.wikipedia.org/wiki/Liste_des_codes_HTTP) |
+| `redirect` | *optionnel* | Rediriger vers l'adresse URL indiquée en valeur. |
+| `type` | *optionnel* | Modifie le type MIME renvoyé |
+| `download` | *optionnel* | Force la page à être téléchargée sous le nom indiqué. |
+| `inline` | *optionnel* | Force la page à être affichée, et peut ensuite être téléchargée sous le nom indiqué (utile pour la généraion de PDF : permet d'afficher le PDF dans le navigateur avant de le télécharger). |
+
+Note : si le type `application/pdf` est indiqué (ou juste `pdf`), la page sera convertie en PDF à la volée. Il est possible de forcer le téléchargement du fichier en utilisant le paramètre `download`.
+
+Exemples :
+
+```
+{{:http code=404}}
+{{:http redirect="/Nos-Activites/"}}
+{{:http redirect="https://mon-site-web.tld/"}}
+{{:http type="application/svg+xml"}}
+{{:http type="pdf" download="liste_membres_ca.pdf"}}
+```
+
+## include
+
+Permet d'inclure un autre squelette.
+
+Paramètres :
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `file` | **obligatoire** | Nom du squelette à inclure |
+| `keep` | *optionnel* | Liste de noms de variables à conserver |
+| `capture` | *optionnel* | Si renseigné, au lieu d'afficher le squelette, son contenu sera enregistré dans la variable de ce nom. |
+| … | *optionnel* | Tout autre paramètre sera utilisé comme variable qui n'existea qu'à l'intérieur du squelette inclus. |
+
+```
+{{* Affiche le contenu du squelette "navigation.html" dans le même répertoire que le squelette d'origine *}}
+{{:include file="./navigation.html"}}
+```
+
+Par défaut, les variables du squelette parent sont transmis au squelette inclus, mais les variables définies dans le squelette inclus ne sont pas transmises au squelette parent. Exemple :
+
+```
+{{* Squelette page.html *}}
+{{:assign title="Super titre !"}}
+{{:include file="./_head.html"}}
+{{$nav}}
+```
+```
+{{* Squelette _head.html *}}
+{{$title}}
+{{:assign nav="Accueil > %s"|args:$title}}
+```
+
+Dans ce cas, la dernière ligne du premier squelette (`{{$nav}}`) n'affichera rien, car la variable définie dans le second squelette n'en sortira pas. Pour indiquer qu'une variable doit être transmise au squelette parent, il faut utiliser le paramètre `keep`:
+
+```
+{{:include file="./_head.html" keep="nav"}}
+```
+
+On peut spécifier plusieurs noms de variables, séparés par des virgules, et utiliser la notation à points :
+
+```
+{{:include file="./_head.html" keep="nav,article.title,name"}}
+{{$nav}}
+{{$article.title}}
+{{$name}}
+```
+
+On peut aussi capturer le résultat d'un squelette dans une variable :
+
+```
+{{:include file="./_test.html" capture="test"}}
+{{:assign var="test" value=$test|replace:'TITRE':'Ceci est un titre'}}
+{{$test}}
+```
+
+Il est possible d'assigner de nouvelles variables au contexte du include en les déclarant comme paramètres tout comme on le ferait avec `{{:assign}}` :
+
+```
+{{:include file="./_head.html" title='%s documentation'|args:$doc.label visitor=$user}}
+```
+
+## captcha
+
+Permet de générer une question qui doit être répondue correctement par l'utilisateur pour valider une action. Utile pour empêcher les robots spammeurs d'effectuer une action.
+
+L'utilisation simplifiée utilise un de ces deux paramètres :
+
+| Paramètre | Fonction |
+| :- | :- |
+| `html` | Si `true`, crée un élément de formulaire HTML et le texte demandant à l'utilisateur de répondre à la question |
+| `verify` | Si `true`, vérifie que l'utilisateur a correctement répondu à la question |
+
+L'utilisation avancée utilise d'abord ces deux paramètres :
+
+| Paramètre | Fonction |
+| :- | :- |
+| `assign_hash` | Nom de la variable où assigner le hash (à mettre dans un ` `) |
+| `assign_number` | Nom de la variable où assigner le nombre de la question (à afficher à l'utilisateur) |
+
+Puis on vérifie :
+
+| Paramètre | Fonction |
+| :- | :- |
+| `verify_hash` | Valeur qui servira comme hash de vérification (valeur du ` `) |
+| `verify_number` | Valeur qui représente la réponse de l'utilisateur |
+| `assign_error` | Si spécifié, le message d'erreur sera placé dans cette variable, sinon il sera affiché directement. |
+
+Exemple :
+
+```
+{{if $_POST.send}}
+ {{:captcha verify_hash=$_POST.h verify_number=$_POST.n assign_error="error"}}
+ {{if $error}}
+ Mauvaise réponse
+ {{else}}
+ ...
+ {{/if}}
+{{/if}}
+
+
+```
+
+## mail
+
+Permet d'envoyer un e-mail à une ou des adresses indiquées (sous forme de tableau).
+
+Restrictions :
+
+* le message est toujours envoyé en format texte ;
+* l'expéditeur est toujours l'adresse de l'association ;
+* l'envoi est limité à une seule adresse e-mail externe (adresse qui n'est pas celle d'un membre) dans une page ;
+* l'envoi est limité à maximum 10 adresses e-mails internes (adresses de membres) dans une page ;
+* un message envoyé à une adresse e-mail externe ne peut pas contenir une adresse web (`https://...`) autre que celle de l'association.
+
+Note : il est également conseillé d'utiliser la fonction `captcha` pour empêcher l'envoi de spam.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `to` | **obligatoire** | Adresse email destinataire (seule l'adresse e-mail elle-même est acceptée, pas de nom) |
+| `subject` | **obligatoire** | Sujet du message |
+| `body` | **obligatoire** | Corps du message |
+| `block_urls` | *optionnel* | (`true` ou `false`) Permet de bloquer l'envoi si le message contient une adresse `https://…` |
+| `attach_file` | *optionnel* | Chemin vers un ou plusieurs documents à joindre au message (situé dans les documents) |
+| `attach_from` | *optionnel* | Chemin vers un ou plusieurs squelettes à joindre au message (par exemple pour joindre un document généré) |
+
+Pour le destinataire, il est possible de spécifier un tableau :
+
+```
+{{:assign var="recipients[]" value="membre1@framasoft.net"}}
+{{:assign var="recipients[]" value="membre2@chatons.org"}}
+{{:mail to=$recipients subject="Coucou" body="Contenu du message\nNouvelle ligne"}}
+```
+
+Exemple de formulaire de contact :
+
+```
+{{if !$_POST.email|check_email}}
+ L'adresse e-mail indiquée est invalide.
+{{elseif $_POST.message|trim == ''}}
+ Le message est vide
+{{elseif $_POST.send}}
+ {{:captcha verify=true}}
+ {{:mail to=$config.org_email subject="Formulaire de contact" body="%s a écrit :\n\n%s"|args:$_POST.email:$_POST.message block_urls=true}}
+ Votre message nous a bien été transmis !
+{{/if}}
+
+
+```
+
+## redirect
+
+Redirige vers une nouvelle page.
+
+Avec le paramètre `force`, si la page actuelle est ouverte dans une fenêtre modale (grâce à la cible `_dialog`), alors la fenêtre modale est fermée, et la redirection se passe dans la page parente.
+
+Avec le paramètre `to`, si la page actuelle est ouverte dans une fenêtre modal (grâce à la cible `_dialog`), alors la fenêtre modale est fermée, et la page parente est rechargée. Si la page n'est pas ouvertre dans dans une fenêtre modale, la redirection est effectuée.
+
+Seules les adresses internes sont acceptées, il n'est pas possible de rediriger vers une adresse extérieure.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `force` | optionnel | Adresse de redirection forcée |
+| `to` | optionnel | Adresse de redirection si pas dans une fenêtre modale |
+
+Si `to=null` est utilisé, alors la fenêtre modale sera fermée. Ou, si la page n'est pas dans une fenêtre modale, la page courante sera rechargée.
+
+## api
+
+Permet d'appeler l'API de Paheko, que ça soit sur l'instance locale, en cours, ou une autre instance externe.
+
+Voir la [documentation de l'API](https://paheko.cloud/api) pour la liste des fonctions disponibles.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `method` | obligatoire | Méthode de requête : `GET`, `POST`, etc. |
+| `path` | obligatoire | Chemin de la méthode de l'API à appeler. |
+| `fail` | optionnel | Booléen. Si `true`, alors une erreur sera affichée si la requête échoue. Si `false`, aucune erreur ne sera affichée. Défaut : `true`. |
+| `assign` | optionnel | Capturer le résultat dans cette variable. |
+| `assign_code` | optionnel | Capturer le code de retour dans cette variable. |
+
+Par défaut, les requêtes sont réalisées sur la base de données locale, dans ce cas les paramètres suivants sont également disponibles :
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `access` | optionnel | Niveau d'autorisation de l'API (défaut : `admin`). |
+
+
+```
+{{:assign var="users." value=42}}
+{{:api
+ method="POST"
+ path="accounting/transaction"
+ assign="result"
+
+ id_year=1
+ type="revenue"
+ date="01/01/2023"
+ label="Don de Ada Lovelace"
+ reference="DON-0001"
+ payment_reference="Credit Mutuel 00042"
+ amount="51,49"
+ debit="756"
+ credit="512A"
+ linked_users=$users
+}}
+
+L'écriture n°{{$result.id}} a été créée.
+```
+
+Mais cette fonction permet également d'appeler une API Paheko distante, dans ce cas les paramètres suivants sont nécessaires :
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `url` | obligatoire | Adresse HTTP de l'instance Paheko distante. |
+| `user` | obligatoire | Identifiant d'accès à l'API distante. |
+| `password` | obligatoire | Mot de passe d'accès à l'API distante. |
+
+```
+{{:api
+ method="POST"
+ path="sql"
+ sql="SELECT * FROM users;"
+ url="https://mon-asso.paheko.cloud/"
+ user="zmgyfr1qnm"
+ password="OAqFTLFzujJWr6lLn1Mu7w"
+ assign="result"
+ assign_code="code"
+ fail=false
+}}
+
+{{if $code == 200}}
+ Il y a {{$result.count}} résultats.
+{{else}}
+ La requête a échoué : code {{$code}} — {{$result.error}}
+{{/if}}
+
+```
+
+# Fonctions relatives aux Modules
+
+## save
+
+Enregistre des données, sous la forme d'un document, dans la base de données, pour le module courant.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `key` | optionnel | Clé unique du document |
+| `id` | optionnel | Numéro unique du document |
+| `validate_schema` | optionnel | Fichier de schéma JSON à utiliser pour valider les données avant enregistrement |
+| `validate_only` | optionnel | Liste des paramètres à valider (par exemple pour ne faire qu'une mise à jour partielle), séparés par des virgules. |
+| `assign_new_id` | optionnel | Si renseigné, le nouveau numéro unique du document sera indiqué dans cette variable. |
+| … | optionnel | Autres paramètres : traités comme des valeurs à enregistrer dans le document |
+
+Si ni `key` ni `id` ne sont indiqués, un nouveau document sera créé avec un nouveau numéro (ID) unique.
+
+Si le document indiqué existe déjà, il sera mis à jour. Les valeurs nulles (`NULL`) seront effacées.
+
+```
+{{:save key="facture_43" nom="Atelier mobile" montant=250}}
+```
+
+Enregistrera dans la base de données le document suivant sous la clé `facture_43` :
+
+```
+{"nom": "Atelier mobile", "montant": 250}
+```
+
+Exemple de mise à jour :
+
+```
+{{:save key="facture_43" montant=300}}
+```
+
+Exemple de récupération du nouvel ID :
+
+```
+{{:save titre="Coucou !" assign_new_id="id"}}
+Le document n°{{$id}} a bien été enregistré.
+```
+
+### Validation avec un schéma JSON
+
+```
+{{:save titre="Coucou" texte="Très long" validate_schema="./document.schema.json"}}
+```
+
+Pour ne valider qu'une partie du schéma, par exemple si on veut faire une mise à jour du document :
+
+```
+{{:save key="test" titre="Coucou" validate_schema="./document.schema.json" validate_only="titre"}}
+```
+
+## delete
+
+Supprime un document lié au module courant.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `key` | optionnel | Clé unique du document |
+| `id` | optionnel | Numéro unique du document |
+
+Il est possible de spécifier d'autres paramètres, ou une clause `where` et des paramètres dont le nom commence par deux points.
+
+* Supprimer le document avec la clé `facture_43` : `{{:delete key="facture_43"}}`
+* Supprimer le document avec la clé `ABCD` et dont la propriété `type` du document correspond à la valeur `facture` : `{{:delete key="ABCD" type="facture"}}`
+* Supprimer tous les documents : `{{:delete}}`
+* Supprimer tous les documents ayant le type `facture` : `{{:delete type="facture"}}`
+* Supprimer tous les documents de type `devis` ayant une date dans le passé : `{{:delete :type="devis" where="$$.type = :type AND $$.date < datetime()"}}`
+
+## read
+
+Lire un fichier stocké dans les fichiers du code du module.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `file` | obligatoire | Chemin du fichier à lire |
+| `assign` | optionnel | Variable dans laquelle placer le contenu du fichier. |
+
+Si le paramètre `assign` n'est pas utilisé, le contenu du fichier sera affiché directement.
+
+Exemple pour lire un fichier JSON :
+
+```
+{{#read file="baremes.json" assign="baremes"}}
+{{:assign baremes=$baremes|json_decode}}
+Barème kilométrique pour une voiture de 3 CV : {{$baremes.voiture.3cv}}
+```
+
+Exemple pour lire un fichier CSV :
+
+```
+{{#read file="baremes.csv" assign="baremes"}}
+{{:assign baremes=$baremes|trim|explode:"\n"}}
+
+{{#foreach from=$baremes item="line"}}
+ {{:assign bareme=$line|str_getcsv}}
+ Nom du barème : {{$bareme.0}}
+ Calcul : {{$bareme.1}}
+{{/foreach}}
+```
+
+## admin_header
+
+Affiche l'entête de l'administration de l'association.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `title` | *optionnel* | Titre de la page |
+| `layout` | *optionnel* | Aspect de la page. Peut être `public` pour une page publique simple (sans le menu), ou `raw` pour une page vierge (sans aucun menu ni autre élément). Défaut : vide (affichage du menu) |
+| `current` | *optionnel* | Indique quel élément dans le menu de gauche doit être marqué comme sélectionné |
+| `custom_css` | *optionnel* | Fichier CSS supplémentaire à appeler dans le `` |
+
+```
+{{:admin_header title="Gestion des dons" current="acc"}}
+```
+
+Liste des choix possibles pour `current` :
+
+* `home` : menu Accueil
+* `users` : menu Membres
+* `users/new` : sous-menu "Ajouter" de Membres
+* `users/services` : sous-menu "Activités et cotisations" de Membres
+* `users/mailing` : sous-menu "Message collectif" de Membres
+* `acc` : menu Comptabilité
+* `acc/new` : sous-menu "Saisie" de Comptabilité
+* `acc/accounts` : sous-menu "Comptes"
+* `acc/simple` : sous-menu "Suivi des écritures"
+* `acc/years` : sous-menu "Exercices et rapports"
+* `docs` : menu Documents
+* `web` : menu Site web
+* `config` : menu Configuration
+* `me` : menu "Mes infos personnelles"
+* `me/services` : sous-menu "Mes activités et cotisations"
+
+Exemple d'utilisation de `custom_css` depuis un module :
+
+```
+{{:admin_header title="Mon module" custom_css="./style.css"}}
+```
+
+## admin_footer
+
+Affiche le pied de page de l'administration de l'association.
+
+```
+{{:admin_footer}}
+```
+
+## delete_form
+
+Affiche un formulaire demandant la confirmation de suppression d'un élément.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `legend` | **obligatoire** | Libellé de l'élément `` du formulaire |
+| `warning` | **obligatoire** | Libellé de la question de suppression (en gros en rouge) |
+| `alert` | *optionnel* | Message d'alerte supplémentaire (bloc jaune) |
+| `info` | *optionnel* | Informations liées à la suppression (expliquant ce qui va être impacté par la suppression) |
+| `confirm` | *optionnel* | Libellé de la case à cocher pour la suppression, si ce paramètre est absent ou `NULL`, la case à cocher ne sera pas affichée. |
+
+Le formulaire envoie un `POST` avec le bouton ayant le nom `delete`. Si le paramètre `confirm` est renseigné, alors la case à cochée aura le nom `confirm_delete`.
+
+Exemple :
+
+```
+{{#load id=$_GET.id assign="invoice"}}
+{{else}}
+ {{:error message="Facture introuvable"}}
+{{/load}}
+
+{{#form on="delete"}}
+ {{if !$_POST.confirm_delete}}
+ {{:error message="Merci de cocher la case"}}
+ {{/if}}
+ {{:delete id=$invoice.id}}
+{{/form}}
+
+{{:form_errors}}
+
+{{:delete_form
+ legend="Suppression d'une facture"
+ warning="Supprimer la facture n°%d ?"|args:$invoice.id
+ info="Le devis lié sera également supprimé"
+ alert="La facture sera définitivement perdue !"
+ confirm="Cocher cette case pour confirmer la suppression de la facture"
+}}
+```
+
+## input
+
+Crée un champ de formulaire HTML. Cette fonction est une extension à la balise ` ` en HTML, mais permet plus de choses.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `name` | **obligatoire** | Nom du champ |
+| `type` | **obligatoire** | Type de champ |
+| `required` | *optionnel* | Mettre à `true` si le champ est obligatoire |
+| `label` | *optionnel* | Libellé du champ |
+| `help` | *optionnel* | Texte d'aide, affiché sous le champ |
+| `default` | *optionnel* | Valeur du champ par défaut, si le formulaire n'a pas été envoyé, et que la valeur dans `source` est vide |
+| `source` | *optionnel* | Source de pré-remplissage du champ. Si le nom du champ est `montant`, alors la valeur de `[source].montant` sera affichée si présente. |
+
+Si `label` ou `help` sont spécifiés, le champ sera intégré à une balise HTML ``, et le libellé sera intégré à une balise ` `. Dans ce cas il faut donc que le champ soit dans une liste ` `. Si ces deux paramètres ne sont pas spécifiés, le champ sera le seul tag HTML.
+
+```
+
+ {{:input name="amount" type="money" label="Montant" required=true}}
+
+```
+
+Note : le champ aura comme `id` la valeur `f_[name]`. Ainsi un champ avec `amount` comme `name` aura `id="f_amount"`.
+
+### Valeur du champ
+
+La valeur du champ est remplie avec :
+
+* la valeur dans `$_POST` qui correspond au `name` ;
+* sinon la valeur dans `source` (tableau) avec le même nom (exemple : `$source[name]`) ;
+* sinon la valeur de `default` est utilisée.
+
+Note : le paramètre `value` n'est pas supporté sauf pour checkbox et radio.
+
+### Types de champs supportés
+
+* les types classiques de `input` en HTML : text, search, email, url, file, date, checkbox, radio, password, etc.
+ * Note : pour checkbox et radio, il faut utiliser le paramètre `value` en plus pour spécifier la valeur.
+* `textarea`
+* `money` créera un champ qui attend une valeur de monnaie au format décimal
+* `datetime` créera un champ date et un champ texte pour entrer l'heure au format `HH:MM`
+* `radio-btn` créera un champ de type radio mais sous la forme d'un gros bouton
+* `select` crée un sélecteur de type ``. Dans ce cas il convient d'indiquer un tableau associatif dans le paramètre `options`.
+* `select_groups` crée un sélecteur de type ``, mais avec des ``. Dans ce cas il convient d'indiquer un tableau associatif à deux niveaux dans le paramètre `options`.
+* `list` crée un champ permettant de sélectionner un ou des éléments (selon si le paramètre `multiple` est `true` ou `false`) dans un formulaire externe. Le paramètre `can_delete` indique si l'utilisateur peut supprimer l'élément déjà sélectionné (si `multiple=false`). La sélection se fait à partir d'un formulaire dont l'URL doit être spécifiée dans le paramètre `target`. Les formulaires actuellement supportés sont :
+ * `!acc/charts/accounts/selector.php?targets=X` pour sélectionner un compte du plan comptable, où X est une liste de types de comptes qu'il faut permettre de choisir (séparés par des `:`)
+ * `!users/selector.php` pour sélectionner un membre
+
+## button
+
+Affiche un bouton, similaire à `` en HTML, mais permet d'ajouter une icône par exemple.
+
+```
+{{:button type="submit" name="save" label="Créer ce membre" shape="plus" class="main"}}
+```
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `type` | optionnel | Type du bouton |
+| `name` | optionnel | Nom du bouton |
+| `label` | optionnel | Label du bouton |
+| `shape` | optionnel | Affiche une icône en préfixe du label |
+| `class` | optionnel | Classe CSS |
+| `title` | optionnel | Attribut HTML `title` |
+| `disabled` | optionnel | Désactive le bouton si `true` |
+
+
+## link
+
+Affiche un lien.
+
+```
+{{:link href="!users/new.php" label="Créer un nouveau membre"}}
+```
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `href` | **obligatoire** | Adresse du lien |
+| `label` | **obligatoire** | Libellé du lien |
+| `target` | *optionnel* | Cible du lien, utiliser `_dialog` pour que le lien s'ouvre dans une fenêtre modale. |
+
+
+Préfixer l'adresse par "!" donnera une URL absolue en préfixant l'adresse par l'URL de l'administration.
+Sans "!", l'adresse générée sera relative au contexte d'appel (module/plugin ou squelette site web).
+
+
+## linkbutton
+
+Affiche un lien sous forme de faux bouton, avec une icône si le paramètre `shape` est spécifié.
+
+```
+{{:linkbutton href="!users/new.php" label="Créer un nouveau membre" shape="plus"}}
+```
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `href` | **obligatoire* | Adresse du lien |
+| `label` | **obligatoire** | Libellé du bouton |
+| `target` | *optionnel* | Cible de l'ouverture du lien |
+| `shape` | *optionnel* | Affiche une icône en préfixe du label |
+
+Si on utilise `target="_dialog"` alors le lien s'ouvrira dans une fenêtre modale (iframe) par dessus la page actuelle.
+
+Si on utilise `target="_blank"` alors le lien s'ouvrira dans un nouvel onglet.
+
+## icon
+
+Affiche une icône.
+
+```
+{{:icon shape="print"}}
+```
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `shape` | **obligatoire** | Forme de l'icône. |
+
+
+### Formes d'icônes disponibles
+
+![](shapes.png)
+
+## user_field
+
+Affiche un champ de la fiche membre.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `name` | **obligatoire** | Nom du champ. |
+| `value` | **obligatoire** | Valeur du champ. |
+
+## edit_user_field
+
+Afficher un champ de formulaire pour modifier un champ de la fiche membre.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `name` | **obligatoire** | Nom du champ. |
+| `source` | *optionnel* | Source de pré-remplissage du champ. Si le nom du champ est `montant`, alors la valeur de `[source].montant` sera utilisée comme valeur du champ. |
+
+# Gestion de fichiers dans les modules
+
+Les modules peuvent stocker des fichiers, mais seulement dans leur propre contexte. Un module ne peut pas gérer les fichiers du site web, des écritures comptables, des membres, ou des autres modules, il ne peut gérer que ses propres fichiers.
+
+Quand les données d'un module sont supprimé, les fichiers du module sont aussi supprimés.
+
+Mais si le module stocke des fichiers liés à un document JSON (par exemple dans un sous-répertoire pour chaque module), c'est au code du module de s'assurer que les fichiers seront supprimés lors de la suppression du document.
+
+Par défaut, tous les fichiers des modules sont en accès restreint : ils ne peuvent être vus et modifiés que par les membres connectés qui sont au niveau d'accès indiqué dans les paramètres `restrict_section` et `restrict_level` du fichier `module.ini`.
+
+Pour qu'un fichier soit visible publiquement aux personnes non connectées, il faut le placer dans le sous-répertoire `public` du module.
+
+Attention : de par ce fonctionnement, **tous les fichiers** d'un module sont potentiellement accessibles par **tous les membres ayant accès au module** et connaissant le nom du fichier.
+
+Il est donc recommandé de ne pas utiliser ce mécanisme pour stocker des données personnelles ou des données sensibles.
+
+## admin_files
+
+Affiche (dans le contexte de l'administration) la liste des fichiers dans un sous-répertoire, et éventuellement la possibilité d'en ajouter ou de les supprimer.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `path` | optionnel | Chemin du sous-répertoire où sont stockés les fichiers |
+| `upload` | optionnel | Booléen. Si `true`, l'utilisateur pourra ajouter des fichiers. (Défaut : `false`) |
+| `edit` | optionnel | Booléen. Si `true`, l'utilisateur pourra modifier ou supprimer les fichiers existants. (Défaut : `false`) |
+| `use_trash` | optionnel | Booléen. Si `false`, le fichier sera supprimé, sans passer par la corbeille. Défaut : `true` |
+
+Exemple pour afficher la liste des fichiers du sous-répertoire `facture43` et permettre de rajouter de nouveaux fichiers :
+
+```
+{{:admin_files path="facture43" upload=true edit=false}}
+```
+
+## delete_file
+
+Supprimer un fichier ou un répertoire lié au module courant.
+
+| Paramètre | Obligatoire ou optionnel ? | Fonction |
+| :- | :- | :- |
+| `path` | obligatoire | Chemin du fichier ou répertoire |
+
+Exemple pour supprimer un fichier seul :
+
+```
+{{:delete_file path="facture43/justificatif.pdf"}}
+```
+
+Pour supprimer un répertoire et tous les fichiers dedans :
+
+```
+{{:delete_file path="facture43"}}
+```
diff --git a/doc/admin/brindille_modifiers.md b/doc/admin/brindille_modifiers.md
new file mode 100644
index 0000000..433588e
--- /dev/null
+++ b/doc/admin/brindille_modifiers.md
@@ -0,0 +1,785 @@
+Title: Référence des filtres Brindille
+
+{{{.nav
+* [Modules](modules.html)
+* [Documentation Brindille](brindille.html)
+* [Fonctions](brindille_functions.html)
+* [Sections](brindille_sections.html)
+* **[Filtres](brindille_modifiers.html)**
+}}}
+
+<>
+
+# Filtres PHP
+
+Ces filtres viennent directement de PHP et utilisent donc les mêmes paramètres. Voir la documentation PHP pour plus de détails.
+
+| Nom | Description | Documentation PHP |
+| :- | :- | :- |
+| `htmlentities` | Convertit tous les caractères éligibles en entités HTML | [Documentation PHP](https://www.php.net/htmlentities) |
+| `htmlspecialchars` | Convertit les caractères spéciaux en entités HTML | [Documentation PHP](https://www.php.net/htmlspecialchars) |
+| `trim` | Supprime les espaces et lignes vides au début et à la fin d'un texte | [Documentation PHP](https://www.php.net/trim) |
+| `ltrim` | Supprime les espaces et lignes vides au début d'un texte | [Documentation PHP](https://www.php.net/ltrim) |
+| `rtrim` | Supprime les espaces et lignes vides à la fin d'un texte | [Documentation PHP](https://www.php.net/rtrim) |
+| `md5` | Génère un hash MD5 d'un texte | [Documentation PHP](https://www.php.net/md5) |
+| `sha1` | Génère un hash SHA1 d'un texte | [Documentation PHP](https://www.php.net/sha1) |
+| `strlen` | Nombre de caractères dans une chaîne de texte | [Documentation PHP](https://www.php.net/strlen) |
+| `strpos` | Position d'un élément dans une chaîne de texte | [Documentation PHP](https://www.php.net/strpos) |
+| `strrpos` | Position d'un dernier élément dans une chaîne de texte | [Documentation PHP](https://www.php.net/strrpos) |
+| `substr` | Découpe une chaîne de caractère | [Documentation PHP](https://www.php.net/substr) |
+| `strtotime` | Transforme une date en timestamp UNIX | [Documentation PHP](https://www.php.net/strtotime) |
+| `strip_tags` | Supprime les tags HTML | [Documentation PHP](https://www.php.net/strip_tags) |
+| `nl2br` | Remplace les retours à la ligne par des tags HTML ` ` | [Documentation PHP](https://www.php.net/nl2br) |
+| `wordwrap` | Ajoute des retours à la ligne tous les 75 caractères | [Documentation PHP](https://www.php.net/wordwrap) |
+| `abs` | Renvoie la valeur absolue d'un nombre (exemple : -42 sera transformé en 42) | [Documentation PHP](https://www.php.net/abs) |
+| `gettype` | Renvoie le type d'une variable | |
+| `intval` | Transforme une valeur en entier (integer) | [Documentation PHP](https://www.php.net/intval) |
+| `boolval` | Transforme une valeur en booléen (true ou false) | [Documentation PHP](https://www.php.net/boolval) |
+| `floatval` | Transforme une valeur en nombre flottant (à virgule) | [Documentation PHP](https://www.php.net/floatval) |
+| `strval` | Transforme une valeur en chaîne de texte | [Documentation PHP](https://www.php.net/strval) |
+| `arrayval` | Transforme une valeur en tableau | [Documentation PHP](https://www.php.net/manual/fr/language.types.type-juggling.php) |
+| `json_decode` | Transforme une chaîne JSON en valeur | [Documentation PHP](https://www.php.net/json_decode) |
+| `json_encode` | Transforme une valeur en chaîne JSON | [Documentation PHP](https://www.php.net/json_encode) |
+| `http_build_query` | Transformer un tableau en chaîne *query string* pour URL | [Documentation PHP](https://www.php.net/http_build_query) |
+| `str_getcsv` | Transformer une chaîne de texte de format CSV en tableau | [Documentation PHP](https://www.php.net/str_getcsv) |
+
+# Filtres utiles pour les e-mails
+
+## check_email
+
+Permet de vérifier la validité d'une adresse email. Cette fonction vérifie la syntaxe de l'adresse mais aussi que le nom de domaine indiqué possède bien un enregistrement de type MX.
+
+Renvoie `true` si l'adresse est valide.
+
+```
+{{if !$_POST.email|check_email}}
+L'adresse e-mail indiquée est invalide.
+{{/if}}
+```
+
+## protect_contact
+
+Crée un lien protégé pour une adresse email, pour éviter que l'adresse ne soit recueillie par les robots spammeurs (empêche également le copier-coller et le lien ne fonctionnera pas avec javascript désactivé).
+
+# Filtres de tableaux
+
+## has
+
+Renvoie vrai si le tableau contient l'élément passé en paramètre.
+
+```
+{{:assign var="table" a="bleu" b="orange"}}
+{{if $table|has:"bleu"}}
+ Oui, il y a du bleu
+{{/if}}
+```
+
+## in
+
+Renvoie vrai si l'élément fait partie du tableau passé en paramètre.
+
+C'est exactement la même chose que `has`, mais exprimé à l'envers.
+
+```
+{{:assign var="table" a="bleu" b="orange"}}
+{{if "bleu"|in:$table}}
+ Oui, il y a du bleu
+{{/if}}
+```
+
+## has_key
+
+Renvoie vrai si le tableau contient la clé passée en paramètre.
+
+```
+{{:assign var="table" a="bleu" b="orange"}}
+{{if $table|has_key:"b"}}
+ Oui, il y a la clé "b"
+{{/if}}
+```
+
+## key_in
+
+Renvoie vrai si la clé fait partie du tableau passé en paramètre.
+
+C'est exactement la même chose que `has_key`, mais exprimé à l'envers.
+
+```
+{{:assign var="table" a="bleu" b="orange"}}
+{{if "b"|key_in:$table}}
+ Oui, il y a la clé "b"
+{{/if}}
+```
+
+## keys
+
+Renvoie les clés du tableau, sous forme de tableau.
+
+```
+{{:assign var="table" a="bleu" b="orange"}}
+{{:assign var="cles" value=$table|keys}}
+{{$cles|implode:","}}
+```
+
+Donnera :
+
+```
+a,b
+```
+
+## values
+
+Renvoie les valeurs du tableau, sous forme de tableau.
+
+Cela revient en fait à supprimer les clés associatives.
+
+```
+{{:assign var="table" a="bleu" b="orange"}}
+{{#foreach from=$table key="cle" item="valeur"}}
+ {{$cle}} = {{$valeur}}
+{{/foreach}}
+--
+{{:assign var="valeurs" value=$table|values}}
+{{#foreach from=$valeurs key="cle" item="valeur"}}
+ {{$cle}} = {{$valeur}}
+{{/foreach}}
+```
+
+Donnera :
+
+```
+a = bleu
+b = orange
+--
+0 = bleu
+1 = orange
+```
+
+## count
+
+Compte le nombre d'entrées dans un tableau.
+
+```
+{{$products|count}}
+= 5
+```
+
+## explode
+
+Sépare une chaîne de texte en tableau à partir d'une chaîne de séparation.
+
+```
+{{:assign var="table" value="a,b,c"|explode:","}}
+- {{$table.0}}
+- {{$table.1}}
+- {{$table.2}}
+```
+
+Affichera :
+
+```
+- a
+- b
+- c
+```
+
+## implode
+
+Réunit un tableau sous forme de chaîne de texte en utilisant éventuellement une chaîne de liaison entre chaque élément du tableau.
+
+```
+{{:assign var="table" a="bleu" b="orange"}}
+{{$table|implode}}
+{{$table|implode:" - "}}
+```
+
+Affichera :
+
+```
+bleuorange
+bleu - orange
+```
+
+## map
+
+Applique un filtre sur chaque élément du tableau.
+
+Le premier paramètre doit être le nom du filtre. Les autres paramètres seront passés au filtre.
+
+```
+{{:assign var="table" a="01" b="02"}}
+{{:assign var="table" value=$table|map:intval}}
+- {{$table.a}}
+- {{$table.b}}
+```
+
+Affichera :
+
+```
+- 1
+- 2
+```
+
+## ksort, sort
+
+Trie un tableau par ordre alpha-numérique, sans tenir compte des majuscules/minuscules. `ksort` trie le tableau en utilisant les clés, et `sort` trie le tableau en utilisant les valeurs.
+
+```
+{{:assign var="table" b="3" a="2" c="1"}}
+{{$table|sort|implode:","}}
+{{$table|ksort|implode:","}}
+```
+
+Affichera :
+
+```
+1,2,3
+2,3,1
+```
+
+## max, min
+
+Renvoie respectivement la valeur la plus haute ou la plus basse d'un tableau de valeurs numériques.
+
+```
+{{:assign var="table" b="3" a="2" c="1"}}
+{{$table|max}}
+{{$table|min}}
+```
+
+Affichera :
+
+```
+3
+1
+```
+
+# Filtres de texte
+
+## args
+
+Remplace des arguments dans le texte selon le schéma utilisé par [sprintf](https://www.php.net/sprintf).
+
+```
+{{"Il y a %d résultats dans la recherche sur le terme '%s'."|args:$results_count:$query}}
+= Il y a 5 résultat dans la recherche sur le terme 'test'.
+```
+
+## cat
+
+Concaténer un texte avec un autre.
+
+```
+{{"Tangerine"|cat:" Dream"}}
+= Tangerine Dream
+```
+
+## count_words
+
+Compte le nombre de mots dans un texte.
+
+## escape
+
+Échappe le contenu pour un usage dans un document HTML. Ce filtre est appliqué par défaut à tout ce qui est affiché (variables, etc.) sauf à utiliser le filtre `raw` (voir plus bas).
+
+## excerpt
+
+Produit un extrait d'un texte.
+
+Supprime les tags HTML, tronque au nombre de caractères indiqué en second argument (si rien n'est indiqué, alors 600 est utilisé), et englobe dans un paragraphe `...
`.
+
+Équivalent de :
+
+```
+{{$html|strip_tags|truncate:600|nl2br}}
+```
+
+## extract_leading_number
+
+Extrait le numéro au début d'une chaîne de texte.
+
+Exemple :
+
+```
+{{:assign title="02. Cours sur la physique nucléaire"}}
+{{$title|extract_leading_number}}
+```
+
+Affichera :
+
+```
+02
+```
+
+## format_phone_number
+
+Formatte un numéro de téléphone selon le format du pays de l'association.
+
+Seule la France est supportée pour le moment.
+
+Exemple :
+
+```
+{{:assign number="0102030405"}}
+{{$number|format_phone_number}}
+```
+
+Affichera :
+
+```
+01 02 03 04 05
+```
+
+## markdown
+
+Transforme un texte en HTML en utilisant la syntaxe Markdown.
+
+Il est conseillé de rajouter le filtre `|raw` pour ne pas échapper le HTML produit, si on veut afficher le texte formatté dans une page HTML.
+
+```
+{{$texte|markdown|raw}}
+```
+
+## raw
+
+Passer ce filtre désactive la protection automatique contre le HTML (échappement) dans le texte. À utiliser en connaissance de cause avec les contenus qui contiennent du HTML et sont déjà filtrés !
+
+```
+{{"Test"}} = <b>Test
+{{"Test"|raw}} = Test
+```
+
+
+## replace
+
+Remplace des parties du texte par une autre partie.
+
+```
+{{"Tata yoyo"|replace:"yoyo":"yaya"}}
+= Tata yaya
+```
+
+## regexp_replace
+
+Remplace des valeurs en utilisant une expression rationnelles (regexp) ([documentation PHP](https://www.php.net/manual/fr/regexp.introduction.php)).
+
+```
+{{"Tartagueule"|regexp_replace:"/ta/i":"tou"}}
+= tourtougueule
+```
+
+
+## remove_leading_number
+
+Supprime le numéro au début d'un titre.
+
+Cela permet de définir un ordre spécifique aux pages et catégories dans les listes.
+
+```
+{{"03. Beau titre"|remove_leading_number}}
+Beau titre
+```
+
+
+## truncate
+
+Tronque un texte à une longueur définie.
+
+| Argument | Fonction | Valeur par défaut (si omis) |
+| :- | :- | :- |
+| 1 | longueur en nombre de caractères | 80 |
+| 2 | texte à placer à la fin (si tronqué) | … |
+| 3 | coupure stricte, si `true` alors un mot pourra être coupé en deux, si `false` le texte sera coupé au dernier mot complet | `false` |
+
+```
+{{:assign texte="Ceci n'est pas un texte."}}
+{{$texte|truncate:19:"(...)":true}}
+{{$texte|truncate:19:"":false}}
+```
+
+Affichera :
+
+```
+Ceci n'est pas un (...)
+Ceci n'est pas un t
+```
+
+## typo
+
+Formatte un texte selon les règles typographiques françaises : ajoute des espaces insécables devant ou derrière les ponctuations françaises (`« » ? ! :`).
+
+## urlencode
+
+Encode une chaîne de texte pour utilisation dans une adresse URL (alias de `rawurlencode` en PHP).
+
+## xml_escape
+
+Échappe le contenu pour un usage dans un document XML.
+
+## Autres filtres de texte
+
+Les filtres suivants modifient la casse (majuscule/minuscules) d'un texte et ne fonctionneront correctement que si l'extension `mbstring` est installée sur le serveur. Sinon les lettres accentuées ne seront pas modifiées.
+
+Note : il est donc préférable d'utiliser la propriété CSS [`text-transform`](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform) pour modifier la casse si l'usage n'est que pour l'affichage, et non pas pour enregistrer les données.
+
+* `tolower` : transforme un texte en minuscules
+* `toupper` : transforme un texte en majuscules
+* `ucfirst` : met la première lettre du texte en majuscule
+* `ucwords` : met la première lettre de chaque mot en majuscule
+* `lcfirst` : met la première lettre du texte en minuscule
+
+# Filtres sur les sommes en devises
+
+## money
+
+Formatte une valeur de monnaie pour l'affichage.
+
+Une valeur de monnaie doit **toujours** inclure les cents (exprimée sous forme d'entier). Ainsi `15,02` doit être exprimée sous la forme `1502`.
+
+Paramètres optionnels :
+
+1. `true` (défaut) pour ne rien afficher si la valeur est zéro, ou `false` pour afficher `0,00`
+2. `true` pour afficher le signe `+` si le nombre est positif (`-` est toujours affiché si le nombre est négatif)
+
+```
+{{* 12 345,67 = 1234567 *}}
+{{:assign amount=1234567}}
+{{$amount|money}}
+12 345,67
+```
+
+## money_currency
+
+Comme `money` (même paramètres), formatte une valeur de monnaie (entier) pour affichage, mais en ajoutant la devise.
+
+```
+{{:assign amount=1502}}
+{{$amount|money_currency}}
+15,02 €
+```
+
+## money_html
+
+Idem que `money`, mais pour l'affichage en HTML :
+
+```
+{{* 12 345,67 = 1234567 *}}
+{{:assign amount=1234567}}
+{{$amount|money_html}}
+12 345,67
+```
+
+## money_currency_html
+
+Idem que `money_currency`, mais pour l'affichage en HTML :
+
+```
+{{:assign amount=1502}}
+{{$amount|money_currency_html}}
+15,02 €
+```
+
+## money_raw
+
+Formatte une valeur de monnaie (entier) de manière brute : les milliers n'auront pas de séparateur.
+
+```
+{{:assign amount=1234567}}
+{{$amount|money_raw}}
+12345,67
+```
+
+## money_int
+
+Transforme un nombre à partir d'une chaîne de caractère (par exemple `12345,67`) en entier (`1234567`) pour stocker une valeur de monnaie.
+
+```
+{{:assign montant=$_POST.montant|trim|money_int}}
+```
+
+# Filtres SQL
+
+## quote_sql
+
+Protège une chaîne contre les attaques SQL, pour l'utilisation dans une condition.
+
+**Note : il est FORTEMENT déconseillé d'intégrer directement des sources extérieures dans les requêtes SQL, il est préférable d'utiliser les paramètres dans la boucle `sql` et ses dérivées, comme ceci : `{{#sql select="id, nom" tables="users" where="lettre_infos = :lettre" :lettre=$_GET.lettre}}`.**
+
+Exemple :
+
+```
+{{:assign nom=$_GET.nom|quote_sql}}
+{{#sql select="id, nom" tables="users" where="nom = %s"|args:$nom}}
+```
+
+## quote_sql_identifier
+
+La même chose que `quote_sql`, mais pour les identifiants (par exemple nom de table ou de colonne).
+
+Exemple :
+
+```
+{{:assign colonne=$_GET.colonne|quote_sql_identifier}}
+{{#sql select="id, %s"|args:$colonne tables="users"}}
+```
+
+Il est possible d'utiliser un préfixe en argument, utile par exemple quand on a plusieurs tables avec le même nom de colonne :
+
+```
+{{:assign colonne=$_GET.colonne|quote_sql_identifier:"u1"}}
+{{#sql select="u1.id, %s"|args:$colonne tables="users AS u1 INNER JOIN users AS u2 ON u2.id_parent = u1.id"}}
+```
+
+## sql_where
+
+Permet de créer une partie d'une clause SQL `WHERE` complexe.
+
+Le premier paramètre est le nom de la colonne (sans préfixe).
+
+Paramètres :
+
+1. Comparateur : `=, !=, IN, NOT IN, >, >=, <, <=`
+2. Valeur à comparer (peut être un tableau)
+
+Exemple pour afficher la liste des membres des catégories n°1 et n°2:
+
+```
+{{:assign var="list." value=1}}
+{{:assign var="list." value=2}}
+{{#sql select="nom" tables="users" where="id_category"|sql_where:'IN':$id_list}}
+ {{$nom}}
+{{/sql}}
+```
+
+Le requête SQL générée sera alors `SELECT nom FROM users WHERE id_category IN (1, 2)`.
+
+## sql_user_fields
+
+Permet de récupérer le contenu de champs de la fiche utilisateur pour une requête SQL.
+
+C'est particulièrement utile si le module permet de sélectionner dans sa configuration une liste de champs de membre (par exemple pour la carte de membre, ou les reçus fiscaux).
+
+Si un champ mentionné n'existe plus dans les fiches de membres, il sera ignoré.
+
+* Le premier paramètre est la liste des champs (tableau ou chaîne de texte)
+* Le second est le préfixe à utiliser (alias de la table membres), optionnel
+* Le troisième est la chaîne de texte à utiliser pour coller les champs entre eux
+
+Exemple :
+
+```
+{{:assign var="champs_adresse." value="rue"}}
+{{:assign var="champs_adresse." value="ville"}}
+{{:assign var="champs_adresse_sql" value=$champs_adresse|sql_user_fields:"u":" - "}}
+{{#select !champs_adresse_sql AS adresse FROM users AS u; !champs_adresse_sql=$champs_adresse_sql}}
+ {{$adresse}}
+{{/select}}
+```
+
+Affichera :
+
+```
+30 rue de Machin Chose — Dijon
+```
+
+Et la clause SQL générée sera :
+
+```
+LTRIM(COALESCE(' - ' || u.rue, '') || COALESCE(' - ' || u.ville), ' - ')
+```
+
+# Filtres de date
+
+## date
+
+Formatte une date selon le format spécifié en premier paramètre.
+
+Le format est identique au [format utilisé par PHP](https://www.php.net/manual/fr/datetime.format.php).
+
+Si aucun format n'est indiqué, le défaut sera `d/m/Y à H:i`. (en français)
+
+Exemples :
+
+```
+{{:assign this_year=$now|date:'Y'}}
+{{$date|date:'d/m/Y'}}
+```
+
+## strftime
+
+Formatte une date selon un format spécifié en premier paramètre.
+
+Le format à utiliser est identique [au format utilisé par la fonction strftime de PHP](https://www.php.net/strftime).
+
+Un format doit obligatoirement être spécifié.
+
+En passant un code de langue en second paramètre, cette langue sera utilisée. Sont supportés le français (`fr`) et l'anglais (`en`). Le défaut est le français si aucune valeur n'est passée en second paramètre .
+
+```
+{{:assign this_year=$now|date:'%Y'}}
+{{$date|date:'%d/%m/%Y'}}
+```
+
+## relative_date
+
+Renvoie une date relative à la date du jour : `aujourd'hui`, `hier`, `demain`, ou sinon `mardi 2 janvier` (si la date est de l'année en cours) ou `2 janvier 2021` (si la date est d'une autre année).
+
+En spécifiant `true` en premier paramètre, l'heure sera ajoutée au format `14h34`.
+
+## date_short
+
+Formatte une date au format court : `d/m/Y`.
+
+En spécifiant `true` en premier paramètre l'heure sera ajoutée : `à H\hi`.
+
+## date_long
+
+Formatte une date au format long : `lundi 2 janvier 2021`.
+
+En spécifiant `true` en premier paramètre l'heure sera ajoutée : `à 20h42`.
+
+## date_hour
+
+Formatte une date en renvoyant l'heure uniquement : `20h00`.
+
+En passant `true` en premier paramètre, les minutes seront omises si elles sont égales à zéro : `20h`.
+
+## atom_date
+
+Formatte une date au format ATOM : `Y-m-d\TH:i:sP`
+
+## parse_date
+
+Vérifie le format d'une chaîne de texte représentant la date et la transforme en chaîne de date standardisée au format `AAAA-MM-JJ`.
+
+Les formats acceptés sont :
+
+* `AAAA-MM-JJ`
+* `JJ/MM/AAAA`
+* `JJ/MM/AA`
+
+## parse_datetime
+
+Vérifie le format d'une chaîne de texte représentant la date et l'heure et la transforme en chaîne de date et heure standardisée au format `AAAA-MM-JJ HH:mm`.
+
+Les formats acceptés sont :
+
+* `AAAA-MM-JJ HH:mm:ss`
+* `AAAA-MM-JJ HH:mm`
+* `JJ/MM/AAAA HH:mm`
+
+## parse_time
+
+Vérifie le format d'une chaîne de texte représentant l'heure et la transforme en chaîne de date standardisée au format `HH:MM`.
+
+Les formats acceptés sont :
+
+* `HH:MM`
+* `H:M`
+* `H:MM`
+* `HH:M`
+
+Le séparateur peut être `:` ou `h`.
+
+# Filtres de condition
+
+Ces filtres sont à utiliser dans les conditions
+
+## match
+
+Renvoie `true` si le texte indiqué en premier paramètre est trouvé dans la variable.
+
+Ce filtre est insensible à la casse.
+
+```
+{{if $page.path|match:"/aide"}}Bienvenue dans l'aide !{{/if}}
+```
+
+## regexp_match
+
+Renvoie `true` si l'expression régulière indiquée en premier paramètre est trouvée dans la variable.
+
+Exemple pour voir si le texte contient les mots "Bonjour" ou "Au revoir" (insensible à la casse) :
+
+```
+{{if $texte|regexp_match:"/Bonjour|Au revoir/i"}}
+ Trouvé !
+{{else}}
+ Rien trouvé :-(
+{{/if}}
+```
+
+# Autres filtres
+
+## math
+
+Réalise un calcul mathématique. Cette fonction accepte :
+
+* les nombres: `42`, `13,37`, `14.05`
+* les signes : `+ - / *` pour additionner, diminuer, diviser ou multiplier
+* les parenthèses : `( )`
+* les fonctions : `round(0.5452, 2)` `ceil(29,09)` `floor(0.99)` mais aussi : min, max, cos, sin, tan, asin, acos, atan, sinh, cosh, tanh, exp, sqrt, abs, log, log10, et pi.
+
+Le résultat est renvoyé sous la forme d'un entier, ou d'un nombre flottant dont les décimales sont séparées par un point.
+
+```
+{{"1+1"|math}}
+= 2
+```
+
+Il est possible de donner d'autres arguments, de la même manière qu'avec `args` pour y inclure des données provenant de variables :
+
+```
+{{:assign age=42}}
+{{"1+%d"|math:$age}}
+= 43
+{{:assign prix=39.99 tva=19.1}}
+{{"round(%f*%f, 2)"|math:$prix:$tva}}
+= 47.63
+```
+
+## or
+
+Si la variable passée est évalue comme `false` (c'est à dire que sa valeur est un texte vide, ou un nombre qui vaut zéro, ou la valeur `false`), alors le premier paramètre sera utilisé.
+
+```
+{{:assign texte=""}}
+{{$texte|or:"Le texte est vide"}}
+```
+
+Il est possible de chaîner les appels à `or` :
+
+```
+{{:assign texte1="" texte2="0"}}
+{{$texte1|or:$texte2|or:"Aucun texte"}}
+```
+
+## size_in_bytes
+
+Renvoie une taille en octets, Ko, Mo, ou Go à partir d'une taille en octets.
+
+```
+{{100|size_in_bytes}} = 100 o
+{{1500|size_in_bytes}} = 1,50 Ko
+{{1048576|size_in_bytes}} = 1 Mo
+```
+
+## spell_out_number
+
+Épelle un nombre en toutes lettres.
+
+Le premier paramètre peut être utilisé pour spécifier le code de la langue à utiliser (par défaut c'est le français, donc le code `fr`).
+
+```
+{{42|spell_out_number}}
+```
+
+Donnera :
+
+```
+quarante deux
+```
+
+## uuid
+
+Renvoie un identifiant unique au format UUIDv4.
diff --git a/doc/admin/brindille_sections.md b/doc/admin/brindille_sections.md
new file mode 100644
index 0000000..490d14b
--- /dev/null
+++ b/doc/admin/brindille_sections.md
@@ -0,0 +1,827 @@
+Title: Référence des sections Brindille
+
+{{{.nav
+* [Modules](modules.html)
+* [Documentation Brindille](brindille.html)
+* [Fonctions](brindille_functions.html)
+* **[Sections](brindille_sections.html)**
+* [Filtres](brindille_modifiers.html)
+}}}
+
+<>
+
+# Sections généralistes
+
+## foreach
+
+Permet d'itérer sur un tableau par exemple. Ainsi chaque élément du tableau exécutera une fois le contenu de la section.
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `from` | **obligatoire** | Variable sur laquelle effectuer l'itération |
+| `key` | **optionnel** | Nom de la variable à utiliser pour la clé de l'élément |
+| `item` | **optionnel** | Nom de la variable à utiliser pour la valeur de l'élément |
+
+Considérons ce tableau :
+
+```
+{{:assign var="tableau" a="bleu" b="orange"}}
+```
+
+On peut alors itérer pour récupérer les clés (`a` et `b` ainsi que les valeurs `bleu` et `orange`) :
+
+```
+{{#foreach from=$tableau key="key" item="value"}}
+{{$key}} = {{$value}}
+{{/foreach}}
+```
+
+Cela affichera :
+
+```
+a = bleu
+b = orange
+```
+
+Si on a un tableau à plusieurs niveaux, les éléments du tableau sont automatiquement transformés en variable :
+
+```
+{{:assign var="tableau.a" couleur="bleu"}}
+{{:assign var="tableau.b" couleur="orange"}}
+```
+
+```
+{{#foreach from=$variable}}
+{{$couleur}}
+{{/foreach}}
+```
+
+Affichera :
+
+```
+bleu
+orange
+```
+
+### Itérer sans tableau
+
+Il est aussi possible de faire `X` itérations, arbitrairement, sans avoir de tableau en entrée, en utilisant le paramètre `count`.
+
+C'est l'équivalent des boucles `for` dans les autres langages de programmation.
+
+Exemple :
+
+```
+{{#foreach count=3 key="i"}}
+- {{$i}}
+{{/foreach}}
+```
+
+Affichera :
+
+```
+- 0
+- 1
+- 2
+```
+
+## restrict
+
+Permet de limiter (restreindre) une partie de la page aux membres qui sont connectés et/ou qui ont certains droits.
+
+Deux paramètres optionnels peuvent être utilisés ensemble (il n'est pas possible d'utiliser seulement un des deux) :
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `level` | *optionnel* | Niveau d'accès : `read`, `write`, `admin` |
+| `section` | *optionnel* | Section où le niveau d'accès doit s'appliquer : `users`, `accounting`, `web`, `documents`, `config` |
+| `block` | *optionnel* | Si ce paramètre est présent et vaut `true`, alors l'accès sera interdit si les conditions d'accès demandées ne sont pas remplies : une page d'erreur sera renvoyée. |
+
+Exemple pour voir si un membre est connecté :
+
+```
+{{#restrict}}
+ Un membre est connecté, mais on ne sait pas avec quels droits.
+{{else}}
+ Aucun membre n'est connecté.
+{{/restrict}}
+```
+
+Exemple pour voir si un membre qui peut administrer les membres est connecté :
+
+```
+{{#restrict section="users" level="admin"}}
+ Un membre est connecté, et il a le droit d'administrer les membres.
+{{else}}
+ Aucun membre n'est connecté, ou un membre est connecté mais n'est pas administrateur des membres.
+{{/restrict}}
+```
+
+Pour bloquer l'accès aux membres non connectés, ou qui n'ont pas accès en écriture à la comptabilité.
+
+```
+{{#restrict block=true section="accounting" level="write"}}
+{{/restrict}}
+```
+
+Le mieux est de mettre ce code au début d'un squelette.
+
+# Requêtes SQL
+
+## select
+
+Exécute une requête SQL `SELECT` et effectue une itération pour chaque résultat de la requête.
+
+Pour une utilisation plus simplifiée des requêtes, voir aussi la section [sql](#sql).
+
+Attention : la syntaxe de cette section est différente des autres sections Brindille. En effet après le début (`{{#select`) doit suivre la suite de la requête, et non pas les paramètres :
+
+```
+Liste des membres inscrits à la lettre d'informations :
+{{#select nom, prenom FROM users WHERE lettre_infos = 1;}}
+ - {{prenom}} {{$nom}}
+{{else}}
+ Aucun membre n'est inscrit à la lettre d'information.
+{{/select}}
+```
+
+Des paramètres nommés de SQL peuvent être présentés après le point-virgule marquant la fin de la requête SQL :
+
+```
+{{:assign prenom="Karim"}}
+{{#select * FROM users WHERE prenom = :prenom;
+ :prenom=$prenom}}
+...
+{{/select}}
+```
+
+Notez les deux points avant le nom du paramètre. Ces paramètres sont protégés contre les injections SQL (généralement appelés paramètres nommés).
+
+Pour intégrer des paramètres qui ne sont pas protégés (**attention !**), il faut utiliser le point d'exclamation :
+
+```
+{{:assign var="categories." value=1}}
+{{:assign var="categories." value=2}}
+{{#select * FROM users WHERE !categories;
+ !categories='id_category'|sql_where:'IN':$categories}}
+```
+
+Cela créera la requête suivante : `SELECT * FROM users WHERE id_category IN (1, 2);`
+
+Il est aussi possible d'intégrer directement des variables dans la requête, en utilisant la syntaxe `{$variable|filtre:argument1:argument2}`, comme une variable classique donc, mais au lieu d'utiliser des doubles accolades, on utilise ici des accolades simples. Ces variables seront automatiquement protégées contre les injections SQL.
+
+```
+{{:assign prenom="Camille"}}
+{{#select * FROM users WHERE initiale_prenom = {$prenom|substr:0:1};}}
+```
+
+Cependant, pour plus de lisibilité il est conseillé d'utiliser la syntaxe des paramètres nommés SQL (voir ci-dessus).
+
+Il est aussi possible d'insérer directement du code SQL (attention aux problèmes de sécurité dans ce cas !), pour cela il faut rajouter un point d'exclamation après l'accolade ouvrante :
+
+```
+{{:assign var="prenoms." value="Karim"}}
+{{:assign var="prenoms." value="Camille"}}
+{{#select * FROM users WHERE {!"prenom"|sql_where:"IN":$prenoms};}}
+...
+{{/select}}
+```
+
+Il est aussi possible d'utiliser les paramètres suivants :
+
+| Paramètre | Fonction |
+| :- | :- |
+| `debug` | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. |
+| `explain` | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. |
+| `assign` | Si renseigné, une variable de ce nom sera créée, et le contenu de la ligne y sera assigné. |
+
+Exemple avec `debug` :
+
+```
+{{:assign prenom="Karim"}}
+{{#select * FROM users WHERE prenom = :prenom; :prenom=$prenom debug=true}}
+...
+{{/select}}
+```
+
+Affichera juste au dessus du résultat la requête exécutée :
+
+```
+SELECT * FROM users WHERE nom = 'Karim'
+```
+
+### Paramètre assign
+
+Exemple avec `assign` :
+
+```
+{{#select * FROM users WHERE prenom = 'Camille' LIMIT 1; assign="membre"}}{{/select}}
+{{$membre.nom}}
+```
+
+Il est possible d'utiliser un point final pour que toutes les lignes soient mises dans un tableau :
+
+```
+{{#select * FROM users WHERE prenom = 'Camille' LIMIT 10; assign="membres."}}{{/select}}
+
+{{#foreach from=$membres}}
+ Nom : {{$nom}}
+ Adresse : {{$adresse}}
+{{/foreach}}
+```
+
+## sql
+
+
+Effectue une requête SQL de type `SELECT` dans la base de données, mais de manière simplifiée par rapport à `select`.
+
+```
+{{#sql select="*, julianday(date) AS day" tables="membres" where="id_categorie = :id_categorie" :id_categorie=$_GET.id_categorie order="numero DESC" begin=":page*100" limit=100 :page=$_GET.page}}
+…
+{{/sql}}
+```
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `tables` | **obligatoire** | Liste des tables à utiliser dans la requête (séparées par des virgules). |
+| `select` | *optionnel* | Liste des colonnes à sélectionner, si non spécifié, toutes les colonnes (`*`) seront sélectionnées |
+
+### Sections qui héritent de `sql`
+
+Certaines sections (voir plus bas) héritent de `sql` et rajoutent des fonctionnalités. Dans toutes ces sections, il est possible d'utiliser les paramètres facultatifs suivants :
+
+| Paramètre | Fonction |
+| :- | :- |
+| `where` | Condition de sélection des résultats |
+| `begin` | Début des résultats, si vide une valeur de `0` sera utilisée. |
+| `limit` | Limitation des résultats. Si vide, une valeur de `10000` sera utilisée. |
+| `group` | Contenu de la clause `GROUP BY` |
+| `having` | Contenu de la clause `HAVING` |
+| `order` | Ordre de tri des résultats. Si vide le tri sera fait par ordre d'ajout dans la base de données. |
+| `assign` | Si renseigné, une variable de ce nom sera créée, et le contenu de la ligne du résultat y sera assigné. |
+| `debug` | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. |
+| `explain` | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. |
+| `count` | Booléen ou texte. Si ce paramètre est `TRUE`, le nombre de résultats sera retourné. Si une chaîne de texte est indiquée, elle sera utilisée dans la clause `COUNT()`. |
+
+Il est également possible de passer des arguments dans les paramètres à l'aides des arguments nommés qui commencent par deux points `:` :
+
+```
+{{#articles where="title = :montitre" :montitre="Actualité"}}
+```
+
+Exemples d'utilisation du paramètre `count` :
+
+```
+{{#articles count=true}}
+ Il y a {{$count}} articles.
+{{/articles}}
+
+{{#articles count=true assign="result"}}
+{{/articles}}
+Il y a {{$result.count}} articles.
+
+{{#articles count="DISTINCT title"}}
+ Il y a {{$count}} articles avec un titre différent.
+{{/articles}}
+```
+
+# Membres
+
+## users
+
+Liste les membres.
+
+Paramètres possibles :
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `id` | optionnel | Identifiant unique du membre, ou tableau contenant une liste d'identifiants. |
+| `search_name` | optionnel | Ne lister que les membres dont le nom correspond au texte passé en paramètre. |
+| `id_parent` | optionnel | Ne lister que les membres rattachés à l'identifiant unique du membre responsable indiqué. |
+
+Chaque itération renverra la fiche du membre, ainsi que ces variables :
+
+| Variable | Description |
+| :- | :- |
+| `$id` | Identifiant unique du membre |
+| `$_name` | Nom du membre, tel que défini dans la configuration |
+| `$_login` | Identifiant de connexion du membre, tel que défini dans la configuration |
+| `$_number` | Numéro du membre, tel que défini dans la configuration |
+
+
+## subscriptions
+
+Liste les inscriptions à une ou des activités.
+
+Paramètres possibles :
+
+| Paramètre | | Fonction |
+| :- | :- | :- |
+| `user` | optionnel | Identifiant unique du membre |
+| `active` | optionnel | Si `TRUE`, seules les inscriptions à jour sont listées |
+| `id_service` | optionnel | Ne renvoie que les inscriptions à l'activité correspondant à cet ID. |
+
+# Comptabilité
+
+## accounts
+
+Liste les comptes d'un plan comptable.
+
+| Paramètre | Fonction |
+| :- | :- |
+| `codes` (optionel) | Ne renvoyer que les comptes ayant ces codes (séparer par des virgules). |
+| `id` (optionel) | Ne renvoyer que le compte ayant cet ID. |
+
+## balances
+
+Renvoie la balance des comptes.
+
+| Paramètre | Fonction |
+| :- | :- |
+| `codes` (optionel) | Ne renvoyer que les balances des comptes ayant ces codes (séparer par des virgules). |
+| `year` (optionel) | Ne renvoyer que les balances des comptes utilisés sur l'année (indiquer ici un ID de year). |
+
+## transactions
+
+Renvoie des écritures.
+
+| Paramètre | | Fonction |
+| :- | :- | :- |
+| `id` | optionnel | Indiquer un ID d'écriture pour récupérer ses informations. |
+| `user` | optionnel | Indiquer ici un ID utilisateur pour lister les écritures liées à un membre. |
+
+## years
+
+Liste les exercices comptables
+
+| Paramètre | Fonction |
+| :- | :- |
+| `closed` (optionel) | Mettre `closed=true` pour ne lister que les exercices clôturés, ou `closed=false` pour ne lister que les exercices ouverts. |
+
+# Pour le site web
+
+## breadcrumbs
+
+Permet de récupérer la liste des pages parentes d'une page afin de constituer un [fil d'ariane](https://fr.wikipedia.org/wiki/Fil_d'Ariane_(ergonomie)) permettant de remonter dans l'arborescence du site
+
+Un seul paramètre est possible :
+
+| Paramètre | Fonction |
+| :- | :- |
+| `uri` (obligatoire) | Adresse unique de la page parente |
+| ou `id_page` (obligatoire) | Numéro unique (ID) de la page parente |
+
+Chaque itération renverra trois variables :
+
+| Variable | Contenu |
+| :- | :- |
+| `$id` | Numéro unique (ID) de la page ou catégorie |
+| `$title` | Titre de la page ou catégorie |
+| `$uri` | Nom unique de la page ou catégorie |
+| `$url` | Adresse HTTP de la page ou catégorie |
+
+### Exemple
+
+```
+
+{{#breadcrumbs id_page=$page.id}}
+ {{$title}}
+{{/breadcrumbs}}
+
+```
+
+## pages, articles, categories (sql)
+
+Note : ces sections héritent de `sql` (voir plus haut).
+
+* `pages` renvoie une liste de pages, qu'elles soient des articles ou des catégories
+* `categories` ne renvoie que des catégories
+* `articles` ne renvoie que des articles
+
+À part cela ces trois types de section se comportent de manière identique.
+
+| Paramètre | Fonction |
+| :- | :- |
+| `search` | Renseigner ce paramètre avec un terme à rechercher dans le texte ou le titre. Dans ce cas par défaut le tri des résultats se fait sur la pertinence, sauf si le paramètre `order` est spécifié. |
+| `future` | Renseigner ce paramètre à `false` pour que les articles dont la date est dans le futur n'apparaissent pas, `true` pour ne renvoyer QUE les articles dans le futur, et `null` (ou ne pas utiliser ce paramètre) pour que tous les articles, passés et futur, apparaissent. |
+| `uri` | Adresse unique de la page/catégorie à retourner. |
+| `id_parent` | Numéro unique (ID) de la catégorie parente. Utiliser `null` pour n'afficher que les articles ou catégories de la racine du site. |
+| `parent` | Adresse unique (URI) de la catégorie parente. Exemple pour renvoyer la liste des articles de la sous-catégorie "Événements" de la catégorie "Notre atelier" : `evenements`. Utiliser `null` pour n'afficher que les articles ou catégories de la racine du site. Ajouter un point d'exclamation au début de la valeur pour inverser la condition. |
+
+Par exemple lister 5 articles de la catégorie "Actualité", qui ne sont pas dans le futur, triés du plus récent au plus ancien :
+
+```
+{{#articles future=false parent="actualite" order="published DESC" limit=5}}
+ {{$title}}
+{{/articles}}
+```
+
+Chaque élément de ces boucles contiendra les variables suivantes :
+
+| Nom de la variable | Description | Exemple |
+| :- | :- | :- |
+| `id` | Numéro unique de la page (ID) | `1312` |
+| `id_parent` | Numéro unique de la catégorie parente (ID) | `42` |
+| `type` | Type de page : `1` = catégorie, `2` = article | `2` |
+| `uri` | Adresse unique de la page | `bourse-aux-velos` |
+| `url` | Adresse HTTP de la page | `https://site.association.tld/bourse-aux-velos` |
+| `path` | Chemin complet de la page | `actualite/atelier/bourse-aux-velos` |
+| `parent` | Chemin de la catégorie parente | `actualite/atelier`|
+| `title` | Titre de la page | `Bourse aux vélos` |
+| `content` | Contenu brut de la page | `# Titre …` |
+| `html` | Rendu HTML du contenu de la page | `
Titre …
` |
+| `has_attachments` | `true` si la page a des fichiers joints, `false` sinon | `true` |
+| `published` | Date de publication | `2023-01-01 01:01:01` |
+| `modified` | Date de modification | `2023-01-01 01:01:01` |
+
+Si une recherche a été effectuée, deux autres variables sont fournies :
+
+| Nom de la variable | Description | Exemple |
+| :- | :- | :- |
+| `snippet` | Extrait du contenu contenant le texte recherché (entouré de balises ``) | `L’ONU appelle la France à s’attaquer aux « profonds problèmes » de racisme au sein des forces de…` |
+| `url_highlight` | Adresse de la page, où le texte recherché sera mis en évidence | `https://.../onu-racisme#:~:text=racisme%20au%20sein` |
+
+
+## attachments, documents, images (sql)
+
+Note : ces sections héritent de `sql` (voir plus haut).
+
+* `attachments` renvoie une liste de fichiers joints à une page du site web
+* `documents` renvoie une liste de fichiers joints qui ne sont pas des images
+* `images` renvoie une liste de fichiers joints qui sont des images
+
+À part cela ces trois types de section se comportent de manière identique.
+
+Note : seul les fichiers de la section site web sont accessibles, les fichiers de membres, de comptabilité, etc. ne sont pas disponibles.
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `parent` | **obligatoire** si `id_parent` n'est pas renseigné | Nom unique (URI) de l'article ou catégorie parente dont ont veut lister les fichiers |
+| `id_parent` | **obligatoire** si `parent` n'est pas renseigné | Numéro unique (ID) de l'article ou catégorie parente dont ont veut lister les fichiers |
+| `except_in_text` | *optionnel* | passer `true` à ce paramètre , et seuls les fichiers qui ne sont pas liés dans le texte de la page seront renvoyés |
+
+# Sections relatives aux modules
+
+## form
+
+Permet de gérer la soumission d'un formulaire (`` en HTML).
+
+Si l'élément dont le nom spécifié dans le paramètre `on` a été envoyé en `POST`, alors le code à l'intérieur de la section est exécuté.
+
+Toute erreur à l'intérieur de la section arrêtera son exécution, et le message sera ajouté aux erreurs du formulaire.
+
+Une vérification de sécurité [anti-CSRF](https://fr.wikipedia.org/wiki/Cross-site_request_forgery) est également appliquée. Si cette vérification échoue, le message d'erreur "Merci de bien vouloir renvoyer le formulaire." sera renvoyé. Pour que cela marche il faut que le formulaire dispose d'un bouton de type "submit", généré à l'aide de la fonction `button`. Exemple : `{{:button type="submit" name="save" label="Enregistrer"}}`.
+
+En cas d'erreurs, le reste du contenu de la section ne sera pas exécuté. Les messages d'erreurs seront placés dans un tableau dans la variable `$form_errors`.
+
+Il est aussi possible de les afficher simplement avec la fonction `{{:form_errors}}`. Cela revient à faire une boucle sur la variable `$form_errors`.
+
+```
+{{#form on="save"}}
+ {{if $_POST.titre|trim === ''}}
+ {{:error message="Le titre est vide."}}
+ {{/if}}
+ {{* La ligne suivante ne sera pas exécutée si le titre est vide. *}}
+ {{:save title=$_POST.titre|trim}}
+{{else}}
+ {{:form_errors}}
+{{/form}}
+```
+
+Il est possible d'utiliser `{{:form_errors}}` en dehors du bloc `{{else}}` :
+
+```
+{{#form on="save"}}
+ …
+{{/form}}
+…
+{{:form_errors}}
+```
+
+
+
+## load (sql)
+
+Note : cette section hérite de `sql` (voir plus haut). De ce fait, le nombre de résultats est limité à 10000 par défaut, si le paramètre `limit` n'est pas renseigné.
+
+Charge un ou des documents pour le module courant.
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `module` | optionnel | Nom unique du module lié (par exemple : `recu_don`). Si non spécifié, alors le nom du module courant sera utilisé. |
+| `key` | optionnel | Clé unique du document |
+| `id` | optionnel | Numéro unique du document |
+| `each` | optionnel | Traiter une clé du document comme un tableau |
+
+Il est possible d'utiliser d'autres paramètres : `{{#load cle="valeur"}}`. Cela va comparer `"valeur"` avec la valeur de la clé `cle` dans le document JSON. C'est l'équivalent d'écrire `where="json_extract(document, '$.cle') = 'valeur'"`.
+
+Pour des conditions plus complexes qu'une simple égalité, il est possible d'utiliser la syntaxe courte `$$…` dans le paramètre `where`. Ainsi `where="$$.nom LIKE 'Bourse%'` est l'équivalent de `where="json_extract(document, '$.nom') LIKE 'Bourse%'"`.
+
+Voir [la documentation de SQLite pour plus de détails sur la syntaxe de json_extract](https://www.sqlite.org/json1.html#jex).
+
+Note : un index SQL dynamique est créé pour chaque requête utilisant une clause `json_extract`.
+
+Chaque itération renverra ces deux variables :
+
+| Variable | Valeur |
+| :- | :- |
+| `$key` | Clé unique du document |
+| `$id` | Numéro unique du document |
+
+Ainsi que chaque élément du document JSON lui-même.
+
+### Exemples
+
+Afficher le nom du document dont la clé est `facture_43` :
+
+```
+{{#load key="facture_43"}}
+{{$nom}}
+{{/load}}
+```
+
+Afficher la liste des devis du module `invoice` depuis un autre module par exemple :
+
+```
+{{#load module="invoice" type="quote"}}
+Titre du devis : {{$subject}}
+Montant : {{$total}}
+{{/load}}
+```
+
+### Utilisation du paramètre `each`
+
+Le paramètre `each` est utile pour faire une boucle sur un tableau contenu dans le document. Ce paramètre doit contenir un chemin JSON valide. Par exemple `membres[1].noms` pour boucler sur le tableau `noms`, du premier élément du tableau `membres`. Voir la documentation [de la fonction json_each de SQLite pour plus de détails](https://www.sqlite.org/json1.html#jeach).
+
+Pour chaque itération de la section, la variable `{{$value}}` contiendra l'élément recherché dans le critère `each`.
+
+Par exemple nous pouvons avoir un élément `membres` dans notre document JSON qui contient un tableau de noms de membres :
+
+```
+{{:assign var="membres." value="Greta Thunberg}}
+{{:assign var="membres." value="Valérie Masson-Delmotte"}}
+{{:save membres=$membres}}
+```
+
+Nous pouvons utiliser `each` pour faire une liste :
+
+```
+{{#load each="membres"}}
+- {{$value}}
+{{/load}}
+```
+
+Ou pour récupérer les documents qui correspondent à un critère :
+
+```
+{{#load each="membres" where="value = 'Greta Thunberg'"}}
+Le document n°{{$id}} est celui qui parle de Greta.
+{{/load}}
+```
+
+## list
+
+Attention : cette section n'hérite **PAS de `sql`**.
+
+Un peu comme `{{#load}}` cette section charge les documents d'un module, mais au sein d'une liste (tableau HTML).
+
+Cette liste gère automatiquement l'ordre selon les préférences des utilisateurs, ainsi que la pagination.
+
+Cette section est très puissante et permet de générer des listes simplement, une fois qu'on a saisi la logique de son fonctionnement.
+
+| Paramètre | Optionnel / obligatoire ? | Fonction |
+| :- | :- | :- |
+| `schema` | **requis** si `select` n'est pas fourni | Chemin vers un fichier de schéma JSON qui représenterait le document |
+| `select` | **requis** si `schema` n'est pas fourni | Liste des colonnes à sélectionner, sous la forme `$$.colonne AS "Colonne"`, chaque colonne étant séparée par un point-virgule. |
+| `module` | *optionnel* | Nom unique du module lié (par exemple : `recu_don`). Si non spécifié, alors le nom du module courant sera utilisé. |
+| `columns` | *optionnel* | Permet de n'afficher que certaines colonnes du schéma. Indiquer ici le nom des colonnes, séparées par des virgules. |
+| `order` | *optionnel* | Colonne utilisée par défaut pour le tri (si l'utilisateur n'a pas choisi le tri sur une autre colonne). Si `select` est utilisé, il faut alors indiquer ici le numéro de la colonne, et non pas son nom. |
+| `desc` | *optionnel* | Si ce paramètre est à `true`, l'ordre de tri sera inversé. |
+| `max` | *optionnel* | Nombre d'éléments à afficher dans la liste, sur chaque page. |
+| `where` | *optionnel* | Condition `WHERE` de la requête SQL. |
+| `debug` | *optionnel* | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. |
+| `explain` | *optionnel* | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. |
+| `disable_user_ordering` | *optionnel* | Booléen. Si ce paramètre est `true`, il ne sera pas possible à l'utilisateur d'ordonner les colonnes. |
+
+Pour déterminer quelles colonnes afficher dans le tableau, il faut utiliser soit le paramètre `schema` pour indiquer un fichier de schéma JSON qui sera utilisé pour donner le libellé des colonnes (via la `description` indiquée dans le schéma), soit le paramètre `select`, où il faut alors indiquer le nom et le libellé des colonnes sous la forme `$$.colonne1 AS "Libellé"; $$.colonne2 AS "Libellé 2"`.
+
+Comme pour `load`, il est possible d'utiliser des paramètres supplémentaires : `cle="valeur"`. Cela va comparer `"valeur"` avec la valeur de la clé `cle` dans le document JSON. C'est l'équivalent d'écrire `where="json_extract(document, '$.cle') = 'valeur'"`.
+
+Pour des conditions plus complexes qu'une simple égalité, il est possible d'utiliser la syntaxe courte `$$…` dans le paramètre `where`. Ainsi `where="$$.nom LIKE 'Bourse%'` est l'équivalent de `where="json_extract(document, '$.nom') LIKE 'Bourse%'"`.
+
+Voir [la documentation de SQLite pour plus de détails sur la syntaxe de json_extract](https://www.sqlite.org/json1.html#jex).
+
+Note : un index SQL dynamique est créé pour chaque requête utilisant une clause `json_extract`.
+
+Chaque itération renverra toujours ces deux variables :
+
+| Variable | Valeur |
+| :- | :- |
+| `$key` | Clé unique du document |
+| `$id` | Numéro unique du document |
+
+Ainsi que chaque élément du document JSON lui-même.
+
+La section ouvre un tableau HTML et le ferme automatiquement, donc le contenu de la section **doit** être une ligne de tableau HTML (``).
+
+Dans chaque ligne du tableau il faut respecter l'ordre des colonnes indiqué dans `columns` ou `select`. Une dernière colonne est réservée aux boutons d'action : `... `.
+
+**Attention :** une seule liste peut être utilisée dans une même page. Avoir plusieurs listes provoquera des problèmes au niveau du tri des colonnes.
+
+### Exemples
+
+Lister le nom, la date et le montant des reçus fiscaux, à partir du schéma JSON suivant :
+
+```
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "date": {
+ "description": "Date d'émission",
+ "type": "string",
+ "format": "date"
+ },
+ "adresse": {
+ "description": "Adresse du bénéficiaire",
+ "type": "string"
+ },
+ "nom": {
+ "description": "Nom du bénéficiaire",
+ "type": "string"
+ },
+ "montant": {
+ "description": "Montant",
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+}
+```
+
+Le code de la section sera alors comme suivant :
+
+```
+{{#list schema="./recu.schema.json" columns="nom, date, montant"}}
+
+ {{$nom}}
+ {{$date|date_short}}
+ {{$montant|raw|money_currency}}
+
+ {{:linkbutton shape="eye" label="Ouvrir" href="./voir.html?id=%d"|args:$id target="_dialog"}}
+
+
+{{else}}
+ Aucun reçu n'a été trouvé.
+{{/list}}
+```
+
+Si le paramètre `columns` avait été omis, la colonne `adresse` aurait également été incluse.
+
+Il est à noter que si l'utilisation directe du schéma est bien pratique, cela ne permet pas de récupérer des informations plus complexes dans la structure JSON, par exemple une sous-clé ou l'application d'une fonction SQL. Dans ce cas il faut obligatoirement utiliser `select`. Par exemple ici on veut pouvoir afficher l'année, et trier sur l'année par défaut :
+
+```
+{{#list select="$$.nom AS 'Nom du donateur' ; strftime('%Y', $$.date) AS 'Année'" order=2}}
+
+ {{$nom}}
+ {{$col2}}
+
+ {{:linkbutton shape="eye" label="Ouvrir" href="./voir.html?id=%d"|args:$id target="_dialog"}}
+
+
+{{else}}
+ Aucun reçu n'a été trouvé.
+{{/list}}
+```
+
+On peut utiliser le nom des clés du document JSON, mais sinon pour faire référence à la valeur d'une colonne spécifique dans la boucle, il faut utiliser son numéro d'ordre (qui commence à `1`, pas zéro). Ici on veut afficher l'année, donc la seconde colonne, donc `$col1`.
+
+Noter aussi l'utilisation du numéro de la colonne de l'année (`2`) pour le paramètre `order`, qui avec `select` doit indiquer le numéro de la colonne à utiliser pour l'ordre.
+
+
\ No newline at end of file
diff --git a/doc/admin/keyboard.md b/doc/admin/keyboard.md
new file mode 100644
index 0000000..73cfd8e
--- /dev/null
+++ b/doc/admin/keyboard.md
@@ -0,0 +1,44 @@
+Title: Raccourcis claviers dans l'édition de texte — Paheko
+
+{{{.nav
+* **[Raccourcis claviers](keyboard.html)**
+* [Syntaxe MarkDown complète](markdown.html)
+* [Référence rapide MarkDown](markdown_quickref.html)
+}}}
+
+# Raccourcis clavier
+
+Depuis l'édition du texte :
+
+| Raccourci | Action |
+| :- | :- |
+| Ctrl + G | Mettre en gras |
+| Ctrl + I | Mettre en italique |
+| Ctrl + T | Mettre en titre |
+| Ctrl + L | Transformer en lien |
+| Ctrl + Shift + I | Insérer une image |
+| Ctrl + Shift + F | Insérer un fichier |
+| Ctrl + P | Prévisualiser |
+| Ctrl + S | Enregistrer |
+| F11 | Activer ou désactiver l'édition plein écran |
+| F1 | Afficher l'aide |
+| Echap | Prévisualiser (rappuyer pour revenir à l'édition) |
+
+
+Depuis la prévisualisation :
+
+| Raccourci | Action |
+| :- | :- |
+| Ctrl + P | Retour à l'édition |
+
+Depuis l'aide ou l'insertion de fichier :
+
+| Raccourci | Action |
+| :- | :- |
+| Echap | Fermer et revenir à l'édition |
+
+# Ajouter un fichier ou une image
+
+Il est aussi possible de faire glisser et déposer une image ou un fichier sur le champ d'édition du texte pour l'envoyer et l'insérer.
+
+De même, il est aussi possible d'utiliser le copier/coller dans le texte pour insérer un fichier ou une image.
diff --git a/doc/admin/markdown.md b/doc/admin/markdown.md
new file mode 100644
index 0000000..3415d94
--- /dev/null
+++ b/doc/admin/markdown.md
@@ -0,0 +1,709 @@
+Title: Référence complète MarkDown — Paheko
+
+{{{.nav
+* [Raccourcis claviers](keyboard.html)
+* **[Syntaxe MarkDown complète](markdown.html)**
+* [Référence rapide MarkDown](markdown_quickref.html)
+}}}
+
+<>
+
+# Syntaxe MarkDown
+
+Paheko permet d'utiliser la syntaxe [MarkDown](https://fr.wikipedia.org/wiki/Markdown) dans les pages du site web.
+
+Cette syntaxe est la plus répandue dans les outils d'édition de texte, si vous ne la connaissez pas encore, voici les règles qu'on peut utiliser pour formatter du texte avec MarkDown dans la plupart des outils (dont Paheko), ainsi que [les règles spécifiques supportées par Paheko](#extensions).
+
+## Styles de texte
+
+### Italique
+
+Pour mettre un texte en italique il faut l'entourer de tirets bas ou d'astérisques :
+
+```
+Ce texte est en *italique, dingue !*
+```
+
+Donnera :
+
+> Ce texte est en *italique, dingue !*
+
+### Gras
+
+Pour le gras, procéder de la même manière, mais avec deux tirets bas ou deux astérisques :
+
+```
+Ce texte est **très gras**.
+```
+
+> Ce texte est **très gras**.
+
+### Gras et italique
+
+Pour combiner, utiliser trois tirets ou trois astérisques :
+
+```
+Ce texte est ***gras et italique***.
+```
+
+> Ce texte est ***gras et italique***.
+
+### Barré
+
+Utiliser un symbole tilde pour barrer un texte :
+
+```
+Texte ~~complètement barré~~.
+```
+
+> Texte ~~complètement barré~~.
+
+### Surligné
+
+Il est possible de marquer une phrase ou un mot comme surligné en l'entourant de deux signes égal :
+
+```
+Ce texte est ==surligné==.
+```
+
+> Ce texte est ==surligné==.
+
+
+### Code
+
+Il est possible d'indiquer du code dans une ligne de texte avec un caractère *backtick* (accent grave en français, obtenu avec les touches [Alt Gr + 7](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows)) :
+
+```
+Le code `` c'est rigolo !
+```
+
+> Le code `` c'est rigolo !
+
+### Avertissement sur les styles de texte
+
+Un style de texte ne s'applique que dans un même paragraphe, il n'est pas possible d'appliquer un style sur plusieurs paragraphes :
+
+Dans l'exemple suivant, les astérisques ne seront pas remplacées par du gras, elles resteront telles quelles :
+
+```
+Ce texte n'est pas très **gras.
+
+Et celui-ci encore moins**.
+```
+
+> Ce texte n'est pas très **gras.
+>
+> Et celui-ci encore moins**.
+
+## Liens
+
+Créez un lien en mettant le texte désiré entre crochets et le lien associé entre parenthèses :
+
+```
+Je connais un super gestionnaire [d'association](https://paheko.cloud/) !
+```
+
+Donne :
+
+> Je connais un super gestionnaire [d'association](https://paheko.cloud/) !
+
+Il est possible de faire un lien vers une autre page du site web en utilisant son adresse unique :
+
+```
+N'oubliez pas de [vous inscrire à notre atelier](atelier-soudure).
+```
+
+Il est aussi possible de simplement inclure une adresse URL et elle sera automatiquement transformée en lien :
+
+```
+https://paheko.cloud/
+```
+
+## Blocs
+
+### Paragraphes et retours à la ligne
+
+Une ligne vide indique un changement de paragraphe :
+
+```
+Ceci est un paragraphe.
+
+Ceci est est un autre.
+```
+
+Un retour à la ligne simple est traité comme tel :
+
+```
+Ceci est un
+paragraphe.
+```
+
+> Ceci est un
+> paragraphe.
+
+### Titres et sous-titres
+
+Pour faire un titre, vous devez mettre un ou plusieurs caractères *hash* (`#`) au début de la ligne.
+
+Un titre avec un seul caractère est un titre principal (niveau 1), avec deux caractères c'est un sous-titre (niveau 2), etc. jusqu'au niveau 6.
+
+```
+# Titre principal (niveau 1)
+## Sous-titre (niveau 2)
+### Sous-sous-titre (niveau 3)
+#### Niveau 4
+##### Niveau 5
+###### Dernier niveau de sous-titre (6)
+```
+
+Donnera :
+
+> # Titre principal (niveau 1) {.no_toc}
+> ## Sous-titre (niveau 2) {.no_toc}
+> ### Sous-sous-titre (niveau 3) {.no_toc}
+> #### Niveau 4 {.no_toc}
+> ##### Niveau 5 {.no_toc}
+> ###### Dernier niveau de sous-titre (6) {.no_toc}
+
+
+### Listes
+
+Vous pouvez créer des listes avec les caractères astérisque (`*`) et tiret `-` en début de ligne pour des listes non ordonnées :
+
+```
+* une élément
+* un autre
+ - un sous élément
+ - un autre sous élément
+* un dernier élément
+```
+
+> * une élément
+> * un autre
+> - un sous élément
+> - un autre sous élément
+> * un dernier élément
+
+Ou avec des nombres pour des listes ordonnées :
+
+```
+1. élément un
+2. élément deux
+```
+
+> 1. élément un
+> 2. élément deux
+
+L'ordre des nombres n'est pas important, seul le premier nombre est utilisé pour déterminer à quel numéro commencer la liste.
+
+Exemple :
+
+```
+3. A
+5. B
+4. C
+```
+
+> 3. A
+> 5. B
+> 4. C
+
+Il est ainsi possible d'utiliser uniquement le même numéro pour ne pas avoir à numéroter sa liste :
+
+```
+1. Un
+1. Deux
+```
+
+> 1. Un
+> 1. Deux
+
+### Citations
+
+Les citations se font en ajoutant le signe *supérieur à* (`>`) au début de la ligne :
+
+```
+> Programming is not a science. Programming is a craft.
+```
+
+> Programming is not a science. Programming is a craft.
+
+### Code
+
+Créez un bloc de code en indentant chaque ligne avec quatre espaces, ou en mettant trois accents graves ``` ` ``` (*backtick*, obtenu avec [Alt Gr + 7](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows)) sur la ligne au dessus et en dessous de votre code:
+
+ ```
+ ...
+ ```
+
+Résultat :
+
+```
+...
+```
+
+### Tableaux
+
+Pour créer un tableau vous devez séparer les colonnes avec des barres verticales (`|`, obtenu avec les touches [AltGr + 6](https://fr.wikipedia.org/wiki/Barre_verticale#Saisie)).
+
+La première ligne contient les noms des colonnes, la seconde ligne contient la ligne de séparation (chaque cellule doit contenir un ou plusieurs tirets), et les lignes suivantes représentent le contenu du tableau.
+
+```
+| Colonne 1 | Colonne 2 |
+| - | - | - |
+| AB | CD |
+```
+
+| Colonne 1 | Colonne 2 |
+| - | - |
+| AB | CD |
+
+Par défaut les colonnes sont centrées. On peut aussi aligner le texte à gauche ou à droite en mettant deux points après le ou les tirets de la ligne suivant l'entête :
+
+```
+| Aligné à gauche | Centré | Aligné à droite |
+| :--------------- |:---------------:| :--------------:|
+| Aligné à gauche | ce texte | Aligné à droite |
+| Aligné à gauche | est | Aligné à droite |
+| Aligné à gauche | centré | Aligné à droite |
+```
+
+| Aligné à gauche | Centré | Aligné à droite |
+| :--------------- |:---------------:| :--------------:|
+| Aligné à gauche | ce texte | Aligné à droite |
+| Aligné à gauche | est | Aligné à droite |
+| Aligné à gauche | centré | Aligné à droite |
+
+### Ligne de séparation
+
+Il suffit de mettre au moins 3 tirets à la suite sur une ligne séparée pour ajouter une ligne de séparation :
+
+```
+---
+```
+
+Résultat :
+
+---
+
+### Commentaires
+
+Pour ajouter un commentaire qui ne sera pas affiché dans le texte, utiliser la syntaxe suivante :
+
+```
+
+```
+
+## Notes de bas de page
+
+Pour créer une note de base de page, il faut mettre entre crochets un signe circonflexe (obtenu en appuyant sur la touche circonflexe, puis sur espace) suivi du numéro ou du nom de la note. Enfin, à la fin du texte il faudra répéter les crochets, le signe circonflexe, suivi de deux points et de la définition.
+
+```
+Texte très intéressant[^1]. Approuvé par 100% des utilisateurs[^Source].
+
+[^1]: Ceci est une note de bas de page
+[^Source]: Enquête Paheko sur la base de 1 personne interrogée.
+```
+
+Donnera ceci :
+
+> Texte très intéressant[^1]. Approuvé par 100% des utilisateurs[^Source].
+>
+> [^1]: Ceci est une note de bas de page
+> [^Source]: Enquête Paheko sur la base de 1 personne interrogée.
+
+
+## Insertion de vidéos depuis un service de vidéo
+
+Certains services vidéo comme les instances Peertube permettent l'intégration des vidéos.
+
+Pour cela il faut recopier le code d'intégration donné par le service vidéo. Voici un exemple :
+
+```
+
+```
+
+Résultat :
+
+
+
+## Identifiant et classe CSS sur les titres
+
+Il est possible de spécifier l'ID et la classe CSS d'un titre en les rajoutant à la fin du titre, entre accolades, comme ceci :
+
+```
+## Titre de niveau 2 {#titre2} {.text-center}
+```
+
+Le code HTML résultant sera comme ceci :
+
+```
+Titre de niveau 2
+```
+
+## Classes CSS
+
+Il est possible de donner une classe CSS parente à un ensemble d'éléments en les mettant au centre d'un bloc définissant cette classe :
+
+```
+{{{.custom-quote .custom-block
+
+Paragraphe
+
+> Citation
+}}}
+```
+
+Créera le code HTML suivant :
+
+```
+
+
+
Paragraphe
+
+
Citation
+
+```
+
+## Tags HTML
+
+Certains tags HTML sont autorisés :
+
+| Tag | Utilisation | Exemple |
+| :- | :- | :- |
+| `` | Touches de clavier | Ctrl + B |
+| `` | Exemple de programme en console | bohwaz@platypus ~ % sudo apt install paheko |
+| `` | Variable dans un programme informatique | ab + cd = 42 |
+| `` | Texte supprimé | Texte supprimé |
+| `` | Texte ajouté | Texte ajouté |
+| `` | Texte en exposant | Texteexposant |
+| `` | Texte en indice | Texteindice |
+| `` | Texte surligné | Texte surligné |
+| `` | Insérer un lecteur audio dans la page | `` |
+| `` | Insérer une vidéo dans la page | `` |
+
+Mais leurs possibilités sont limitées, notamment sur les attributs autorisés.
+
+# Extensions
+
+Paheko propose des extensions au langage MarkDown, qui n'existent pas dans les autres logiciels utilisant aussi MarkDown.
+
+Toutes ces extensions se présentent sous la forme d'un code situé entre deux signes **inférieur à** (`<<`) et deux signes **supérieur à** (`>>`), à ne pas confondre avec les guillements français (`«` et `»`).
+
+## Images jointes
+
+Il est possible d'intégrer une image jointe à la page web en plaçant le code suivant sur une ligne (sans autre texte) :
+
+```
+<>
+```
+
+* `Nom_fichier.jpg` : remplacer par le nom du fichier de l'image (parmi les images jointes à la page)
+* `Alignement` : remplacer par l'alignement :
+ * `gauche` ou `left` : l'image sera placée à gauche en petit (200 pixels), le texte remplira l'espace laissé sur la droite de l'image ;
+ * `droite` ou `right` : l'image sera placée à droite en petit, le texte remplira l'espace laissé sur la gauche de l'image ;
+ * `centre` ou `center` : l'image sera placée au centre en taille moyenne (500 pixels), le texte sera placé au dessus et en dessous.
+* Légende : indiquer ici une courte description de l'image.
+
+Exemple :
+
+```
+<>
+```
+
+Il est aussi possible d'utiliser la syntaxe avec des paramètres nommés :
+
+```
+<>
+```
+
+Les images qui ne sont pas mentionnées dans le texte seront affichées après le texte sous forme de galerie.
+
+## Galerie d'images
+
+Il est possible d'afficher une galerie d'images (sous forme d'images miniatures) avec la balise `<>
+```
+
+Si aucun nom de fichier n'est indiqué, alors toutes les images jointes à la page seront affichées :
+
+```
+<>
+```
+
+### Diaporama d'images
+
+On peut également afficher cette galerie sous forme de diaporama. Dans ce cas une seule image est affichée, et on peut passer de l'une à l'autre.
+
+La syntaxe est la même, mais on ajoute le mot `slideshow` après le mot `gallery` :
+
+```
+<>
+```
+
+## Fichiers joints
+
+Pour créer un bouton permettant de voir ou télécharger un fichier joint à la page web, il suffit d'utiliser la syntaxe suivante :
+
+```
+<>
+```
+
+* `Nom_fichier.ext` : remplacer par le nom du fichier (parmi les fichiers joints à la page)
+* `Libellé` : indique le libellé du qui sera affiché sur le bouton, si aucun libellé n'est indiqué alors c'est le nom du fichier qui sera affiché
+
+## Vidéos
+
+Pour inclure un lecteur vidéo dans la page web à partir d'un fichier vidéo joint à la page, il faut utiliser le code suivant :
+
+```
+<>
+```
+
+On peut aussi spécifier d'autres paramètres :
+
+* `file` : nom du fichier vidéo
+* `poster` : nom de fichier d'une image utilisée pour remplacer la vidéo avant qu'elle ne soit lue
+* `subtitles` : nom d'un fichier de sous-titres au format VTT ou SRT
+* `width` : largeur de la vidéo (en pixels)
+* `height` : hauteur de la vidéo (en pixels)
+
+Exemple :
+
+```
+<>
+```
+
+## Sommaire / table des matières automatique
+
+Il est possible de placer le code `<>` pour générer un sommaire automatiquement à partir des titres et sous-titres :
+
+```
+<>
+```
+
+Affichera un sommaire comme celui-ci :
+
+<>
+
+Il est possible de limiter les niveaux en utilisant le paramètre `level` comme ceci :
+
+```
+<>
+```
+
+N'affichera que les titres de niveau 1 (précédés d'un seul signe hash `#`), comme ceci :
+
+<>
+
+Enfin il est possible de placer la table des matières sur le côté du texte, en utilisant le paramètre `aside` :
+
+```
+<>
+```
+
+Note : en plus de la syntaxe `<>`, Paheko supporte aussi les syntaxes suivantes par compatibilité avec [les autres moteurs de rendu MarkDown](https://alexharv074.github.io/2018/08/28/auto-generating-markdown-tables-of-contents.html) : `{:toc}` `[[_TOC_]]` `[toc]`.
+
+### Exclure un sous-titre du sommaire
+
+Il est aussi possible d'indiquer qu'un titre ne doit pas être inclus dans le sommaire en utilisant la classe `no_toc` comme ceci :
+
+```
+## Sous-titre non-inclus {.no_toc}
+```
+
+## Grilles et colonnes
+
+Pour une mise en page plus avancée, il est possible d'utiliser les *grilles*, adaptation des [grids en CSS](https://developer.mozilla.org/fr/docs/Web/CSS/CSS_Grid_Layout). Il faut utiliser la syntaxe `<>...Contenu...< >`.
+
+Attention, les blocs `<>` et `< >` doivent obligatoirement être placés sur des lignes qui ne contiennent rien d'autre.
+
+**Note :** sur petit écran (mobile ou tablette) les grilles et colonnes sont désactivées, tout sera affiché dans une seule colonne, comme si les grilles n'étaient pas utilisées.
+
+Pour spécifier le nombre de colonnes on peut utiliser un raccourci qui *mime* les colonnes, comme ceci :
+
+```
+<>
+```
+
+Ce code indique qu'on veut créer une grille de 2 colonnes de largeur identique.
+
+Dans les raccourcis, le point d'exclamation `!` indique une colonne simple, et le hash `#` indique une colonne qui prend le reste de la place selon le nombre de colonnes total.
+
+D'autres exemples de raccourcis :
+
+* `!!` : deux colonnes de largeur égale
+* `!!!` : trois colonnes de largeur égale
+* `!##` : deux colonnes, la première occupant un tiers de la largeur, la seconde occupant les deux tiers
+* `!##!` : 4 colonnes, la première occupant un quart de la largeur, la seconde occupant la moitié, la dernière occupant le quart
+
+Alternativement, pour plus de contrôle, ce bloc accepte les paramètres suivants :
+
+* `short` : notation courte décrite ci-dessus
+* `gap` : espacement entre les blocs de la grille
+* `template` : description CSS complète de la grille (propriété [`grid-template`](https://developer.mozilla.org/fr/docs/Web/CSS/grid-template))
+
+Après ce premier bloc `<>` qui définit la forme de la grille, on peut entrer le contenu de la première colonne.
+
+Pour créer la seconde colonne il faut simplement placer un nouveau bloc `<>` vide (aucun paramètre) sur une ligne.
+
+Enfin on termine en fermant la grille avec un block `< >`. Voici un exemple complet :
+
+```
+<>
+Col. 1
+<>
+Col. 2
+<>
+Col. 3
+< >
+```
+
+<>
+Col. 1
+<>
+Col. 2
+<>
+Col. 3
+< >
+
+Exemple avec 3 colonnes, dont 2 petites et une large :
+
+```
+<>
+Col. 1
+<>
+Colonne 2 large
+<>
+Col. 3
+< >
+```
+
+<>
+Col. 1
+<>
+Colonne 2 large
+<>
+Col. 3
+< >
+
+Il est possible de créer plus de blocs qu'il n'y a de colonnes, cela créera une nouvelle ligne avec le même motif :
+
+```
+<>
+L1 C1
+<>
+L1 C2
+<>
+L2 C1
+<>
+L2 C2
+< >
+```
+
+<>
+L1 C1
+<>
+L1 C2
+<>
+L2 C1
+<>
+L2 C2
+< >
+
+Enfin, il est possible d'utiliser la notation CSS [`grid-row`](https://developer.mozilla.org/en-US/docs/Web/CSS/grid-row) et [`grid-column`](https://developer.mozilla.org/en-US/docs/Web/CSS/grid-column) pour chaque bloc, permettant de déplacer les blocs, ou de faire en sorte qu'un bloc s'étende sur plusieurs colonnes ou plusieurs lignes. Pour cela il faut utiliser le paramètre `row` ou `column` qui précède le bloc :
+
+```
+<>
+A
+<>
+B
+<>
+C
+<>
+D
+< >
+```
+
+<>
+A
+<>
+B
+<>
+C
+<>
+D
+< >
+
+Noter que dans ce cas on doit utiliser la notation `short="…"` pour pouvoir utiliser les autres paramètres.
+
+Enfin, il est possible d'aligner un bloc verticalement par rapport aux autres en utilisant le paramètre `align` (équivalent de la propriété CSS [`align-self`](https://developer.mozilla.org/en-US/docs/Web/CSS/align-self)).
+
+
+
+## Alignement du texte
+
+Il suffit de placer sur une ligne seule le code `<>` pour centrer du texte :
+
+```
+<>
+Texte centré
+< >
+```
+
+On peut procéder de même avec `<>` et `<>` pour aligner à gauche ou à droite.
+
+## Couleurs
+
+Comme sur les [Skyblogs](https://decoblog.skyrock.com/), il est possible de mettre en couleur le texte et le fond, et même de créer des dégradés !
+
+Utiliser la syntaxe `<>...texte...< >` pour changer la couleur du texte, ou `<>...texte...< >` pour la couleur du fond.
+
+Il est possible d'indiquer plusieurs couleurs, séparées par des espaces, pour créer des dégradés.
+
+```
+<>Rouge !< >
+<>Fond jaune pétant !< >
+<>Dégradé de texte !< >
+<>Dégradé du fond< >
+
+<>
+<>
+
+## Il est aussi possible de faire des blocs colorés
+
+Avec des paragraphes
+
+> Et citations
+
+< >
+< >
+```
+
+> <>Rouge !< >
+> <>Fond jaune pétant !< >
+> <>Dégradé de texte !< >
+> <>Dégradé du fond< >
+>
+> <>
+> <>
+> ## Il est aussi possible de faire des blocs colorés {.no_toc}
+>
+> Avec des paragraphes
+>
+> > Et citations
+>
+> < >
+> < >
+
+Il est possible d'utiliser les couleurs avec [leur nom](https://developer.mozilla.org/en-US/docs/Web/CSS/named-color) ou leur code hexadécimal (exemple : `#ff0000` pour rouge).
+
+**Attention : cette fonctionnalité est rigolote mais doit être utilisé avec parcimonie, en effet cela risque de rendre le texte illisible, notamment pour les personnes daltoniennes.**
diff --git a/doc/admin/markdown_quickref.md b/doc/admin/markdown_quickref.md
new file mode 100644
index 0000000..3822b36
--- /dev/null
+++ b/doc/admin/markdown_quickref.md
@@ -0,0 +1,55 @@
+Title: Référence rapide MarkDown — Paheko
+
+{{{.nav
+* [Raccourcis claviers](keyboard.html)
+* [Syntaxe MarkDown complète](markdown.html)
+* **[Référence rapide MarkDown](markdown_quickref.html)**
+}}}
+
+# Référence rapide MarkDown
+
+|Nom | Syntaxe | Rendu | Notes |
+| :- | :- | :- | :- |
+| Italique | `*italique*` | *italique* | |
+| Gras | `**gras**` | **gras** | |
+| Gras et italique | `***gras et italique***` | ***gras et italique*** | |
+| Barré | `~~barré~~` | ~~barré~~ | [^P] |
+| Surligné | `==surligné==` | ==surligné== | [^P] |
+| Lien | `[Libellé du lien](adresse)` | [Libellé du lien](https://paheko.cloud/) | |
+| Titre niveau 1 | `# Titre 1` | Titre 1 | |
+| Titre niveau 2 | `## Titre 2` | Titre 2 | |
+| Titre niveau 3 | `### Titre 3` | Titre 3 | |
+| Titre niveau 4 | `#### Titre 4` | Titre 4 | |
+| Titre niveau 5 | `##### Titre 5` | Titre 5 | |
+| Titre niveau 6 | `###### Titre 6` | Titre 6 | |
+| Liste | \* Liste 1 \* Liste 2
| | |
+| Liste imbriquée | \* Liste 1 \* Sous-liste 1
| | |
+| Liste numérotée | 1. Liste 1 2. Liste 2
| Liste 1 Liste 2 | |
+| Code dans du texte | Voir ce \`code\`
| Voir ce `code` | |
+| Bloc de code | \``` Bloc de code \```
| Bloc de code
| |
+| Citation | > Citation > Citation
| Citation Citation | |
+| Tableau | \| Colonne 1 \| Colonne 2 \| \| - \| - \| \| A \| B \|
| | |
+| Ligne horizontale | `----` | | |
+| Référence à une note de bas de page | `[^1]` | [^1] | [^P] |
+| Définition d'une note de bas de page | \[^1]: Définition
| [^1] | [^P] |
+| Bloc avec classe CSS | {{{.boutons * [Paheko](https://paheko.cloud/) }}}
| | [^P] |
+| Sommaire / table des matières | `<>` | *(ne peut être montré sur cette page)* | [^P] |
+| Image jointe | `<>` | *(ne peut être montré sur cette page)* | [^P] |
+| Fichier joint | `<>` | *(ne peut être montré sur cette page)* | [^P] |
+| Grille à 2 colonnes | \<> Colonne 1 \<> Colonne 2 \< >
| *(ne peut être montré sur cette page)* | [^P] |
+| Texte centré | `<>Centre< >` | Centre
| [^P] |
+| Texte aligné à droite | `<>Droite< >` | Droite
| [^P] |
+| Texte coloré | `<>Rouge< >` | <>Rouge< > | [^P] |
+| Fond coloré | `<>Vert<>` | <>Vert<> | [^P] |
+| Dégradé de texte | `<>Orange à cyan< >` | <>Orange à cyan< > | [^P] |
+| Dégradé de fond | `<>Orange à cyan<>` | <>Orange à cyan<> | [^P] |
+| Clavier | `Ctrl + C ` | Ctrl + C | |
+| Exemple console | `Exemple ` | Exemple | |
+| Variable maths | `ab + cd = 42` | ab + cd = 42 | |
+| Texte supprimé | `supprimé` | supprimé | |
+| Texte ajouté | `ajouté ` | ajouté | |
+| Exposant | `Texteexposant ` | Texteexposant | |
+| Indice | `Texteindice ` | Texteindice | |
+
+[^1]: Exemple de note de bas de page
+[^P]: Indique une syntaxe qui ne fait pas partie du standard Markdown, mais est spécifique à Paheko.
\ No newline at end of file
diff --git a/doc/admin/modules.md b/doc/admin/modules.md
new file mode 100644
index 0000000..5bc1d25
--- /dev/null
+++ b/doc/admin/modules.md
@@ -0,0 +1,338 @@
+Title: Développer des modules pour Paheko
+
+{{{.nav
+* **[Modules](modules.html)**
+* [Documentation Brindille](brindille.html)
+* [Fonctions](brindille_functions.html)
+* [Sections](brindille_sections.html)
+* [Filtres](brindille_modifiers.html)
+}}}
+
+<>
+
+# Introduction
+
+Depuis la version 1.3, Paheko dispose d'extensions modifiables, nommées **Modules**.
+
+Les modules permettent de créer et modifier des formulaires, des modèles de documents simples, à imprimer, mais aussi de créer des "mini-applications" directement dans l'administration de l'association, avec le minimum de code, sans avoir à apprendre à programmer PHP.
+
+Les modules utilisent le langage [Brindille](brindille.html), aussi utilisé pour le site web (qui est lui-même un module). Avec Brindille on parle d'un **squelette** pour un fichier texte contenant du code Brindille.
+
+Les modules ne permettent pas d'exécuter du code PHP, ni de modifier la base de données en dehors des données du module, contrairement aux [plugins](https://fossil.kd2.org/paheko/wiki?name=Documentation/Plugin&p). Grâce à Brindille, les administrateurs de l'association peuvent modifier ou créer de nouveaux modules sans risques pour le serveur, car le code Brindille ne permet pas d'exécuter de fonctions dangereuses. Les **plugins** eux sont écrits en PHP et ne peuvent pas être modifiés par une association. Du fait des risques de sécurité, seuls les plugins officiels sont proposés sur Paheko.cloud.
+
+# Exemples
+
+Paheko fournit quelques modules par défaut, qui peuvent être modifiés ou servir d'inspiration pour de nouveaux modules :
+
+* Reçu de don simple
+* Reçu de paiement simple
+* Reçu fiscal
+* Cartes de membres
+* Heures d'ouverture
+* Modèles d'écritures comptables
+
+Ces exemples sont développés directement avec Brindille et peuvent être modifiés ou lus depuis le menu **Configuration**, onglet **Extensions**.
+
+Un module fourni dans Paheko peut être modifié, et en cas de problème il peut être remis à son état d'origine.
+
+D'autres exemples d'utilisation sont imaginables :
+
+* Auto-remplissage de la déclaration de la liste des dirigeants à la préfecture
+* Compte de résultat et bilan conforme au modèle du plan comptable
+* Formulaires partagés entre la partie privée, et le site web (voir par exemple le module "heures d'ouverture")
+* Gestion de matériel prêté par l'association
+
+# Pré-requis
+
+Une connaissance de la programmation informatique est souhaitable pour commencer à modifier ou créer des modules, mais cela n'est pas requis, il est possible d'apprendre progressivement.
+
+# Résumé technique
+
+* Utilisation de la syntaxe Brindille
+* Les modules peuvent utiliser toutes les fonctions et boucles de Brindille
+* Les modules peuvent stocker et récupérer des données dans la base SQLite dans une table clé-valeur spécifique à chaque module
+* Les données du module sont stockées en JSON, on peut faire des requêtes complètes avec l'extension [JSON de SQLite](https://www.sqlite.org/json1.html)
+* Les données peuvent être validées avant enregistrement en utilisant [JSON Schema](https://json-schema.org/understanding-json-schema/)
+* Un module peut également accéder aux données des autres modules
+* Un module peut aussi accéder à toutes les données de la base de données, sauf certaines données à risque (voir plus bas)
+* Un module ne peut pas modifier les données de la base de données
+* Paheko crée automatiquement des index sur les requêtes SQL des modules, permettant de rendre les requêtes rapides
+
+# Structure des répertoires
+
+Chaque module a un nom unique (composé uniquement de lettres minuscules, de tirets bas et de chiffres) et dispose d'un sous-répertoire dans le dossier `modules`. Ainsi le module `recu_don` serait dans le répertoire `modules/recu_don`.
+
+Dans ce répertoire le module peut avoir autant de fichiers qu'il veut, mais certains fichiers ont une fonction spéciale :
+
+* `module.ini` : contient les informations sur le module, voir ci-dessous pour les détails
+* `config.html` : si ce squelette existe, un bouton "Configurer" apparaîtra dans la liste des modules (Configuration -> Modules) et affichera ce squelette dans un dialogue
+* `icon.svg` : icône du module, qui sera utilisée sur la page d'accueil, si le bouton est activé, et dans la liste des modules. Attention l'élément racine du fichier doit porter l'id `img` pour que l'icône fonctionne (``), notamment pour que les couleurs du thème s'appliquent à l'icône.
+* `README.md` : si ce fichier existe, son contenu sera affiché dans les détails du module
+
+## Snippets
+
+Les modules peuvent également avoir des `snippets`, ce sont des squelettes qui seront inclus à des endroits précis de l'interface, permettant de rajouter des fonctionnalités, ils sont situés dans le sous-répertoire `snippets` du module :
+
+* `snippets/transaction_details.html` : sera inclus en dessous de la fiche d'une écriture comptable
+* `snippets/transaction_new.html` : sera inclus au début du formulaire de saisie d'écriture
+* `snippets/user_details.html` : sera inclus en dessous de la fiche d'un membre
+* `snippets/my_details.html` : sera inclus en dessous de la page "Mes informations personnelles"
+* `snippets/my_services.html` : sera inclus en dessous de la page "Mes inscriptions et cotisations"
+* `snippets/home_button.html` : sera inclus dans la liste des boutons de la page d'accueil (ce fichier ne sera pas appelé si `home_button` est à `true` dans `module.ini`, il le remplace)
+
+### Snippets MarkDown
+
+Il est également possible, depuis Paheko 1.3.2, d'étendre les fonctionnalités Markdown du site web en créant un snippet dans le répertoire `snippets/markdown/`, par exemple `snippets/markdown/map.html`.
+
+Le snippet sera appelé quand on utilise le tag du même nom dans le contenu du site web. Ici par exemple ça serait `<>`.
+
+Le nom du snippet doit commencer par une lettre minuscule et peut être suivi de lettres minuscules, de chiffres, ou de tirets bas. Exemples : `map2024` `map_openstreetmap`, etc.
+
+Le snippet reçoit ces variables :
+
+* `$params` : les paramètres du tag
+* `$block` : booléen, `TRUE` si le tag est seul sur une ligne, ou `FALSE` s'il se situe à l'intérieur d'un texte
+* `$content` : le contenu du bloc, si celui-ci est sur plusieurs lignes
+
+Exemple :
+
+```
+<>
+
+Voici un marqueur : <>
+```
+
+Dans le premier appel, `map.html` recevra ces variables :
+
+```
+$params = ['center' => 'Auckland, New Zealand']
+$content = "Ceci est la capitale de Nouvelle-Zélande !"
+$block = TRUE
+```
+
+Dans le second appel, le snippet recevra celles-ci :
+
+```
+$params = [0 => 'marker']
+$content = NULL
+$block = FALSE
+```
+
+## Fichier module.ini
+
+Ce fichier décrit le module, au format INI (`clé=valeur`), en utilisant les clés suivantes :
+
+* `name` (obligatoire) : nom du module
+* `description` : courte description de la fonctionnalité apportée par le module
+* `author` : nom de l'auteur
+* `author_url` : adresse web HTTP menant au site de l'auteur
+* `home_button` : indique si un bouton pour ce module doit être affiché sur la page d'accueil (`true` ou `false`)
+* `menu` : indique si ce module doit être listé dans le menu de gauche (`true` ou `false`)
+* `restrict_section` : indique la section auquel le membre doit avoir accès pour pouvoir voir le menu de ce module, parmi `web, documents, users, accounting, connect, config`
+* `restrict_level` : indique le niveau d'accès que le membre doit avoir dans la section indiquée pour pouvoir voir le menu de ce module, parmi `read, write, admin`.
+
+Attention : les directives `restrict_section` et `restrict_level` ne contrôlent *que* l'affichage du lien vers le module dans le menu et dans les boutons de la page d'accueil, mais pas l'accès aux pages du module.
+
+# Variables spéciales
+
+Toutes les pages d'un module disposent de la variable `$module` qui contient l'entité du module en cours :
+
+* `$module.name` contient le nom unique (`recu_don` par exemple)
+* `$module.label` le libellé du module
+* `$module.description` la description
+* `$module.config` la configuration du module
+* `$module.url` l'adresse URL du module (`https://site-association.tld/m/recu_don/` par exemple)
+
+# Stockage de données
+
+Un module peut stocker des données de deux manières : dans sa configuration, ou dans son stockage de documents JSON.
+
+## Configuration
+
+La première manière est de stocker des informations dans la configuration du module. Pour cela on utilise la fonction `save` et la clé `config` :
+
+```
+{{:save key="config" accounts_list="512A,512B" check_boxes=true}}
+```
+
+On pourra retrouver ces valeurs dans la variable `$module.config` :
+
+```
+{{if $module.config.check_boxes}}
+ {{$module.config.accounts_list}}
+{{/if}}
+```
+
+## Stockage de documents JSON
+
+Chaque module peut stocker ses données dans une base de données clé-document qui stockera les données dans des documents au format JSON dans une table SQLite.
+
+Grâce aux [fonctions JSON de SQLite](https://www.sqlite.org/json1.html) on pourra ensuite effectuer des recherches sur ces documents.
+
+Pour enregistrer il suffit d'utiliser la fonction `save` :
+
+```
+{{:save key="facture001" type="facture" date="2022-01-01" label="Vente de petits pains au chocolat" total="42"}}
+```
+
+Si la clé indiquée (dans le paramètre `key`) n'existe pas, l'enregistrement sera créé, sinon il sera mis à jour avec les valeurs données.
+
+### Validation
+
+On peut utiliser un [schéma JSON](https://json-schema.org/understanding-json-schema/) pour valider que le document qu'on enregistre est valide :
+
+```
+{{:save validate_schema="./document.schema.json" type="facture" date="2022-01-01" label="Vente de petits pains au chocolat" total="42"}}
+```
+
+Le fichier `document.schema.json` devra être dans le même répertoire que le squelette et devra contenir un schéma valide. Voici un exemple :
+
+```
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "date": {
+ "description": "Date d'émission",
+ "type": "string",
+ "format": "date"
+ },
+ "type": {
+ "description": "Type de document",
+ "type": "string",
+ "enum": ["devis", "facture"]
+ },
+ "total": {
+ "description": "Montant total",
+ "type": "integer",
+ "minimum": 0
+ },
+ "label": {
+ "description": "Libellé",
+ "type": "string"
+ },
+ "description": {
+ "description": "Description",
+ "type": ["string", "null"]
+ }
+ },
+ "required": [ "type", "date", "total", "label"]
+}
+```
+
+Si le document fourni n'est pas conforme au schéma, il ne sera pas enregistré et une erreur sera affichée.
+
+#### Propriété non requise
+
+Si vous souhaitez utiliser dans votre document une propriété non requise, il ne faut pas la fournir en paramètre de la fonction `save`.
+
+Si elle est fournie mais vide, il faut aussi autoriser le type `null` (en minuscules) au type de votre propriété.
+
+Exemple :
+
+ [...]
+ "description": {
+ "description": "Description",
+ "type": ["string", "null"]
+ }
+ [...]
+
+### Stockage JSON dans SQLite (pour information)
+
+Explication du fonctionnement technique derrière la fonction `save`.
+
+En pratique chaque enregistrement sera placé dans une table SQL dont le nom commence par `module_data_`. Ici la table sera donc nommée `module_data_factures` si le nom unique du module est `factures`.
+
+Le schéma de cette table est le suivant :
+
+```
+CREATE TABLE module_data_factures (
+ id INTEGER PRIMARY KEY NOT NULL,
+ key TEXT NULL,
+ document TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX module_data_factures_key ON module_data_factures (key);
+```
+
+Comme on peut le voir, chaque ligne dans la table peut avoir une clé unique (`key`), et un ID ou juste un ID auto-incrémenté. La clé unique n'est pas obligatoire, mais peut être utile pour différencier certains documents.
+
+Par exemple le code suivant :
+
+```
+{{:save key="facture_43" nom="Facture de courses"}}
+```
+
+Est l'équivalent de la requête SQL suivante :
+
+```
+INSERT OR REPLACE INTO module_data_factures (key, document) VALUES ('facture_43', '{"nom": "Facture de courses"}');
+```
+
+### Récupération et liste de documents
+
+Il sera ensuite possible d'utiliser la boucle `load` pour récupérer les données :
+
+```
+{{#load id=42}}
+ Ce document est de type {{$type}} créé le {{$date}}.
+ {{$label}}
+ À payer : {{$total}} €
+ {{else}}
+ Le document numéro 42 n'a pas été trouvé.
+{{/load}}
+```
+
+Cette boucle `load` permet aussi de faire des recherches sur les valeurs du document :
+
+```
+
+{{#load where="$$.type = 'facture'" order="date DESC"}}
+ {{$label}} ({{$total}} €)
+{{/load}}
+
+```
+
+La syntaxe `$$.type` indique d'aller extraire la clé `type` du document JSON.
+
+C'est un raccourci pour la syntaxe SQLite `json_extract(document, '$.type')`.
+
+# Export et import de modules
+
+Il est possible d'exporter un module modifié. Cela créera un fichier ZIP contenant à la fois le code modifié et le code non modifié.
+
+De la même manière il est possible d'importer un module à partir d'un fichier ZIP d'export. Si vous créez votre fichier ZIP manuellement, attention à respecter le fait que le code du module doit se situer dans le répertoire `modules/nom_du_module` du fichier ZIP. Tout fichier ou répertoire situé en dehors de cette arborescence provoquera une erreur et l'impossibilité d'importer le module.
+
+# Restrictions
+
+* Il n'est pas possible de télécharger ou envoyer des données depuis un autre serveur
+* Il n'est pas possible d'écrire un fichier local
+
+## Envoi d'e-mail
+
+Voir [la documentation de la fonction `{{:mail}}`](brindille_functions.html#mail)
+
+## Tables et colonnes de la base de données
+
+Pour des raisons de sécurité, les modules ne peuvent pas accéder à toutes les données de la base de données.
+
+Les colonnes suivantes de la table `users` (liste des membres) renverront toujours `NULL` :
+
+* `password`
+* `pgp_key`
+* `otp_secret`
+
+Tenter de lire les données des tables suivantes résultera également en une erreur :
+
+* emails
+* emails_queue
+* compromised_passwords_cache
+* compromised_passwords_cache_ranges
+* api_credentials
+* plugins_signals
+* config
+* users_sessions
+* logs
\ No newline at end of file
diff --git a/doc/admin/skriv.md b/doc/admin/skriv.md
new file mode 100644
index 0000000..1ad29e4
--- /dev/null
+++ b/doc/admin/skriv.md
@@ -0,0 +1,160 @@
+Title: Référence rapide SkrivML - Paheko
+
+<>
+
+# Syntaxe SkrivML
+
+Paheko propose la syntaxe [SkrivML](https://fossil.kd2.org/paheko/doc/trunk/doc/skrivml.html) pour le formatage du texte des pages du site web.
+
+## Styles de texte
+
+| Style | Syntaxe |
+| :- | :- |
+| *Italique* | `Entourer le texte de ''deux apostrophes''` |
+| **Gras** | `Entourer le texte de **deux astérisques**` |
+| Texte Souligné | `Entourer le texte de deux __tirets bas__.` |
+| ~~Barré~~ | `Deux --tirets hauts-- pour barrer.` |
+| Texte Exposant | `XXI^^ème^^ siècle` |
+| Texte Indice | `CO,,2,,` |
+
+**Attention :** ces styles ne fonctionnent que si le code entoure des mots complets, ça ne fonctionne pas au milieu de mots.
+
+```
+Un **mot** en gras. Mais on ne peut pas cou**per** un mot avec du gras.
+```
+
+> Un **mot** en gras. Mais on ne peut pas cou\*\*per** un mot avec du gras.
+
+## Titres
+
+Doivent être précédé d'un ou plusieurs signe égal. Peuvent aussi être suivi du même nombre de signe égal.
+
+```
+= Titre niveau 1
+== Titre niveau 2
+=== Titre niveau 3 ===
+==== Titre de niveau 4 ====
+```
+
+## Listes
+
+Listes non ordonnées :
+
+```
+* Item 1
+* Item 2
+** Sous-item 2.1
+** Sous-item 2.2
+*** Sous-item 2.2.1
+```
+
+Listes ordonnées :
+
+```
+# Item 1
+# Item 2
+## Sub-item 2.1
+## Sub-item 2.2
+### Sub-item 2.2.1
+```
+
+## Liens
+
+Lien interne :
+
+```
+Voir [[cette page|adresse-unique-autre-page]].
+```
+
+Lien externe :
+
+```
+[[https://paheko.cloud/]]
+```
+## Tableaux
+
+```
+!! Colonne 1 !! Colonne 2
+|| Cellule 1 || Cellule 2
+|| Cellule 3 || Cellule 4
+```
+
+## Autres
+
+Consulter la documentation complète de [SkrivML](https://fossil.kd2.org/garradin/doc/trunk/doc/skrivml.html).
+
+# Extensions
+
+Toutes les extensions se présentent sous la forme d'un code situé entre deux signes **inférieur à** (`<<`) et deux signes **supérieur à** (`>>`), à ne pas confondre avec les guillements français (`«` et `»`).
+
+## Images jointes
+
+Il est possible d'intégrer une image jointe à la page web en plaçant le code suivant sur une ligne (sans autre texte) :
+
+```
+<>
+```
+
+* `Nom_fichier.jpg` : remplacer par le nom du fichier de l'image (parmi les images jointes à la page)
+* `Alignement` : remplacer par l'alignement :
+ * `gauche` ou `left` : l'image sera placée à gauche en petit (200 pixels), le texte remplira l'espace laissé sur la droite de l'image ;
+ * `droite` ou `right` : l'image sera placée à droite en petit, le texte remplira l'espace laissé sur la gauche de l'image ;
+ * `centre` ou `center` : l'image sera placée au centre en taille moyenne (500 pixels), le texte sera placé au dessus et en dessous.
+* Légende : indiquer ici une courte description de l'image.
+
+Exemple :
+
+```
+<>
+```
+
+Il est aussi possible d'utiliser la syntaxe avec des paramètres nommés :
+
+```
+<>
+```
+
+Les images qui ne sont pas mentionnées dans le texte seront affichées après le texte sous forme de galerie.
+
+## Fichiers joints
+
+Pour créer un bouton permettant de voir ou télécharger un fichier joint à la page web, il suffit d'utiliser la syntaxe suivante :
+
+```
+<>
+```
+
+* `Nom_fichier.ext` : remplacer par le nom du fichier (parmi les fichiers joints à la page)
+* `Libellé` : indique le libellé du qui sera affiché sur le bouton, si aucun libellé n'est indiqué alors c'est le nom du fichier qui sera affiché
+
+
+# Raccourcis clavier
+
+Depuis l'édition du texte :
+
+| Raccourci | Action |
+| :- | :- |
+| Ctrl + G | Mettre en gras |
+| Ctrl + I | Mettre en italique |
+| Ctrl + T | Mettre en titre |
+| Ctrl + L | Transformer en lien |
+| Ctrl + Shift + I | Insérer une image |
+| Ctrl + Shift + F | Insérer un fichier |
+| Ctrl + P | Prévisualiser |
+| Ctrl + S | Enregistrer |
+| F11 | Activer ou désactiver l'édition plein écran |
+| F1 | Afficher l'aide |
+| Echap | Prévisualiser (rappuyer pour revenir à l'édition) |
+
+
+Depuis la prévisualisation :
+
+| Raccourci | Action |
+| :- | :- |
+| Ctrl + P | Retour à l'édition |
+
+Depuis l'aide ou l'insertion de fichier :
+
+| Raccourci | Action |
+| :- | :- |
+| Echap | Fermer et revenir à l'édition |
diff --git a/doc/admin/web.md b/doc/admin/web.md
new file mode 100644
index 0000000..e9e5684
--- /dev/null
+++ b/doc/admin/web.md
@@ -0,0 +1,96 @@
+Title: Squelettes du site web dans Paheko
+
+{{{.nav
+* [Documentation Brindille](brindille.html)
+* [Fonctions](brindille_functions.html)
+* [Sections](brindille_sections.html)
+* [Filtres](brindille_modifiers.html)
+}}}
+
+# Les squelettes du site web
+
+Les squelettes sont un ensemble de fichiers qui permettent de modéliser l'apparence du site web selon ses préférences et besoins.
+
+La syntaxe utilisée dans les squelettes s'appelle **[Brindille](brindille.html)**. Voir la [documentation de Brindille](brindille.html) pour son fonctionnement.
+
+# Exemples de sites réalisés avec Paheko
+
+* [Faidherbe Alumni](https://www.alumni-faidherbe.fr/)
+* [ASBM Mortagne](https://asbm-mortagne.fr/)
+* [Vélocité 63](https://www.velocite63.fr/)
+* [La rustine, Dijon](https://larustine.org/)
+* [Tauto école](https://tauto-ecole.net/) [(les squelettes sont disponibles ici)](https://gitlab.com/noizette/squelettes-garradin-tauto-ecole/)
+* [La boîte à vélos](https://boiteavelos.chenove.net/)
+
+# Fonctionnement des squelettes
+
+Par défaut sont fournis plusieurs squelettes qui permettent d'avoir un site web basique mais fonctionnel : page d'accueil, menu avec les catégories de premier niveau, et pour afficher les pages, les catégories, les fichiers joints et images. Il y a également un squelette `atom.xml` permettant aux visiteurs d'accéder aux dernières pages publiées.
+
+Les squelettes peuvent être modifiés via l'onglet **Configuration** de la section **Site web** du menu principal.
+
+Une fois un squelette modifié, il apparaît dans la liste comme étant modifié, sinon il apparaît comme *défaut*. Si vous avez commis une erreur, il est possible de restaurer le squelette d'origine.
+
+## Adresses des pages du site
+
+Les squelettes sont appelés en fonction des règles suivantes (dans l'ordre) :
+
+| Squelette appelé | Cas où le squelette est appelé |
+| :---- | :---- |
+| `adresse` | Si l'adresse `adresse` est appelée, et qu'un squelette du même nom existe |
+| `adresse/index.html` | Si l'adresse `adresse/` est appelée, et qu'un squelette `index.html` dans le répertoire du même nom existe |
+| `category.html` | Toute autre adresse se terminant par un slash `/`, si une catégorie du même nom existe |
+| `article.html` | Toute autre adresse, si une page du même nom existe |
+| `404.html` | Si aucune règle précédente n'a fonctionné |
+
+Ainsi l'adresse `https://monsite.paheko.cloud/Actualite/` appellera le squelette `category.html`, mais l'adresse `https://monsite.paheko.cloud/Actualite` (sans slash à la fin) appellera le squelette `article.html` si un article avec l'URI `Actualite` existe. Si un squelette `Actualite` (sans extension) existe, c'est lui qui sera appelé en priorité et ni `category.html` ni `article.html` ne seront appelés.
+
+Autre exemple : `https://monsite.paheko.cloud/atom.xml` appellera le squelette `atom.xml` s'il existe.
+
+Ceci vous permet de créer de nouvelles pages dynamiques sur le site, par exemple pour notre atelier vélo nous avons une page `https://larustine.org/velos` qui appelle le squelette `velos` (sans extension), qui va afficher la liste des vélos actuellement en stock dans notre hangar.
+
+Le type de fichier étant déterminé selon l'extension (`.html, .css, etc.`) pour les fichiers traités par Brindille, un fichier sans extension sera considéré comme un fichier texte par le navigateur. Si on veut que le squelette `velos` (sans extension) s'affiche comme du HTML il faut forcer le type en mettant le code `{{:http type="text/html"}}` au début du squelette (première ligne).
+
+## Fichier content.css
+
+Ce fichier est particulier, car il définit le style du contenu des pages et des catégories.
+
+Ainsi il est également utilisé quand vous éditez un contenu dans l'administration. Donc si vous souhaitez modifier le style d'un élément du texte, il vaux mieux modifier ce fichier, sinon le rendu sera différent entre l'administration et le site public.
+
+# Cache
+
+Depuis la version 1.3, Paheko dispose d'un cache statique du site web.
+
+Cela veut dire que les pages du site web sont enregistrées sous la forme de fichiers HTML statiques, et le serveur web renvoie directement ce fichier sans faire appel à Paheko et son code PHP.
+
+Les fichiers liés aux pages web sont également mis en cache de cette manière, en utilisant des liens symboliques.
+
+Ce cache permet d'avoir un site web très rapide, même s'il reçoit des millions de visites.
+
+## Désactiver le cache
+
+Le seul inconvénient c'est qu'une page mise en cache étant statique, si vous utilisez du contenu dynamique (par exemple afficher un texte différent selon la langue du visiteur) dans le squelette Brindille, alors cela ne fonctionnera plus.
+
+Dans ce cas-là, vous pouvez assigner la variable `nocache` dans le squelette pour désactiver le cache pour cette page :
+
+```
+{{:assign nocache=true}}
+```
+
+Pour permettre des usages du type "affichage en temps presque réel des horaires d'ouverture", le cache d'une page HTML est effacé et remis à jour au bout d'une heure.
+
+## Exceptions
+
+Il est à noter que le cache n'est pas appelé dans les cas suivants :
+
+* si la requête vers la page est d'un autre type que `GET` ou `HEAD`, ainsi par exemple l'envoi d'un formulaire (`POST`) ne sera jamais mis en cache ;
+* si la requête vers la page contient des paramètres dans l'adresse (par exemple `velos.html?list=1` : cette page ne sera pas mise en cache) ;
+* si le visiteur est connecté à l'administration de l'association. Ainsi si vous avez des parties du squelette qui varient en fonction de si la personne est connectée, le cache ne posera pas de problème.
+
+Le cache est intégralement effacé à chaque modification du site web.
+
+Le cache ne concerne que les pages et fichiers du site web public. Il ne concerne pas les modules, les extensions, ou l'administration.
+
+Attention :
+
+* avec un serveur sous Windows, le cache est désactivé car Windows ne sait pas gérer les liens symboliques ;
+* seul Apache sait gérer le cache statique, le cache est désactivé avec les autres serveurs web (nginx, etc.).
diff --git a/doc/icon.png b/doc/icon.png
new file mode 100644
index 0000000..23396ad
Binary files /dev/null and b/doc/icon.png differ
diff --git a/doc/index.md b/doc/index.md
new file mode 100644
index 0000000..02da03f
--- /dev/null
+++ b/doc/index.md
@@ -0,0 +1,213 @@
+# La gestion d'association libre et simple
+
+
+
+
+
+
+### Paheko — la gestion d'association simple
+
+**Paheko**
(anciennement appelé *Garradin*) signifie *coopérer* en *Māori*. C'est un logiciel de gestion d'association, libre, simple et efficace, développé depuis 2012. Son but est de :
+
+* **réduire le temps** passé sur les tâches administratives ;
+* re-**donner de l'autonomie aux adhérent⋅e⋅s** dans la gestion de leurs données ;
+* **simplifier la gestion administrative** de l'association, pour inciter à y participer ;
+* **minimiser le nombre de logiciels à installer et maintenir** en intégrant les outils habituels.
+
+Pour en savoir plus : [voir les principales fonctionnalités](#features).
+
+
+
+
Attention : ce site est dédié au logiciel libre Paheko.
+ Son installation, sur un serveur ou sur un ordinateur personnel, nécessite quelques compétences techniques.
+
Si votre association n'a pas ces compétences, nous recommandons l'utilisation de notre service d'hébergement : Paheko.cloud
+ (Essai gratuit , puis contribution à prix libre, à partir de 5 € par an)
+
+
+
+* [Guides d'installation](wiki:Installation)
+* [Documentation](wiki:Documentation)
+* [Entraide](wiki:Entraide)
+* Essayer gratuitement sur Paheko.cloud
+
+
+
+
+
+Soutenir Paheko en effectuant un don :-)
+
+
+
+ Rechercher
+
+ Chercher dans la documentation technique
+ Chercher dans l'aide utilisateur
+
+
+
+
+
+
+
+
+## C'est quoi ?
+
+* **100% libre :** placé sous la licence [AGPL v3](https://www.gnu.org/licenses/why-affero-gpl.fr.html).
+* Gestion des **adhérent⋅e⋅s** : fiches de membre personnalisables, recherches personnalisées…
+* Gestion des **cotisations** et **activités** : suivi des adhérent⋅e⋅s à jour, des paiements en attente, **rappels automatiques** de cotisation par e-mail, etc.
+* Envoi de **newsletters** avec suivi des adresses e-mail invalides
+* **Comptabilité** puissante (à double entrée), **simple à utiliser par les débutant⋅e⋅s** : recettes, dépenses, suivi des dettes et créances, bilan et compte de résultat annuel, **comptabilité analytique**, export PDF, **reçus fiscaux**, etc.
+* Stockage et **partage** de **documents** : édition collaborative, synchronisation des fichiers sur un ordinateur, etc.
+* Gestion du **site web** de l'association
+* Comptabilisation du **temps bénévole** et sa **valorisation**
+* Gestion de la **caisse informatisée** d'un atelier ou d'une boutique
+* Réservation de **créneaux et d'événements**
+* **Conforme au RGPD** : export des données de l'adhérent⋅e, désabonnement des e-mails, chiffrement des mots de passe…
+
+**Présentation des fonctionnalités sur le site Paheko **
+
+## Dans quels buts ?
+
+Le but est de permettre :
+
+* la gestion des __adhérent⋅e⋅s__ : ajout, modification, suppression, possibilité de choisir les informations présentes sur les fiches adhérent, envoi de mails collectifs aux adhérent⋅e⋅s
+* la tenue de la __comptabilité__ : avoir une gestion comptable complète à même de satisfaire un expert-comptable tout en restant à la portée de celles et ceux qui ne savent pas ce qu'est la comptabilité à double entrée, permettre la production des rapports et bilans annuels et de suivre au jour le jour le budget de l'association
+* la gestion des __cotisations__ et __activités__ : suivi des cotisations à jour, inscriptions et paiement des activités, rappels automatiques par e-mail, etc.
+* le travail __collaboratif__ et __collectif__ : gestion fine des droits d'accès aux fonctions, échange de mails entre membres…
+* la __simplification administrative__ : prise de notes en réunion, archivage et partage de fichiers (afin d'éliminer le besoin d'archiver les documents papier), etc.
+* la publication d'un __site web__ pour l'association, simple mais suffisamment flexible pour pouvoir adapter le fonctionnement à la plupart des besoins
+* l'__autonomisation des adhérents__ : possibilité de mettre à jour leurs informations par eux-même, ou de s'inscrire seul depuis un ordinateur ou un smartphone
+* la possibilité d'adapter aux besoins spécifiques de chaque association via des [__extensions__](wiki:Extensions).
+
+* Fonctionnalités qu'il reste à implémenter : voir [la feuille de route (roadmap)](wiki:Roadmap).
+* Paheko ne convient pas ? [Voir la liste des alternatives, libres ou propriétaires](wiki:Alternatives)
+
+## Un seul logiciel
+
+Paheko réunit en un seul outil les besoins suivants :
+
+* gestion des membres : remplace Ciel Associations, EBP, Assoconnect ou Galette ;
+* comptabilité : remplace Assoconnect, Odoo, Dolibarr, Grisbi, GNUcash, Sage, etc. ;
+* gestion et partage de fichiers, remplace NextCloud, Google Drive ou Dropbox ;
+* site web : remplace WordPress, Drupal, etc. ;
+* suivi du bénévolat : remplace Bénévalibre et les tableaux Excel.
+
+## Documentation et entraide
+
+* D'abord lire la [documentation](/wiki/?name=Documentation) et notamment la [foire aux questions](wiki:FAQ)
+* Voir la page [Entraide](/wiki/?name=Entraide) pour accéder aux listes de discussion et au salon de discussion IRC
+
+## Participer
+
+Tout coup de main est le bienvenu, pas besoin d'avoir des connaissances techniques ! Nous avons un [guide de contribution](wiki:Contribuer) pour vous aider à voir comment vous pouvez participer à Paheko :)
+
+### Développement
+
+Paheko est un logiciel libre, développé en PHP, utilisant la base de données SQLite, et avec une interface utilisant HTML, CSS et un peu de Javascript.
+
+Nous acceptons les contributions (plugins, patch, code, tickets, etc.) avec plaisir, consultez la [documentation développeur⋅euse](wiki:Documentation développeur) pour découvrir comment vous pouvez contribuer.
diff --git a/doc/selfhost2.png b/doc/selfhost2.png
new file mode 100644
index 0000000..12c6ee4
Binary files /dev/null and b/doc/selfhost2.png differ
diff --git a/doc/skrivml.html b/doc/skrivml.html
new file mode 100644
index 0000000..8021903
--- /dev/null
+++ b/doc/skrivml.html
@@ -0,0 +1,1430 @@
+
+
+
+ Skriv Markup Language
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Official Syntax
+
+
+
+
+
+
1. Introduction
+
Here is the official core of Skriv Markup 's syntax.
+
+
1.1 Version
+
The Skriv Markup syntax' current version is alpha .
+
+
1.2 Escaping
+
+ All special characters are escaped. For instance, less-than (< ), greatear-than (> )
+ and ampersand (& ) characters are transformed during HTML export (respectively to
+ < , > and & ).
+
+
+ Sometimes, it's necessary to prevent some language interpretation, when you want to display something that
+ should be used as a markup. To do so, you just have to put a backslash (\ ) character in front of the markup.
+
+
Reasoning
+
+ The backslash character is commonly used to escape sequences in text streams, in many programming languages.
+ More, this character is rarely used in normal texts, so in shouldn't conflict with any other typed characters.
+
+
+
1.3 Note about spaces
+
+ You'll see that some markups can take several parameters; in this case, the parameters are separated with a
+ pipe character (| ). You can add spaces before and/or after any parameter, they will be removed afterward.
+
+
For example, these four lines produce the exact same link:
+
[[ Skriv| http://skriv.org]]
+[[ Skriv | http://skriv.org ]]
+[[ Skriv | http://skriv.org]]
+[[ Skriv | http://skriv.org]]
+
+ In fact, spaces are removed when they are obviously optional. These two lists produce the exact same result:
+
+
* AAA
+* BBB
+
+* AAA
+* BBB
+
+ As well as these two titles:
+
+
== Level 2 title==
+== Level 2 title ==
+
+ And for these tables:
+
+
!! Head 1!! Head 2
+|| Cell 1|| Cell 2
+
+!! Head 1 !! Head 2
+|| Cell 1 || Cell 2
+
+ On the other hand, inline markups without parameters don't remove spaces. So these two lines are different:
+
+
** bold text** and '' italic text''
+** bold text ** and '' italic text ''
+
+
+
+
2. Paragraphs
+
+Out of special blocks, any text is placed inside a paragraph. Paragraphs are separated by one or more blank line.
+Inside a paragraph, line-breaks are kept as they are typed.
+
+
+
Skriv Markup
+
Here is the first paragraph.
+With two lines.
+
+Here is the second paragraph, with
+many
+carriage-returns.
+
+
Result
+
+
+ Here is the first paragraph.
+ With two lines.
+
+
+ Here is the second paragraph, with
+ many
+ carriage-returns.
+
+
+
+
HTML
+
<p>
+ Here is the first paragraph.<br />
+ With two lines.
+</p>
+<p>
+ Here is the second paragraph, with<br />
+ many<br />
+ carriage-returns.
+</p>
+
+
Reasoning
+
+
+ Paragraphs are so common that no explicit markup should be necessary to create them.
+
+
+ Keep line-breaks seems the best way to ensure a result as close as possible from what
+ the writer wants. This point of view is shared by GitHub Flavored Markdown .
+
+
+
+
+
+
3. Basic styles
+
+ Inline styles are marked up using tags before and after the affected text. These tags are constituted by two identical characters.
+ These markups can affect full-words only, they are not effective if they are written in the middle of a word.
+
+
Skriv Markup
+
works on**ly on full** words
+
Output
+
works on**ly on full** words
+
Reasoning
+
+ Most of the time, it's totally pointless to apply a style on a chuck of a word. Usually, that could lead to a misunderstanding of the writer's will.
+ Here again, this point of view is shared by
GitHub Flavored Markdown .
+
+
+
3.1 Italic
+
Skriv Markup
+
two '' single quotes'' for italic
+
Output
+
two single quotes for italic
+
HTML
+
two <em>single quotes</em> for italic
+
Reasoning
+
+ For light emphasis, the doubled-single-quotes markup is widely used (MediaWiki). The doubled-slash characters (// ),
+ used by Creole, is pretty logical; the shape of the slash character reminds the slanted aspect of italic writing.
+ But the visual impact on the text is overweening compared with the desired result.
+
+
+
3.2 Strong
+
Skriv Markup
+
two ** asterisks** for bold
+
Output
+
two asterisks for bold
+
HTML
+
two <strong>asterisks</strong> for bold
+
Reasoning
+
+ Asterisks are commonly used for strong emphasis (Creole, Markdown, txt2tags, DokuWiki). They are also used in emails since the down of time.
+
+
+
3.3 Underline
+
Skriv Markup
+
two __ underscores__ for underline
+
Output
+
two underscores for underline
+
HTML
+
two <u>underscores</u> for underline
+
Reasoning
+
+
Adding underscores before and after is the perfect markup for underlining, as they are suggesting the beginning and the end of the under line.
+
This syntax is already used by Creole and DokuWiki.
+
+
+
3.4 Strikeout
+
Skriv Markup
+
two -- minus signs-- for striking out
+
Output
+
two minus signs for striking out
+
HTML
+
two <s>minus signs</s> for striking out
+
Reasoning
+
+ The minus signs are good for striked out text, for the same reason than the underscores for underlining.
+
+
+
3.5 Monospace
+
Skriv Markup
+
two ## hash signs## for monospace
+
Output
+
two hash signs for monospace
+
HTML
+
two <tt>hash signs</tt> for monospace
+
Reasoning
+
+
Hash signs have a very special shape. Their more-or-less squared look is a valid metaphor for monospaced fonts.
+
This syntax is already used by Creole.
+
+
+
3.6 Superscript
+
Skriv Markup
+
two ^^ caret characters^^ for exponent
+
Output
+
two caret characters for exponent
+
HTML
+
two <sup>cart characters</sup> for exponent
+
Reasoning
+
+
Caret characters are like little arrowheads indicating upside direction.
+
This syntax is already used by Creole.
+
+
+
3.7 Subscript
+
Skriv Markup
+
two ,, commas,, for subscript
+
Output
+
two commas for subscript
+
HTML
+
two <sub>commas</sub> for subscript
+
Reasoning
+
+
On the contrary, commas are going under the text's baseline, thus they are suggesting to push down the text.
+
This syntax is already used by Creole.
+
+
+
+
+
4. Titles
+
4.1 Main syntax
+
+ Titles are written using one to six equal signs (= ) at the beginning of the line. One equal sign for a first level heading;
+ six equal signs for a sixth level heading.
+
+
+ For esthetical reasons, it's possible to add as many equal signs at the end of the line than at the beginning. They will be removed.
+ Spaces are allowed between the markups and the title's text.
+
+
+ On HTML output, DOM identifier should be added. It should be possible to prepend a given prefix at the beginning of these identifiers.
+
+
+
Skriv Markup
+
= Level 1 heading
+== Level 2 heading==
+=== Level 3 heading ===
+==== Level 4 heading
+
+
HTML
+
<h1 id="Level-1-heading">Level 1 heading<h1>
+<h2 id="Level-2-heading">Level 2 heading<h2>
+<h3 id="Level-3-heading">Level 3 heading<h3>
+<h4 id="Level-4-heading">Level 4 heading<h4>
+
+
Reasoning
+
+
Equals signs are a very widespread mean to write headings among wiki syntaxes (MediaWiki, Creole, txt2tags, DokuWiki).
+
It's a convenient writing, very fast to type. The visual impact is clear; with more equal signs at the beginning of the line, the title is more
+ indented, indicating a sub-header.
+
Equals signs at the end of the line are optional, because some existing wiki syntaxes made it mandatory, some others avoid them.
+
+
+
4.2 User-defined identifiers
+
+ Because two titles may have the same text, two titles may have the same DOM identifier. It could be a real problem when creating
+ links pointing to these titles (even for automatically created table of contents).
+
+
+ To circumvent this problem, it is possible to override a title identifier by writing it at the end of the line, using the balanced markup.
+
+
+ It's always possible to disable this feature, by using the usual escaping character (backslash) before the equal signs, or by using the balanced markup all the time.
+
+
+
Skriv Markup
+
=== Darth Vador
+=== Darth Vador===
+=== Darth Vador=== your father
+= Darth Vador \= your father
+= Darth Vador = your father=
+
+
HTML
+
<h3 id="Darth-Vador">Darth Vador</h3>
+<h3 id="Darth-Vador">Darth Vador</h3>
+<h3 id="your-father">Darth Vador</h3>
+<h1 id="Darth-Vador-your-father">Darth Vador = your father<h1>
+<h1 id="Darth-Vador-your-father">Darth Vador = your father<h1>
+
+
Reasoning
+
+ This syntax is the simplest way to define special identifiers, without any fancy addition.
+
+
+
+
+
5. Lists
+
5.1 Unordered lists
+
+ Unordered lists are also known as “bulleted-lists”. They are written using asterisk characters (* ), placed at the beginning of the lines.
+ There must be no space before the asterisks.
+
+
+ Sub-lists are created by typing more asterisks.
+
+
Skriv Markup
+
* Item 1
+* Item 2
+** Sub-item 2.1
+** Sub-item 2.2
+*** Sub-item 2.2.1
+
Output
+
+
+ Item 1
+ Item 2
+
+ Sub-item 2.1
+ Sub-item 2.2
+
+
+
+
+
+
+
HTML
+
<ul>
+ <li>Item 1</li>
+ <li>Item 2
+ <ul>
+ <li>Sub-item 2.1</li>
+ <li>Sub-item 2.2
+ <ul>
+ <li>Sub-item 2.2.1</li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+</ul>
+
Reasoning
+
+
Asterisks character are commonly used to create lists (MediaWiki, Creole, Markdown, reStructuredText, POD, Asciidoc, Textile, WikiWikiWeb, TWiki, MoinMoin, Redmine, ...).
+
Adding more asterisks at the beginning of the line indent the text as a result. This indentation makes clear where the sublist is.
+
+
+
5.2 Ordered lists
+
+ Items of ordered lists are displayed with an incremented number in front of them. They are written using hash signs (# ),
+ placed at the beginning of the lines. There must be no space before the hash signs.
+
+
+ Sub-lists are created by typing more hash signs.
+
+
Skriv Markup
+
# Item 1
+# Item 2
+## Sub-item 2.1
+## Sub-item 2.2
+### Sub-item 2.2.1
+
Output
+
+
+ Item 1
+ Item 2
+
+ Sub-item 2.1
+ Sub-item 2.2
+
+ Sub-item 2.2.1
+
+
+
+
+
+
+
HTML
+
<ul>
+ <li>Item 1</li>
+ <li>Item 2
+ <ul>
+ <li>Sub-item 2.1</li>
+ <li>Sub-item 2.2
+ <ol>
+ <li>Sub-item 2.2.1</li>
+ </ol>
+ </li>
+ </ul>
+ </li>
+</ul>
+
Reasoning
+
+
+ The hash sign is a character used to represent numbers, so its usage is understandable for numbered lists.
+ It's also used by many other languages (MediaWiki, Creole, Textile, Redmine, ...).
+
+
The same mechanisms than for unordered lists are applied, to keep a coherent syntax.
+
+
+
5.3 Mixed lists
+
+ It is possible to mix ordered and unordered lists. The last character used determines the list's type.
+
+
Skriv Markup
+
* Item 1
+** Sub-item 1.1
+**# Sub-item 1.1.1
+**# Sub-item 1.1.2
+** Sub-item 1.2
+*# Sub-item 1.3
+* Item 2
+
Output
+
+
+ Item 1
+
+ Sub-item 1.1
+
+ Sub-item 1.1.1
+ Sub-item 1.1.2
+
+
+ Sub-item 1.2
+
+
+ Sub-item 1.3
+
+
+ Item 2
+
+
+
HTML
+
<ul>
+ <li>Item 1
+ <ul>
+ <li>Sub-item 1.1
+ <ol>
+ <li>Sub-item 1.1.1</li>
+ <li>Sub-item 1.1.2</li>
+ </ol>
+ </li>
+ <li>Sub-item 1.2</li>
+ </ul>
+ <ol>
+ <li>Sub-item 2.2.1</li>
+ </ol>
+ </li>
+ <li>Item 2</li>
+</ul>
+
+
+
+
6. Links
+
+ Links are explicitely defined using double-square brackets ([[ ]] ). There must be at least the link destination, but it is also
+ possible to define its title first, separated from the link address with a pipe character (| ).
+
+
+ When no title is given, the address is used as the title, shorten to 40 characters.
+
+
+ By default, every outgoing links (those containing ":// " or starting with "// ") SHOULD be set with
+ a target="_blank" and a rel="nofollow" attributes. This behaviour SHOULD be configurable.
+
+
Skriv Markup
+
[[ /language/intro]]
+[[ http://www.site.com/very/long/long/looong/url/as/you/never/saw]]
+[[ Skriv Markup | http://markup.skriv.org]]
+[[ contact@skriv.org]]
+[[ Contact| contact@skriv.org]]
+
Output
+
+
HTML
+
<a href="https://markup.skriv.org/language/intro">/language/intro</a>
+<a href="http://www.site.com/very/long/long/looong/url/as/you/never/saw"
+ target="_blank" rel="nofollow">http://site.com/very/long/long/looon...</a>
+<a href="http://markup.skriv.org" target="_blank" rel="nofollow">Skriv Markup</a>
+<a href="mailto:contact@skriv.org">contact@skriv.org</a>
+<a href="mailto:contact@skriv.org">Contact</a>
+
Reasoning
+
+
+ Square brackets are commonly used to represent some kind of reference (MediaWiki, Creole, Markdown, Asciidoc, DokuWiki, TWiki, MoinMoin, ...).
+ They are doubled to follow the Skriv rationale .
+
+
+ When you read some text, the title of a link contains more direct information than its destination address.
+ That's why the title is written first, before the address, when defined.
+
+
+
+
+
+
7. Images
+
+ Images' syntax follows the links' syntax. Double-curly brackets ({{ }} ) are used instead of double-square brackets,
+ to define the image location. An optional text can be set as the image alternative representation.
+
+
Skriv Markup
+
+
{{ http://skriv.org/logo.png}}
+{{ Skriv logo| http://skriv.org/logo.png}}
+
+
HTML
+
<img src="http://skriv.org/logo.png" alt="http://skriv.org/logo.png" />
+<img src="http://skriv.org/logo.png" alt="Skriv logo" />
+
Reasoning
+
+
+ Curly brackets are like square brackets, but with a more “artistic” shape. That's enough to distinguish images from regular links.
+
+
+ Double-curly brackets are already used by DokuWiki.
+
+
+
+
+
+
8. Tables
+
+ Skriv Markup helps you to create simple tables as quickly as possible. It is not intended to
+ generate complex tables with merged cells.
+
+
+ Each table row starts with a cell delimiter. There is two kinds of cell delimiter: double-pipe (|| ) for normal cells,
+ and double-exclamation marks (!! ) for header cells. Every cell begins with a delimiter.
+
+
Skriv Markup
+
!! Header 1 !! Header 2
+|| Cell 1 || Cell 2
+|| Cell 3 || Cell 4
+
Output
+
+
+
+ Header 1
+ Header 2
+
+
+ Cell 1
+ Cell 2
+
+
+ Cell 3
+ Cell 4
+
+
+
+
HTML
+
<table>
+ <tr>
+ <th>Header 1</th>
+ <th>Header 2</th>
+ </tr>
+ <tr>
+ <td>Cell 1</td>
+ <td>Cell 2</td>
+ </tr>
+ <tr>
+ <td>Cell 3</td>
+ <td>Cell 4</td>
+ </tr>
+</table>
+
Reasoning
+
+
+ The major goal with the table syntax was to provide a convenient way to create tables, without doing
+ ascii-art . That's the reason why there is only two
+ type of cells, no merged cells, and a very simple layout.
+
+
+ Pipes are the natural characters to create vertical separations between elements. It's perfect for table representation.
+ Exclamation marks have a similar shape, with a subtle difference to indicate headers.
+
+
+
+
+
+
9. Horizontal rules
+
+ Horizontal rules are done by putting at least four minus signs on the same line, without anything else
+ on the line. There must be no space before the minus signs.
+
+
Skriv Markup
+
----
+
Output
+
+
+
+
HTML
+
<hr />
+
Reasoning
+
+ The four minus signs markup represent a line, like an horizontal separator in the page. It is used by a lot of other systems
+ (Creole, Markdown, reStructuredText, WikiWikiWeb, TWiki, ...).
+
+
+
+
+
10. Quotes
+
+ To write a quote, add a right angle bracket (> ) at the beginning of each line.
+ If you need many paragraphs inside the quote, just mark blank lines as well.
+
+
Skriv Markup
+
> Quotes use right angle brackets.
+>
+> Paragraphs are managed as usual.
+> Line-breaks too.
+
Output
+
+
+ Quotes use right angle brackets.
+
+ Paragraphs are managed as usual.
+ Line-breaks too.
+
+
+
+
HTML
+
<blockquote>
+ <p>
+ Quotes use right angle brackets.
+ </p>
+ <p>
+ Paragraphs are managed as usual.<br />
+ Line-breaks too.
+ </p>
+</blockquote>
+
Reasoning
+
+ The right angle bracket character is a convenient mean to separate a quote from the rest
+ of the text (already used in Markdown).
+
+
+
+
+
+
+ Footnotes are used to add internal links inside a document, referencing some chunks of text that should
+ be listed at the bottom of the page.
+
+
+ They are defined using double-parenthesis ((( )) ), where the footnote is referenced. Usually, footnotes
+ are identified by an incremented number; if you want to use a label in place of that number, write it first,
+ separated from the footnote's text by a pipe character (| ).
+
+
Skriv Markup
+
Arthur C. Clarke is one of the best SF author (( just after Issac Asimov)) .
+
+The Ford T is the car of the century (( Source| ahead Mini and Citroën DS, see
+[[Wikipedia|http://en.wikipedia.org/wiki/Car_of_the_Century]])) , but everybody knows
+it should be the VW Beetle (( [[Wikipedia|
+http://en.wikipedia.org/wiki/Volkswagen_Beetle]])) .
+
Output
+
+
+ Arthur C. Clarke is one of the best SF author 1 .
+
+
+ The Ford T is the car of the century Source ,
+ but everybody knows it should be the VW Beetle 3 .
+
+
+
+
HTML
+
Arthur C. Clarke is one of the best SF author <sup><a href="#cite_note-1"
+id="cite_ref-1">1</a></sup>.
+
+The Ford T is the car of the century <sup><a href="#cite_note-2"
+id="cite_ref-2">Source</a></sup>, but everybody knows it should be the VW Beetle
+<sup><a href="#cite_note-3" id="cite_ref-3">3</a></sup>.
+
<div class="footnotes">
+ <p class="footnote"><a href="cite_ref-1" id="cite_note-1">1</a>. juste after
+ Isaac Asimov</p>
+ <p class="footnote"><a href="cite_ref-2" id="cite_note-2">Source</a>. ahead Mini
+ and Citroën DS, see <a href="http://en.wikipedia.org/wiki/Car_of_the_Century">Wiki
+pedia</a></p>
+ <p class="footnote"><a href="cite_ref-3" id="cite_note-3">3</a>. <a
+ href="http://en.wikipedia.org/wiki/Volkswagen_Beetle">Wikipedia</a></p>
+</div>
+
Reasoning
+
+
+ Parenthesis are used to write secondary content that it not crucial to the meaning of a text.
+ Using double-parenthesis indicates that footnotes are like “tertiary content”, even less important
+ so it can be put down at the end of the page.
+
+
+ Double-parenthesis are already used by DokuWiki.
+
+
+
+
+
+
12. Abbreviations
+
+ Abbreviations are written using double-question marks (?? ). The abbreviation and its explaination
+ are separated by a pipe character (| ).
+
+
Skriv Markup
+
?? EFF| Electronic Frontier Foundation??
+
Output
+
+
HTML
+
<abbr title="Electronic Frontier foundation">EFF</abbr>
+
Reasoning
+
+ Question marks are the symbol for interrogation and help. The purpose of this markup is
+ to provide some explaination about a term, so it fit very well.
+
+
+
+
+
+
+ Preformatted texts are blocks of text rendered using monospace font. Line breaks are kept.
+ Skriv syntax is still interpreted.
+
+
+ To create a preformatted text block, just add a space character at the beginning of each line
+ of the block. In fact, once space is enough, but you can put as many spaces as you want.
+
+
Skriv Markup
+
At least one space at the beginning of each
+ line is enough to create a preformatted paragraph.
+
+ Skriv syntax **works**.
+
Output
+
+
At least one space at the beginning of each
+line is enough to create a preformatted paragraph.
+
+Skriv syntax works .
+
+
HTML
+
<pre>
+At least one space at the beginning of each
+line is enough to create a preformatted paragraph.
+
+Skriv syntax <strong>works</strong>.
+</pre>
+
Reasoning
+
+
+ Adding space characters at the beginning of the line is the fastest way to define a style
+ on a block of text. By indenting it from regular text, it gives a special meaning to the text.
+
+
+ MediaWiki already uses this writing to create preformatted text blocks.
+
+
+
+
+
+
14. Verbatim text
+
+ Verbatim text are preformatted text blocks without any interpretation.
+ They are written using triple-square brackets ([[[ ]]] ).
+
+
Skriv Markup
+
[[[
+Triple square brackets also create
+preformatted paragraphs.
+
+**Skriv** syntax is __not__ interpreted.
+]]]
+
Output
+
+
Triple square brackets also create
+preformatted paragraphs.
+
+**Skriv** syntax is __not__ interpreted.
+
+
HTML
+
<pre>
+Triple square brackets also create
+preformatted paragraphs.
+
+**Skriv** syntax is __not__ interpreted.
+</pre>
+
Reasoning
+
+ The angular shape of square bracket characters is a good reminder of verbatim
+ texts' aspect (separate block, monospace font). The usage of opening and closing
+ square brackets is perfect to mark the block's boundaries.
+
+
+
+
+
15. Code
+
+ As for verbatim text, Skriv syntax is not interpreted inside code blocks.
+ The difference between a verbatim text block and a code block is the presence
+ of the programming language's name after the three opening square brackets
+ (there could be spaces between the brackets and the language's name, but it's
+ not a mandatory).
+
+
+ Syntax highlighting is let to the interpreter will.
+
+
Skriv Markup
+
+
[[[ php
+class Foo {
+ public function show() {
+ print("bar\n");
+ }
+}
+]]]
+
+
Output
+
+
class Foo {
+ public function show() {
+ print("bar\n");
+ }
+}
+
+
HTML
+
+
<pre><code class="language-php">
+class Foo {
+ public function show() {
+ print("bar\n");
+ }
+}
+</code></pre>
+
+
Reasoning
+
+
+ Since code blocks content is not interpreted, they are only a special case of verbatim text blocks.
+ So it's logical to keep usage of triple-square brackets.
+
+
+ Code blocks are rendered as a <code> inside a <pre> tag, because it's the
+ recommended
+ method in HTML 5 . When syntax highlighting is activated, this markup is not a mandatory.
+
+
+
+
+
+
16. Styled paragraphs
+
+ This syntax is a convenient way to apply some CSS styles to paragraphs. Styled blocks are
+ written between triple-curly brackets ({{{ }}} ). All paragraphs
+ inside will inherit the specified styles. CSS classes are typed just after the opening brackets.
+
+
+ It is possible to nest styled paragraphs. Just add triple-curly brackets-enclosed blocks inside
+ existing ones. For a better readability, you can add any number of spaces and/or curly brackets
+ before the CSS class names; the sole obligation is to start the lines with three opening curly
+ brackets and three closing curly brackets.
+
+
Skriv Markup
+
+
{{{ css_class1 css_class2
+CSS classes apply to all paragraphs.
+
+Whatever their ''number''.
+
+{{{ {{{ css_class3
+This paragraph has the **3 CSS classes**.
+}}} }}}
+}}}
+
+
HTML
+
<div class="css_class1 css_class2">
+ <p>
+ CSS classes apply to all paragraphs.
+ </p>
+ <p>
+ Whatever their <em>number</em>.
+ </p>
+ <div class="css_class3">
+ <p>
+ This paragraph has the <strong>3 CSS classes</strong>.
+ </p>
+ </div>
+</div>
+
Reasoning
+
+ Curly brackets are undeniably bound to CSS syntax. Every web developers have a mental
+ connection between CSS instructions and curly brackets.
+
+
+
+
+
17. Smileys & Symbols
+
+ Skriv Markup provide helper scriptures, for adding special characters that are not easy to write with a regular keyboard.
+ These characters are useful to add small illustrations to the text.
+
+
+
17.1 Smileys
+
+
+ :-)
+ ☺
+ :-(
+ ☹
+ :-D
+ 😃
+ :-p
+ 😋
+
+
+ :-|
+ 😐
+ ;-)
+ 😉
+ :-o
+ 😲
+ :-x
+ 😶
+
+
+ :'-(
+ 😥
+ :-@
+ 😠
+ :-*
+ 😘
+
+
+
+
+
17.2 Symbols
+
+
+ :sun:
+ ☀
+ :cloud:
+ ☁
+ :umbrella:
+ ☂
+ :star:
+ ★
+
+
+ :phone:
+ ☎
+ :check:
+ ✔
+ :mark:
+ ✖
+ :cross:
+ ✚
+
+
+ :skull:
+ ☠
+ :atom:
+ ⚛
+ :radioactive:
+ ☢
+ :biohazard:
+ ☣
+
+
+ :moon:
+ ☽
+ :square:
+ ■
+ :circle:
+ ●
+ :triangle:
+ ▲
+
+
+ :arrow:
+ ➔
+ :arrowhead:
+ ▶
+ :bullet:
+ ◉
+ :love:
+ ♥
+
+
+ :heart:
+ ♥
+ :spade:
+ ♠
+ :diamond:
+ ♦
+ :club:
+ ♣
+
+
+ :note:
+ ♩
+ :recycle:
+ ♻
+ :flag:
+ ⚑
+ :scale:
+ ⚖
+
+
+ :warning:
+ ⚠
+ /!\
+ ⚠
+ :peace:
+ ☮
+ :yinyang:
+ ☯
+
+
+ :clock:
+ ⌚
+ :hourglass:
+ ⌛
+ :command:
+ ⌘
+ :enter:
+ ⎆
+
+
+ :infinity:
+ ∞
+
+
+
+
+
+ :dice1:
+ ⚀
+ :dice2:
+ ⚁
+ :dice3:
+ ⚂
+ :dice4:
+ ⚃
+
+
+ :dice5:
+ ⚄
+ :dice6:
+ ⚅
+
+
+
+
+
+ :_1/2_:
+ ½
+ :_1/3_:
+ ⅓
+ :_2/3_:
+ ⅔
+ :_1/4_:
+ ¼
+
+
+ :_3/4_:
+ ¾
+ :_1/5_:
+ ⅕
+ :_2/5_:
+ ⅖
+ :_3/5_:
+ ⅗
+
+
+ :_4/5_:
+ ⅘
+
+
+
+
+
+ :_1_:
+ ➊
+ :_2_:
+ ➋
+ :_3_:
+ ➌
+ :_4_:
+ ➍
+
+
+ :_5_:
+ ➎
+ :_6_:
+ ➏
+ :_7_:
+ ➐
+ :_8_:
+ ➑
+
+
+ :_9_:
+ ➒
+ :_10_:
+ ➓
+ :_11_:
+ ⓫
+ :_12_:
+ ⓬
+
+
+ :_13_:
+ ⓭
+ :_14_:
+ ⓮
+ :_15_:
+ ⓯
+ :_16_:
+ ⓰
+
+
+ :_17_:
+ ⓱
+ :_18_:
+ ⓲
+ :_19_:
+ ⓳
+ :_20_:
+ ⓴
+
+
+
+
+ :_A_:
+ Ⓐ
+ :_B_:
+ Ⓑ
+ :_C_:
+ Ⓒ
+ :_D_:
+ Ⓓ
+
+
+ :_E_:
+ Ⓔ
+ :_F_:
+ Ⓕ
+ :_G_:
+ Ⓖ
+ :_H_:
+ Ⓗ
+
+
+ :_I_:
+ Ⓘ
+ :_J_:
+ Ⓙ
+ :_K_:
+ Ⓚ
+ :_L_:
+ Ⓛ
+
+
+ :_M_:
+ Ⓜ
+ :_N_:
+ Ⓝ
+ :_O_:
+ Ⓞ
+ :_P_:
+ Ⓟ
+
+
+ :_Q_:
+ Ⓠ
+ :_R_:
+ Ⓡ
+ :_S_:
+ Ⓢ
+ :_T_:
+ Ⓣ
+
+
+ :_U_:
+ Ⓤ
+ :_V_:
+ Ⓥ
+ :_W_:
+ Ⓦ
+ :_X_:
+ Ⓧ
+
+
+ :_Y_:
+ Ⓨ
+ :_Z_:
+ Ⓩ
+
+
+
+
+
+
+
18. Extensions
+
+ Skriv Markup has an extension mechanism, designed to add new features to the language without
+ modifying its basic syntax. Extensions may also be called “plugins”.
+
+
+ Extensions are validated before becoming a part of the official Skriv Markup syntax. Official extensions
+ SHOULD be available in any Skriv Markup implementation, but they COULD be deactivated at will.
+ Unofficial extensions MAY be activated at will.
+
+
+ Look at the page about extensions to get the list of validated plugins.
+
+
+
18.1 Inline
+
+ Inline extensions are written using double-angle brackets (<< >> ). The extension's
+ name must be written just after the opening brackets. If the plugin needs some parameters, they are
+ added just after, separated by pipe (| ) characters.
+
+
+ Here is an example plugin, which goal is to insert the given number of “lorem ipsum” paragraphs.
+
+
Skriv Markup
+
<< lipsum | 2>>
+
HTML
+
<p>
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque leo sem,
+ commodo quis tempus luctus, tempor vel libero. Phasellus rutrum ipsum accumsan
+ mauris gravida nec ultricies ante mattis. Ut et nibh neque.
+</p>
+<p>
+ Praesent pulvinar rhoncus tincidunt. Ut eleifend sollicitudin nibh sed
+ porttitor. In hac habitasse platea dictumst. Etiam bibendum mi id ligula
+ blandit pulvinar et quis neque. Duis sagittis tempus tellus at rutrum.
+</p>
+
Reasoning
+
+ Angle brackets are not used for any other element of the Skriv Markup syntax.
+ However, they are immediately identified as special markup, because of their usage in HTML.
+
+
+
+
18.2 Block
+
+ Block extensions are very similar to inline ones. They are delimited by triple-angle brackets
+ (<<< >>> ), with the name and possible options just behind the opening
+ brackets.
+
+
+ Here is an example plugin, which translates the given text. The first parameter is the input language,
+ the second parameter is the output language.
+
+
Skriv Markup
+
<<< translate | french| english
+écrire, travailler, organiser
+>>>
+
HTML
+
<p>
+ write, work, organize
+</p>
+
Reasoning
+
+ Same reasoning than for inline extensions. Triple-characters are used for blocks as usual.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/manifest b/manifest
new file mode 100644
index 0000000..1229d16
--- /dev/null
+++ b/manifest
@@ -0,0 +1,766 @@
+-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA512
+
+C Fix\squote\sin\slist
+D 2024-01-06T13:45:04.578
+F .editorconfig e6f131bd881371738fbc49cd771c470959e14df4b5baa398db781f21e2f98b34
+F .fossil-settings/allow-symlinks 5a9cb4b1795fdc8982e907994a2e80eca49b6daf329c826d86903016391506ce
+F .fossil-settings/ignore-glob 962019af50eb59ad515b3bdf7d4bc04b7a982a4b410d1d669a9561c095388772
+F .fossil-settings/manifest 5a9cb4b1795fdc8982e907994a2e80eca49b6daf329c826d86903016391506ce
+F .travis.yml 4da8c0eef1bdf524e1ad6cf8e7bfe3d4d26a680a
+F COPYING 78e50e186b04c8fe1defaa098f1c192181b3d837
+F README.md 827da5e24a451ad78aeff512e83128a3d23132cc34e00e2690bfa29668f08ebb
+F SECURITY.md 58d2d41ec6509b4991e4ebcf46935ba1f33b568afc6e587df4a2d7ae703649f4
+F archives/0.7.0_migration.sql acaa57e89553e8763fcbc2bcb50aa6a18b7327ad
+F archives/0.7.2_migration.sql ba4b5fbcc7a56b971532d8fef20aba41e5c963ad
+F archives/0.8.0_migration.sql b56b9689504b83b8ae279a5c5c42ce05e423d883
+F archives/0.8.0_schema.sql 1c0ae41b79ce190843cc88a7d65f5bce65a0ca96
+F archives/0.8.3_migration.sql 78f64dc82033abd49d4156b013c78828a795d326
+F archives/0.8.3_schema.sql 80be656e5dde9e43cc7027d0ba6eb14b26ff3e7b
+F archives/0.8.4_migration.sql 107bec2b2d0f7c958a87e71e3b2c9da3246b6762
+F archives/0.9.0_migration.sql 90ab72f7f4dcb6d018df98790c3407e366881155
+F archives/0.9.0_schema.sql 9f23495ff41c8810fee101826f74c80d44f7f56f
+F archives/0.9.1_migration.sql 6227606b97cd3aa1296c0c2e7b884ae0b6aa2085
+F archives/0.9.1_schema.sql efe750515a9909e2ee06db77bc97f005a2f8d923
+F archives/0.9.5_schema.sql c8df01c2dd61ee0516f89fa2bfb78e5f208550e9
+F archives/1.0.0_schema.sql 292ae067786851c89ca00bff59e08a12a43d40fd5f64ff85a313477e8007485f
+F archives/plan_comptable.json c4962464667bb992c37d3b7363f80de0eeac9ad7
+F build/debian/config.debian.php f3119a87085bb5fedbceca0c206442c3cfe49e14265125d48e5f2c5a23121d00
+F build/debian/makedeb.sh dd8d4f904f9162b7ba0ef81bbc88514fb61803e2c8fb1c257c36a1f48528b2b6 x
+F build/debian/manpage.txt 81ca70dd7d81d2e4578de5317057f03581326a7db15eca4a90d3d9885f5f3902
+F build/debian/paheko a6de924cf5669b8d9c6db5277a6a74e26d5c9e76b388f338c8a11354fc4a4484 x
+F build/debian/paheko.desktop f020895fffdbed4773f6cf9cfc99e6695ccf35084e80e8a2593167390e12d647
+F build/debian/paheko.menu f93ffda8edce658a00928bb52e7757ca8ce3a17dc1e28944486b73d6fe4781d4
+F build/debian/paheko.png e538df2fa2ebd8b873c77c8e71c1daad3cdc898901d8f06a14966eb655f3d5ab
+F build/windows/Makefile 7b464ebe758771ad301e07cffcbd6dfe04c771a5562098c5bcaa3c5438b3442c
+F build/windows/README.md 5c56cc2aa07f74d5b6f78899af2fd482b19d9f35803f1c9cdfc863a0868fbd8a
+F build/windows/config.local.php f722b32a30b33980e301d264c3224b6fa4e5a4dbbe98cc5956fbee099b8584f9
+F build/windows/launch.bat 4318d01c86cb116035345ea2bfefe584da2bc56520a4bb51e3a282377cce399d
+F build/windows/paheko.ico 969b2f968c7bc5f8474f2fbdbadf515be1ed1798da2fd845faf6f42240f0fece
+F build/windows/paheko.nsis 8f73d399b04c544b5d070fd821fdfbabbbafb861fae761826fba2d837b0e521c
+F build/windows/php.ini 43ac89d6777fa71801cac6eb1d6515ce5f3bd460b022f328b75df0c27f2313be
+F doc/admin/api.md 891b1aa042dd0c03ebdf4f9800de25bfd9d3f5c0565e1b084d3773c6eabb32e5
+F doc/admin/brindille.md 8edc24b7ae8a3199365094300f7ba4f3fbc5575f8dcfc0872f9fafebee339470
+F doc/admin/brindille_functions.md 227ccbad97ed1d0f0662624a8d57041bad12627c5746a5f5c8ede286e70558b2
+F doc/admin/brindille_modifiers.md 5847dbdabd1a426f17a00c3a357c1ed4917e93dc7e61ec0d8e1ba3416329a3b1
+F doc/admin/brindille_sections.md 6ef603f2600a7a22da271140537e226fe570bc6ea887539a86f82d309c5ccbd8
+F doc/admin/keyboard.md e9a85dcaa57bac91a920bfb4cfb595c580f4059f46b4f56b03e662fb79eb8d33
+F doc/admin/markdown.md 61cfd293d2831310e6a1ed051eff57db446a5658bb47edab1a18ebc4cd21f003
+F doc/admin/markdown_quickref.md 6205777c597575cb8d3750325251ef59352ac4daa8489f8e75cf518a6eb3107d
+F doc/admin/modules.md 8bbc7e0c4735fc0dd7b3c7a15e30d4dbf3484b0c64198f105a5d5dd24dc3d7d1
+F doc/admin/skriv.md 840d985008180b189986c8006760b2945e5e55138429c44b0240bd86d5936a1f
+F doc/admin/web.md 9859ee4f6c0b5eb464fe16a5b425473505fcacf015e5ac9c11caaf07bfe2d131
+F doc/icon.png 13439ec7073ffb0bfdca7062ad90755961109b66ac9bf49179f6a2dd07f6a378
+F doc/index.md 54ac4b8f732466b2a46be9d05738300665ef98d8d63a43c1e5055d207794cdd7
+F doc/selfhost2.png 701da39174d67cc10da6b71fade8e1087d3c8b07e62e90b5b03ac7438031affd
+F doc/skrivml.html b749ec40a9ef7f210f12108774b9e0f25c9a6c51fc3b1e6cc452b630a258aa41
+F src/.htaccess.www 1fa848cf6a83049ffacd5db68281f6d2374a6a119ead4c6a3b7977c311e7ae68
+F src/Makefile 2c0887f6862f285e1b11eaacb2bf984eeca725d02e97c0f937136122767fa559
+F src/VERSION 586e3ccefcb1c33d7bbb9d9a5d045ac5237b4dde29cc64134e952cdf5206dbf3
+F src/apache-htaccess.conf de974946e40c9da0a3ad98c7dee430c15297d344d499969ad0be272114db1c62
+F src/apache-vhost.conf 04ec124c9b6632a8a638e78e63a5631b3638aa55cce07d18063ffd0d8970e5fe
+F src/config.dist.php dd2986806a989758fd41c70f1c1bd9ff8ef4ce9af9d49f56f446536cc7b04ba9
+F src/data/index.html abe89f9bfb756bbdfb2f535420e10bb5625eb4e2
+F src/include/data/1.1.0_schema.sql ab4753bbe0e71c8e5910b94a4c73243191543e942a62304032a77f2060b130ab
+F src/include/data/1.1.21_migration.sql 6a26982b556b4e91cab24c577b94b0d621cbcc6042aea0d1aa220bdb2126c7c0
+F src/include/data/1.1.25_migration.sql 72b38ceb6c907083d59552efee8613a8bde94b87ada1503688c4f08c20f90ad2
+F src/include/data/1.1.29_migration.sql 93d8b01db404c3a0adeaa01a26c5dac5cae0deefbbb102d14d727e7c8c204d95
+F src/include/data/charts/be_pcmn_2019.csv 3f6549390acc521c8617b5f4cb67f5b33d528b75e281f739a4c76d9de6f9d26e
+F src/include/data/charts/ch_asso.csv e384d6a13097e16163dcb6e0be0bcf445e11921df085ca978726b0d51ce93987
+F src/include/data/charts/fr_cse_2015.csv 5386feeb8aac9533d0bd87781aab280db9738dcff79e27fac717d6b2a8a059d9
+F src/include/data/charts/fr_pca_1999.csv cce600ccba024cfa5468101a08171d474a4cfdc7bbc1a3ea7b89bd4aebf27508
+F src/include/data/charts/fr_pca_2018.csv b4e1b59a9637ca99f8700fb52f2ed8528b9ecbbe8097b43d3bce4ac38a94aea4
+F src/include/data/charts/fr_pcc_2020.csv 2b88ba109a5724c43cb2e16260f791d6583e9848ef928983cc8977c80c94b0e8
+F src/include/data/charts/fr_pcg_2014.csv a3ec7658abf73ff2704af6f51dfdc4e3e202c4df4e0e1360cce2e1aa55343aad
+F src/include/data/charts/fr_pcs_2018.csv 301907d458e9351c416a793915bf1276ecd63b1dcd0db48fe8c6f937b4522292
+F src/include/data/dictionary.fr d3245db784b299707cf47379ab48d82dd75439a3c5a77bfdf33f5c54408a3548
+F src/include/data/schema.sql 57116110a23d96f4be9c8593b2e031930774e4234783612ba48ac1b4f3d17735 l
+F src/include/data/users_fields_presets.ini c53432e075f004bbf156dd8d15194ade6af69cc4b7be8b7b1ed5ac8c82567b23
+F src/include/init.php 764d5f963e25fd6febd55c3cc2195930dc33d2af4f1dc2921c5a7513e5a23773
+F src/include/lib/Paheko/API.php 6f1b24a15426ba1479465312cc27c583ed653ee0f3d3a971c9450472c61b7173
+F src/include/lib/Paheko/API_Credentials.php b8b75e5569b842f419eb1654163c4edf135a7e02e95b99b27c0d5a1874ec853e
+F src/include/lib/Paheko/Accounting/Accounts.php f427cf5c529aec71faa83dd1809f7adb4b95414f2dcf636e448b3102c331d01b
+F src/include/lib/Paheko/Accounting/AdvancedSearch.php 4dcebe68b436d34d394b4b69edd1ba5f878fd99f737862eac8e29f5a63323973
+F src/include/lib/Paheko/Accounting/AssistedReconciliation.php c253756b1663c63a84a18c8efc6b23f05ed3e2bcf65751fee0de04064d34d1d6
+F src/include/lib/Paheko/Accounting/Charts.php aa097f23d218a57e4652b6bdf9414035027ea51ef5c4cfaadd4eb6578d6f0538
+F src/include/lib/Paheko/Accounting/Export.php 4f4ad7a47258d6e30b9be35d62b2d948af7484aafdf359532997f7c31b0f99c7
+F src/include/lib/Paheko/Accounting/Graph.php 50db85fd4c4f4a4ac6e01d01af3f8cf7f368c73d1b3027ef32928527a6a2c1e6
+F src/include/lib/Paheko/Accounting/Import.php 5dee4748b224b48309c3319bdf8f82bccd3f180dde92e71e138adfd9f96918b0
+F src/include/lib/Paheko/Accounting/Projects.php f2c156faf2f4678103a432d468528b8c7d693de507de5148a2c18d7cc7c489f7
+F src/include/lib/Paheko/Accounting/Reports.php e71e9321d9b54bf6eac5962ccd189c61bb3afb9827974d5e00f6d6a7d03b1a7d
+F src/include/lib/Paheko/Accounting/Transactions.php bed5cfe0ccdf38f199b1f2ad18c29283412a4deb88ca5ea72aa3d970274f2e06
+F src/include/lib/Paheko/Accounting/Years.php 6d8166387a20f437c5910dea43a5b4a903f4ef96ea2e574f3505fab7641da6f2
+F src/include/lib/Paheko/AdvancedSearch.php 5a7f7c2a3842b676c2599db47b0a7ef81df957b1bc763d588adb66e09c2532ee
+F src/include/lib/Paheko/Backup.php 8f8f3823f453908e3b5380f749294e58f65e8e18a9c60e48e944f7b6a053f435
+F src/include/lib/Paheko/CSV.php 8482fbd483951db6b12f0ed14e70eb99adb93fd2e96bbd922d6742fa6186d14c
+F src/include/lib/Paheko/CSV_Custom.php a30a154c6fca07d4217f123c3a9254d5e3d159975063306d6eedbb665ca9f7f7
+F src/include/lib/Paheko/Config.php 38a8dfd9db963ef77cb4373975d2dea83b0a4df73558b9c5d58799a644d217d6
+F src/include/lib/Paheko/DB.php 8ed15d427fb7b21be61c115c7d9c0bd5c4db644c75d34c1514292b65676be879
+F src/include/lib/Paheko/DynamicList.php d40bde39fc4a1c30cdfa1c9ec7f5ad3e3fd91bea144298cd1a65017c18ba0341
+F src/include/lib/Paheko/Email/Emails.php e36a3236d0c9bd47509658c1e112ed58bb708125722aaa14ca8f5a47224fefd1
+F src/include/lib/Paheko/Email/Mailings.php 5286e9dc482beb953c237a6def36f3fbf5f026e09209c245771a732220b67fb2
+F src/include/lib/Paheko/Email/Templates.php 27a909c9c9e9f261797769097f93b11fae7a1304d940b21e07a931b3f15b1d01
+F src/include/lib/Paheko/Entities/API_Credentials.php af3482891c625853a9f38fe2a17f7deb9f6b51fc0eb2539c3c2f9391e04e8e68
+F src/include/lib/Paheko/Entities/Accounting/Account.php a9dc45d2237aaa00c86e2fdb470f757e92cd24cd6d134fa50aa1b4b3edfa1988
+F src/include/lib/Paheko/Entities/Accounting/Chart.php d7fb7ded59d304eb99d6b8fc4985bf73b6fd8f978e5a05963cb444ab7d2a7280
+F src/include/lib/Paheko/Entities/Accounting/Line.php 473fcecdd3c22800106cfb733ea9105066ae0184b2e6ffa8587900c5cde77bfe
+F src/include/lib/Paheko/Entities/Accounting/Project.php f221f56feec52d958a4df736b080dce528af7ac466ec799967365619b5fefed5
+F src/include/lib/Paheko/Entities/Accounting/Transaction.php 877151f889d9aeb63a22953d31c42113f961c90b7445d7b1727ad575e081fd70
+F src/include/lib/Paheko/Entities/Accounting/TransactionLinksTrait.php 9e8c62dbd3fb808b20412535d065279e57edeb0b3bca78f52747598a70f1a934
+F src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php 6d7d38b625e1703c495fcf4bd3ec1c2cc273eb5942ce9b93a7d175bbfad5ab83
+F src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php 5038ed021fba4e56bc8e6be768283f12f5e6881e6c97b8f37762e5d694a922ca
+F src/include/lib/Paheko/Entities/Accounting/Year.php bd9796c31856f5ec40806784cead891c567683445f77c58496c2f286c403a3bb
+F src/include/lib/Paheko/Entities/Email/Email.php e24405674c55b378a3fe4bea2ee57f1449049c74ec6352ce50a4a80649221fbd
+F src/include/lib/Paheko/Entities/Email/Mailing.php e71284ec734199c060f27709e60227517a324e893ad7e7b7542f8143d8bf27fa
+F src/include/lib/Paheko/Entities/Email/Message.php ba4dc91b79edc17533840b3af57f8e3b4cf1159f85722b5027cacd1915091339
+F src/include/lib/Paheko/Entities/Files/File.php 2e4dbd2fd6b30a7704eaf85fb116b702553971112cb9a827f38bb0c2a3d9ed31
+F src/include/lib/Paheko/Entities/Files/FilePermissionsTrait.php e5ac5707517af5932bcf286c1c95159f2d17d7529a9af842f8c860d76cd11c0d
+F src/include/lib/Paheko/Entities/Files/FileThumbnailTrait.php 6608d4caab67ba703c467ee836f5346f91ea476276a330d6797db01e283acf81
+F src/include/lib/Paheko/Entities/Files/FileVersionsTrait.php 6fc878b2fb66bb02ccfd4afa945756a9bdec0613d310d731d2b91b3e297a9a77
+F src/include/lib/Paheko/Entities/Module.php 2bfdedeb7adee9579401ad5e3f78db292d43a135dc01b4a7c79a4798986735d9
+F src/include/lib/Paheko/Entities/Plugin.php 5ba82415019306b83727f62460c57fc9f1475528cab3074fb13909e138f29d9c
+F src/include/lib/Paheko/Entities/Search.php 9502b9219a5b40647651732fc2f56bfb0049c0f12bc3a025c7d710ab909394ef
+F src/include/lib/Paheko/Entities/Services/Fee.php 3116155735de6a5f0e7649c40da9b789daa196d970e4523b091074e0d875d7b0
+F src/include/lib/Paheko/Entities/Services/Reminder.php 51bb91ad7ac4d4804f887382c4c5a7ff58bab5eccfd5a44f06bb707fc505f613
+F src/include/lib/Paheko/Entities/Services/ReminderMessage.php fbaef97c819df828721440d5fc47cabe2a1d23a26fb4dbbd2537516a4cba132b
+F src/include/lib/Paheko/Entities/Services/Service.php 9de625b4c8ca882ebbb552552255d00ecf3315429a1bba631f2767aa94e096d7
+F src/include/lib/Paheko/Entities/Services/Service_User.php 40f2eaf44b60d3ffb5cc8afe9902593dad11c1edc72a4e0a32a7430db2c6967b
+F src/include/lib/Paheko/Entities/Signal.php a5e7ac2ccc7fbdcacdde3769249f0d267012785acf1362592939730c2c9c66be
+F src/include/lib/Paheko/Entities/Users/Category.php 6413ce3b9175ed74a30c4cd685737b19e61ed959837dcd8e6b125d8dd3cec62b
+F src/include/lib/Paheko/Entities/Users/DynamicField.php 082994b2d2c2314269321886289edbd7fbf6ac68f2f1fe2b2ccc727d5f7420c3
+F src/include/lib/Paheko/Entities/Users/User.php e3c30ce20e15325c815586e66ae3385097309ea1a90faae1258ce7be963d9bd2
+F src/include/lib/Paheko/Entities/Web/Page.php 42fca84ebfe3f747008cd68bb9ffcd29fa1a930982a36b5ede7cb7b5158679d7
+F src/include/lib/Paheko/Entity.php b8ca90fb3a9034590c580cd897993bf04f51cff4dc84185bc7bc52f5382803d6
+F src/include/lib/Paheko/Extensions.php 75ac18b3ca043cbf179aaadca4f2c8349da578b7f785036134487186726d27ac
+F src/include/lib/Paheko/Files/Files.php 16545358437543c09c52d6380e015b4d6bf5bdf2887efa44a9ac09464d7ff4cb
+F src/include/lib/Paheko/Files/Storage.php ba31577894ea74d9acad5fc73b5cb7f1038bf80469bb1a9f8fec0d9c86b56027
+F src/include/lib/Paheko/Files/Storage/FileSystem.php 1e6d4c57f1d647287bc3f459cc3d47bbdcf00c49996bab6db50cb9824010d8d2
+F src/include/lib/Paheko/Files/Storage/SQLite.php 8d794bb5a72c9f809ba9a9ed3407d1a035fea9fb747576fee4152b0a89deed97
+F src/include/lib/Paheko/Files/Storage/StorageInterface.php 2921ea55a0f6e802950323abae575df6e6624e774ff9b1fe6edeeeaf791e3a4f
+F src/include/lib/Paheko/Files/Transactions.php 1385346800e1a8d45d75a891657690b4d5fda06eb77c19b38861623b41afeede
+F src/include/lib/Paheko/Files/Trash.php bbb480f47e0d87179824c1457dd252102852b83b15904d73336a8e9ae3abe11b
+F src/include/lib/Paheko/Files/Users.php 03407caa6e0d61b3ef7b0fa896212611bd517cc9ae318883b836a03d03991d92
+F src/include/lib/Paheko/Files/WebDAV/NextCloud.php 229f5fb71514108df6a23adaf454d8923ff239d0af27efa1d592bfbfd1e5d86a
+F src/include/lib/Paheko/Files/WebDAV/Server.php 3f2d99c0c189211b6302057b791b583a4d404913aa891511294fc8100a8793b3
+F src/include/lib/Paheko/Files/WebDAV/Session.php d427f05aa517fa12557c3470e828cfc48a7ff9ac4631d7a6b6c2087110498c58
+F src/include/lib/Paheko/Files/WebDAV/Storage.php f55e8e076239e5fad6c0f3082dd102e6e0cb58caa4ef398d6d80e4033fb37220
+F src/include/lib/Paheko/Files/WebDAV/WebDAV.php 73a217a837b2e90673dd35c8dffcc452cc0ec072e91c74abe98837afa36efed5
+F src/include/lib/Paheko/Form.php 4d2c42aac6bc65fad279ae7b017b175faff35fb707c3ef62ba67314929c86d0b
+F src/include/lib/Paheko/Install.php 38c05fd8307f6f4b2cc60bcf3aae64c5d52dbc68457407fa37fcd9c88439d937
+F src/include/lib/Paheko/Log.php 17fdc8dca9b5fa3291bd61ebeb243dbdb32410b88f5e5805fd31d810f562759f
+F src/include/lib/Paheko/Plugins.php 31c5631e873444fad42cde9e433c94288f437580f71fcb0e4ff63be03a987d31
+F src/include/lib/Paheko/Search.php 6e24dc0bda30e39235cf505275f4e9d309f01437f3c5e3071283095cc712e49d
+F src/include/lib/Paheko/Services/Fees.php a75ded20d288cbf6f086daf15cf36154780a05f2880de7e32b5eefde00059391
+F src/include/lib/Paheko/Services/Reminders.php da771eb57ba22b54d8643ed5d76a50fce651e11f67fbe43b6313299a8da8c216
+F src/include/lib/Paheko/Services/Services.php 6ec7982e4235f39763d401f84851933a429435cdc3a2da7ba823bba41ef42b88
+F src/include/lib/Paheko/Services/Services_User.php 9e67c6257146aeba80f4a225ce463240e1de29c225a3c5eced10042a80469433
+F src/include/lib/Paheko/Static_Cache.php edc4248208668422dc2d910933903bcb230d48c3612e42bd5417eddb1f5c6cb6
+F src/include/lib/Paheko/Template.php 0b6840d1d669afd5815f9686eaca9d5995b050caa9ff76c69e849f304c5abc66
+F src/include/lib/Paheko/Upgrade.php 1f6f24d62a884167c7a54a705c7924e15ef9f43ed0e5cbe2316b65411ba98042
+F src/include/lib/Paheko/UserException.php 6f9f5ffa6da08080d84fc154f02ff20ad57d5f508da390fbf382534465a195ef
+F src/include/lib/Paheko/UserTemplate/CommonFunctions.php b87628aa1010cbfb6ea8b6d2f746e82e0aa123c793f354ed89176d667eea31fe
+F src/include/lib/Paheko/UserTemplate/CommonModifiers.php e21813cdbc0d6156d2b857bf85ae56acb2b2e28a34b12b3627a42ad5e0d7e0cc
+F src/include/lib/Paheko/UserTemplate/Functions.php 440081c003aa222e4fbeded563f4971f5aac271e08d3b7cda1db7718098d0c4d
+F src/include/lib/Paheko/UserTemplate/Modifiers.php 22201197247089daaa9bc4b61adf598cae3de084a04eb6d131558d192310c9f2
+F src/include/lib/Paheko/UserTemplate/Modules.php c899e03ae03563b4bba626f31a9df8df806a74ca70f15e1a14610f6dab97ab3b
+F src/include/lib/Paheko/UserTemplate/Sections.php cc144cf763041dbf5a148aa9e1d10638e65049d4f8fca6564d8188345890f6fc
+F src/include/lib/Paheko/UserTemplate/UserTemplate.php 6b9729b0bebb6b53cb5f9fada8445985af0f593f1b5dc1366f577b3bfed9ff23
+F src/include/lib/Paheko/Users/AdvancedSearch.php f508f34992cf1fa47e556f3b4185b52c66eeb78f874de858c408cc44f954f27d
+F src/include/lib/Paheko/Users/Categories.php e3917ac1a89abef0ba1dd71793502d2d412e8ae278b4c2dbbc3f1bb423e4323a
+F src/include/lib/Paheko/Users/DynamicFields.php bb7a043b7dfa12377b56f3131dd3daa773a429f25af4a8584bfed997f65ba569
+F src/include/lib/Paheko/Users/Session.php 9e16088dbda0feef4e641faa83223e4eaec143f9bbf82d7a27701044a63567f8
+F src/include/lib/Paheko/Users/Users.php 8b056aca383dc42802ec5ba7b8b96f9e5fdd7fdaa1162a5d1e1a1f7f2b72862d
+F src/include/lib/Paheko/Utils.php 4381ec7b83632d5b56578772035bc0d95d2b1b37182a1376fdb1771392d4a637
+F src/include/lib/Paheko/Web/Cache.php 541d11136dffacc1fed40f049cc260d7f3c2ef7ad318410bde9c80729499957f
+F src/include/lib/Paheko/Web/Render/AbstractRender.php 576f498e9357474ec793aab4fbe460035c6c3fa3b9d55207f326588d8332552f
+F src/include/lib/Paheko/Web/Render/Encrypted.php 055b068332ddf95ba0f29d43e715aa6f66227ee3c011df1369d135d0b1304142
+F src/include/lib/Paheko/Web/Render/Extensions.php a42e230e2b6c7ae3dba5d3cbfd957bb95eb2105f8c79c38d20d93eb84c7d09c5
+F src/include/lib/Paheko/Web/Render/Markdown.php d45beb860c8f99cd0f7b1e0f784b34d42e458afd67516807952e7260bafdff16
+F src/include/lib/Paheko/Web/Render/Render.php bf9ea0b4507b7701c34778cc266be9e9529e6e4be150b4fa52e923a31f7c465a
+F src/include/lib/Paheko/Web/Render/Skriv.php 472606401bd6631e54bcb7081a3b17f12790f9249a528aeba348b364126b0719
+F src/include/lib/Paheko/Web/Router.php 562eda56629e12b5d0710703fc3f48071caa1df99127101f1cfdaea481c7c928
+F src/include/lib/Paheko/Web/Sync.php 4745ad2187f7b0651b417d7b1c5b45a558eaf9f3aeb25e0252fd4233df27d51c
+F src/include/lib/Paheko/Web/Web.php 853a03d12281d2cf2e976bc9f61439420e041af600c581b8025764d7016d39d5
+F src/include/lib/dependencies.list a2ecd905ca0fc5fdc1cb35ab808f409ba96746c7b2fa891a83f6e8974ed1d875
+F src/include/migrations/1.1/30.php c393e962ee1933a051ef041551b2ab1d27cb8f990eece5ccdcb0a2b78c5861a4
+F src/include/migrations/1.1/31.sql 410442c903644007e377e60e0df7face87512613e91530c5f8b74c0f6036208b
+F src/include/migrations/1.2/1.2.0.sql 0689e84c69dfabad05afd6f4638e3e30ceb02d3487c3be277457a2afd75b79cc
+F src/include/migrations/1.2/1.2.1.sql b8ed39033fc2127eb9c8c3e5e9c950b02e7ca6a8ce35b0e051129cc06f7d2951
+F src/include/migrations/1.2/1.2.2.php a4b2e1e6221dd431314fd872bd93e9d78357c97273060a5d934cbc98c45ed5f5
+F src/include/migrations/1.2/schema.sql da30dfe665476016616504dd95adb74d712ea7dc8d9cafce0819052558b93104
+F src/include/migrations/1.3/1.3.0-rc12.sql 11161ac317a08dda515f12560e778982950363cec241fa8a09bdb6988925ec49
+F src/include/migrations/1.3/1.3.0-rc13.sql b6b78bf4c8116aa01c7fc55a571506d6627fb8f194ac5561824830083334c558
+F src/include/migrations/1.3/1.3.0-rc14.php 93634bbe5dcc813eedfe3ccee41b65253b4211057faa7b799b28fd7007d0d96e
+F src/include/migrations/1.3/1.3.0-rc14.sql cb37686db57f21310b326ef0bef7e0dc1b6785d15ad3b7bdfbf38b1aea80c06a
+F src/include/migrations/1.3/1.3.0-rc15.sql fa08177ded47239bfd04ca852d9ed046eec0fecb491179ba25f0f54995890adb
+F src/include/migrations/1.3/1.3.0-rc2.php 48d00a8704fec118b3378199cdd13b9800f1fcb160a5466e52ed6f5e9b110080
+F src/include/migrations/1.3/1.3.0-rc5.php 04b6f67038ea82524e7f4d469eb79b253aab692b87c5012f01e1c01b289fbfea
+F src/include/migrations/1.3/1.3.0-rc5.sql 870ceef216f7ae31189e389543aeaa4d990083ac8d382ff5d775b4b140395324
+F src/include/migrations/1.3/1.3.0-rc7.php 49dfa481b37334158fc0df017a4749bae504900468301f866f2876e0be3fa02b
+F src/include/migrations/1.3/1.3.0.php 37e1876293e26690ee923e04a4a466bda3bbe9a0a1d62266b833f14b42104169
+F src/include/migrations/1.3/1.3.0.sql 02437ff2468951a43547a548965cbf37b8c5852fcc9ac31b05fdae40cf80ad9c
+F src/include/migrations/1.3/1.3.0_bookings.sql 70038509b6da43357389713b1fffc47346ca6034efab63d0c6be9d9bd0b7bb93
+F src/include/migrations/1.3/1.3.0_schema.sql cc2e6bf0f7ebbaa5ac1fe52f3c68bb993115fb14758379f9b05c64296458ad84
+F src/include/migrations/1.3/1.3.2.sql 3a81504a4d49d8226aba81aadf40a0bc1955fcecd3ab5d009186080c70ce4318
+F src/include/migrations/1.3/1.3.3.sql 765f1b91b165f2d806c00e2673491a76be9c78b37eaf82b2da06691443e403d6
+F src/include/migrations/1.3/1.3.5.sql 0f6116f331fe383330e803d0261b16e0abf3123e088de8c9e427c85ba7899af6
+F src/include/migrations/1.3/1.3.6.php 6641862274cd20981136cf9ccb8afa49f4193b357c62011e722e5118c5bb90f5
+F src/include/migrations/1.3/1.3.6.sql d678a03fa53dfd80ac42479cd1e2deb4d39b07fd1fc3bb6d6f9adbd1d8a833f9
+F src/include/migrations/1.3/schema.sql 789e6d39a66344c561a8f7538af4df9012dbab55f96887909525440473b48776
+F src/include/test_required.php 22354c63e51c190b09cdebe4f67da51184e2d2d3f1ffe068dc016fd95fd83ac0
+F src/index.php 3633bc39287a8bc3e4fc60b80287a12b61710f6e
+F src/modules/README.txt dd5b3e533be29babbb6d1a3982deee2872519e9db63f4daf68bf765e781670cf
+F src/pubkey.asc e8974cff9c91a4a2e6dc1749dfe79cc46ecd3750b57a829945aa748a932eb54a
+F src/pubkey_old.asc 7eb815c6b8508e342c1383400c531c4774d75da553ec05687f7c3c495f6ed21a
+F src/pubkey_signed.asc 2005d5e9303f9b455be7497161f03ddc835e25eaa1064b5144ba743cf44a3682
+F src/scripts/cron.php b833b2c35fd1b1fcc053aa8ff775cb31cc5b7ec5ae962fd1fd3fabc5840c0b1f
+F src/scripts/emails.php 8c7903525ba625f639e5006ced57e50b29c7c40048a44b4f097684049fea1062
+F src/scripts/handle_bounce.php 8f113ead55cd356a42e223bd61216f26e758ec7bd5d524c69fe50434cf49f5c6
+F src/scripts/storage.php 37792e3e82b0a0eec5f8651d91fdcfad74dadbaaa0e88ee212d22ea0e24a35a8
+F src/scripts/upgrade.php c7191a17d2357dc940cf1c66e2084c1a255cccc8ae66b10a654d95bafe06df95
+F src/sous-domaine.html 570b1ded6010aa8475f0fbd310916c2f171f02147824731a0f8fdb5c15b19492
+F src/templates/_foot.tpl fd3f94279f98856e94e0bf7646da1d5a9a73d6c47f1f5a1718ec2bc9957db6f4
+F src/templates/_head.tpl 285112325566e6290ab1f27820869bbfcc8f57f542ae482b38ac6e9e6b35ff7a
+F src/templates/acc/_table_actions.tpl b564fc53bc4e9d88b2b9f73fe40fde55c69e1b7ddfb6719a97eaa140a835dedc
+F src/templates/acc/_year_select.tpl 9d69235d945068789898d2dab051f4ad3aa4d241
+F src/templates/acc/accounts/_nav.tpl 84ce1f0f134dd7c3bf3c417e6a49fe1455a6feb75e8ea44e98005fe83cb59952
+F src/templates/acc/accounts/all.tpl 527b1a3cf17e6fab54b311b8b26cabb26168506e1359357768156149b745b58e
+F src/templates/acc/accounts/deposit.tpl 5717951cb833afdf60d7cb0c596489290d350b9f7970c8de24a5d2d69569105b
+F src/templates/acc/accounts/index.tpl 27ad87475ad3f6251445051c769eaf83c3d8308e746add8e5948d38267e3e6ef
+F src/templates/acc/accounts/journal.tpl f1c1e22fe191f6a91e2657b9d6a57d7b4d1b181c2599f918412fee40b5b1bf70
+F src/templates/acc/accounts/reconcile.tpl 763c8b6e15a7926b4794e3d7a718131a0088fc5cfa057690ede103c834dd2b2c
+F src/templates/acc/accounts/reconcile_assist.tpl 5f4684d54a63e985bef8cb315bb031e0af610a9939bfd60c807f05f8f41cd1f3
+F src/templates/acc/accounts/simple.tpl 492b45132e107abd27c3f6e5c83f0eca99b45707762924d38d62b3fe752ce236
+F src/templates/acc/accounts/users.tpl d0ee19ce7a9dbf18ce4f494dddecd49671280d0f36ccedeb72b2945743a67c2b
+F src/templates/acc/charts/_country_input.tpl 8970124fcce868eff491e4ea0bb41707fb1ddfb619d0f33c05c2284fc7f45b03
+F src/templates/acc/charts/_nav.tpl 54421037ef8dd93411cdc8030b733bf3f9545961f0a9bde0881dd76ca5e6e4fc
+F src/templates/acc/charts/accounts/_account_form.tpl 0f5791955c82409298a7a543a881f24d1279a68f808df870bf78b4b3affb1826
+F src/templates/acc/charts/accounts/_nav.tpl 41dd69badc966c22e7b4b41835961a55d75fa543c29d5605912b710b5cc934aa
+F src/templates/acc/charts/accounts/all.tpl 41a9827d7adbe228c72a49658782c6f124773753f8e3e86d2716413b2d4e2d07
+F src/templates/acc/charts/accounts/delete.tpl a20a59f44d562f68568e8c617a83d690246b5df94fd98b0057cb3bd20958c090
+F src/templates/acc/charts/accounts/edit.tpl f19aaf4a8f5c2a0588cc3c1f24f701dc3cf96718159b7f784ad4a94e267cd63a
+F src/templates/acc/charts/accounts/index.tpl fae8c70754184c2a20a32c9be764bdac6530e6419344bd7fe8786d4567d2ce43
+F src/templates/acc/charts/accounts/new.tpl 730cd32e4d87930127eea26f2ba2aaeaf18208a56ed4abebe5d98fea783ca3af
+F src/templates/acc/charts/accounts/selector.tpl 0fe30f14a9c4d628e0746b7d783576457b3707599d5bdc7a0365e1b95331ef2e
+F src/templates/acc/charts/delete.tpl 770eef87a72f0fd8f37ff638b21d743c5811e77af9569632275db9836bc45eac
+F src/templates/acc/charts/edit.tpl b40615c384d6567a65dff280d4b72cb03f3f01321df43d332242833e5a16cdd2
+F src/templates/acc/charts/index.tpl d93297a9f5d08d87df1fe621d0f4a5f1b477c3961397c70fe2b1c9b36f1148d4
+F src/templates/acc/index.tpl 961141a8c7629606128ec79b936b30c607ec1d717336e5432df0203b05168b39
+F src/templates/acc/projects/_list.tpl b400835f183326d1e20018b7905d0b594ef298abc776b6d76555e4515ca82ad5
+F src/templates/acc/projects/_nav.tpl f31c70f86785821285a04a5ccf247d9b918861772a0479f17230df86b77f284f
+F src/templates/acc/projects/config.tpl d654a3d8de49c284b90c32a91d18ea3e657b27471fec310667841de5a77b7e19
+F src/templates/acc/projects/delete.tpl 50483883de6621504d4a3b6e2bdae3f54024e03ef3ae6db3b0636c4126ab6324
+F src/templates/acc/projects/edit.tpl 866858263cf78aa66e7a0240dc8ed2f528972efa0dfd434ba51bd587f6e44cc3
+F src/templates/acc/projects/index.tpl 1e74f6bf0469caed2a481a16166daf23fc4c311b80cb92b17d3fe4a567362abb
+F src/templates/acc/reports/_header.tpl e478688d2dd27e8d909fa2bfe64d77bcbbbe154412efb2136378ed57adda4f46
+F src/templates/acc/reports/_journal.tpl e70bc0c6869cb4f20309ac90bf5d124eec74c14ffdad6ec9b77738a137b7dab2
+F src/templates/acc/reports/_journal_diff.tpl 97ed168abada0e4f4c52db6d12fffb2105cbd3d743e934b68783287521ea985a
+F src/templates/acc/reports/_statement.tpl 8914b0b9aec0568d7736146e15eaf74474444670bc0b1865d3cac05deda573f5
+F src/templates/acc/reports/_statement_table.tpl fd1b98776b4252de106b7265e977a9c3abb08e640060214e7ca62e479eefe38c
+F src/templates/acc/reports/balance_sheet.tpl 78ec3533dd8fc56f2a1a3d73bdd1452003b5b8f806ebf182aea7adc0c7befc31
+F src/templates/acc/reports/graphs.tpl ac8c23572f9bea47f62a6730647b8f6b34fdb435af90e8ae23630382de3e79f0
+F src/templates/acc/reports/journal.tpl 2cdd15e90566050036fe662fcb86ece8380cfbbd899b1787575c16c11270dc56
+F src/templates/acc/reports/ledger.tpl 3199442208fca1ecd7e9d3ad136f1392ea1448c6d93118583301b86e400cb0f3
+F src/templates/acc/reports/statement.tpl b5b79a83845ad5533c02e578606e0a9c284c4571223228e88fbaf3435450303f
+F src/templates/acc/reports/trial_balance.tpl f64c876e9d221355cddf5713a561b48e11a809e61c0383f6cf8961fa5cb1aedf
+F src/templates/acc/search.tpl f6fc20649bada08a3765b4ac65337c251e1cf34a37335af6817eb32f8f7a06a5
+F src/templates/acc/transactions/_form.tpl 2359d454365e2458f10fbe8037ca244469d1ce1c742961a01b140bacaa2a7240
+F src/templates/acc/transactions/_lines_form.tpl bdefaea554333083b75c8a333e7ce9c05788ecc4678e08ebe049c280bfbe6274
+F src/templates/acc/transactions/_pending_message.tpl 980bbc8a961d5ce2c23d70dfc99b8751401c233d6dc4245b95c9eab04e055bb8
+F src/templates/acc/transactions/action_project.tpl c6e17886fe039f74a601aae8e59ac0b34de371f4343cb9165a78ef9138f3cc3b
+F src/templates/acc/transactions/actions_delete.tpl e4e47c60f4889d5500f4c319c81c38b2903b8f5dc4e719eb6b8ea59dfb111f12
+F src/templates/acc/transactions/creator.tpl 8d3d1b3556f579ef60d263840e701b875b1613b490bcbf14e1751c93a6b22b47
+F src/templates/acc/transactions/delete.tpl a52ca8a4358c57f04f1d3651611ecd337cf3023385f8e3a2c0af18daa9615217
+F src/templates/acc/transactions/details.tpl 9b35ee0ecc23e80996763f7edf843b8822921d8447e5b0e35bb16ffd0edc15a3
+F src/templates/acc/transactions/edit.tpl f1b0629190fffbd6ec085f4d48562dd97530306ea1b66f400ec0d23009206f07
+F src/templates/acc/transactions/lock.tpl a4d016d4407f984fcbba55b6e6b989bd04ea0792f0ec9e5a55cfec0932dabaa6
+F src/templates/acc/transactions/new.tpl 5c27358fa01820f19734fcec1f8425a8a6520f37a452b3e4a417597a378e3ae9
+F src/templates/acc/transactions/pending.tpl 0aeee62cf38e83b8c55811ad6f8446fde16724d58aff96119aee2fa22c3853d5
+F src/templates/acc/transactions/selector.tpl 7b3ea49429b1700a2fee6b04f60a9c47257956034a8dc1b3cdf4ee8153dbb873
+F src/templates/acc/transactions/service_user.tpl 6640beb19ffe0a684a4b71d14848898b1e095d6db6f212724e73c3fedb376b6f
+F src/templates/acc/transactions/user.tpl 6a563c7bfe8d8af7b2c2bbb4db9f4fe7cb3253706ac2497312fd1cdaed9bcc41
+F src/templates/acc/years/balance.tpl 1c03ed91c5e41188db976ca901ec570e380d53cffbf9a5fa5210222874d2668a
+F src/templates/acc/years/close.tpl f4f366882050d2c62e1d0b6c0fbc7a6c41d6a8d1b55594fc4424f897d77c9b8f
+F src/templates/acc/years/delete.tpl 5b6b3347d51916921e2fe93b90129297141c7dc8709651d7f8fc556629a94012
+F src/templates/acc/years/edit.tpl 885bfd3bc578109d2153719ea6c5fd60c350576d15341779bdb3eac70167526c
+F src/templates/acc/years/export.tpl 8758eb1e941fdd1338457c561808b722c3e20b9e63a8f1bd991c950bd088f465
+F src/templates/acc/years/first_setup.tpl 9f5d11c30c99b0b9c56e665333dbf149d5737c5b0dc17b87b712e33e6345ac6e
+F src/templates/acc/years/import.tpl e820cde50b74fa04a3887f9d2b179c707ecb9894effcbb290542392ae1e2573f
+F src/templates/acc/years/index.tpl d3c41fa54dfcf9c1b00342cb4d6f929d9689a6977ba99f48cd3e53d091f3bda5
+F src/templates/acc/years/new.tpl 6309d13cdc2cb38b5e2b8da1a565467b14588348d0c5504801fcf2032b703398
+F src/templates/acc/years/select.tpl f5d95242b1dfc4e28da0a26e2572f11f0358bcc76a84e5a1b34bf115340d2d8e
+F src/templates/ask_share_password.tpl 64a822abef8bffbd21d9d4d1ce5c05cc06d4fafeef01d068e0af6046f0343d02
+F src/templates/common/_csv_help.tpl d2396fd24265c6d5ec0ad11db694bf685c73d00247d8d44034347f710a06adda
+F src/templates/common/_csv_match_columns.tpl 3b26a258d5e2401494de4d1f52451cc49fad8fccca0484a26f183490773581f4
+F src/templates/common/_sql_table.tpl c361e988d8163383ff52ba5b3e7bc735ad8187d7b8da1aa1796793faaa4f12ec
+F src/templates/common/delete_form.tpl d0d845ada2b1b9c79c636160e5b7c0a08168a59fcad753f1c4dd7588eb1688d6
+F src/templates/common/dynamic_list_head.tpl abde4332b8d2f310dc5d350877319f245416cc530f38045604b013fedcc6ee5d
+F src/templates/common/files/_context_list.tpl 92d4b1a7cfda602aa3004a5f063dd1f84e8d47929ba31047630066ef074b0c7b
+F src/templates/common/files/_file_render_encrypted.tpl 96a545c1d04ba674e9331aba1bf5dbb976d622c792f9dd3a882bb5d2e80815f6
+F src/templates/common/files/_preview.tpl 2d5be4acf580f38311de0544a426bd467e62d61c6ef2a891948f84a09ee197d7
+F src/templates/common/files/delete.tpl be0cff6dce2535e8e379daeaab56a68bed9bf673081364c68e2d07de2b093b47
+F src/templates/common/files/edit_code.tpl fff859ec49c2d8929711ea37211cb3d96aee3b833f6e4110b81c65defc092678
+F src/templates/common/files/edit_web.tpl 62e2d8808847a858da27a249dac0e4944c693089204889f3c699760c4686fb41
+F src/templates/common/files/history.tpl 800cf176bc67e04a438429ed17c75608e1fdd899d35fdb76349db49def47da9c
+F src/templates/common/files/history_rename.tpl cf3891da0eef64621152899dcb15059f3dcac2dbe081a3d3480325db61f6b749
+F src/templates/common/files/rename.tpl 31f0f230d4d5c067ce3eefba7d13756574a8daa85df7b053f2e2da60f22a2501
+F src/templates/common/files/share.tpl 8c51b4bce0edad18ff15289becd1f17a3a3b68edc36d62065f21603cbc1f9cee
+F src/templates/common/files/upload.tpl f4e3d9978c99d9ae9c2f23a008cb1832d1ec55fc93464a94d76d2fa0b69d1826
+F src/templates/common/search/advanced.tpl 399fb967f8c2b83af7fc60b3e023d909e05fe36417236ee1109641b1136b0953
+F src/templates/common/search/saved_searches.tpl 25bd296f13c6056708c3e76bd800016bedbe9b37738dbd0a577abf91eb555271
+F src/templates/config/_menu.tpl 8e80c00714d5a3550e2834a7938c5081febb0fcbe76ec99a2d99318d0e4059d6
+F src/templates/config/advanced/api.tpl d8e3b6d3d4512c9ad82bf5f496fd8dd4d5a401a960ed1c52e24f7c6cdff74bfe
+F src/templates/config/advanced/audit.tpl 5ecb9cb203ffe49626195e86c0101a2f8b4aa16f55b603139fb078de7e700ca6
+F src/templates/config/advanced/errors.tpl 286b66c96854e23a163c99f1869c303fa31db7e0707c5cb921bc2ef57dee32e5
+F src/templates/config/advanced/index.tpl b39fd9f3d537a19a15dad2ecc0c1732b2aa9110a283a0997bbb8063aa6a00384
+F src/templates/config/advanced/reopen.tpl 4d422485411d63d9cfb15c13df9acbadda25d0cc008cce87b186cf0cf7bb456a
+F src/templates/config/advanced/reset.tpl 0f77448e3ea524581acadf687f5fc3e8a7850f4e567654d3b5fa5a0477897a57
+F src/templates/config/advanced/sql.tpl becbafaa6911ea6f65e9f23f43c1f334db754c8f76fec59b8ebfe4930b84bd36
+F src/templates/config/advanced/sql_debug.tpl 6edc4e970bd5cd22454342053111494bd50875e07a96c17bce533ab9b4eb24ef
+F src/templates/config/backup/_menu.tpl 697510b2be8f1c190a9841052aed7f31b19c43c1279d524f92e770319b8eff95
+F src/templates/config/backup/auto.tpl e1cbf70807040a95b63928b1dd4abcb80425cd0b52d006729e9ec8b40f3cb350
+F src/templates/config/backup/documents.tpl c9821014c8320c4cc2fcacba514484509d9c43207b7f41662217af6d2fc671d6
+F src/templates/config/backup/index.tpl 1faf2939208049f19ee7e3f2ea5c728b97afc596384018021a98db5b78908853
+F src/templates/config/backup/restore.tpl 98b0327c18d0b8ed21c88f9fc8f98b6365030dd222ce1bb8b0110bc81d751933
+F src/templates/config/backup/versions.tpl 1c7d54cd7d0586aafc16d2d2c84c8eb6bca5f35b66d0c68baa4a0d924b3db3d6
+F src/templates/config/backup/versions_delete.tpl b02242093c04d66b2e8bf37bc6737711ed8f3420dcbf7c4903cfd2b95bbc0038
+F src/templates/config/categories/delete.tpl d68a5716bd966c99307ba350b5aed3c9128d3cbb1eefe73c444900b473565f86
+F src/templates/config/categories/edit.tpl 69738b641dd4f432ea0c06a069dbf036b0f5b9723f8509ed8a287c63226cb46e
+F src/templates/config/categories/index.tpl 2b49a28f7880c63971edf8c85570e9598b5e52e02f69170dc788b75caa83abd3
+F src/templates/config/custom.tpl 852e5f8d66253c11551dfacf54bd663c1107e8d617b09d048d6b55037e3cf215
+F src/templates/config/disk_usage.tpl 41fb60af426697cf57c69b121c6567399fe307e2cbab96176f92037fdc39a639
+F src/templates/config/edit_image.tpl 249062b118e6a849827d2d853cedb5181b16326b95316890c78d63eef34a4a42
+F src/templates/config/ext/_details.tpl c5a501557bb3e2e27666a489648c2b7fc4c732d7f81aac0ed0ac632636b6834a
+F src/templates/config/ext/_nav.tpl fba85f993ea122b1255cfbb640d37f0d88f03a343c3eeec6041a803d76d86a97
+F src/templates/config/ext/delete.tpl fc672871e7c2928fc62b0e3b932d4e3ebecc9a27290776fbd005991f3e615961
+F src/templates/config/ext/details.tpl 8f8f84e658123b13eccfdde5d7541ccd0766ebcac0c0919a36eee42196fd8973
+F src/templates/config/ext/diff.tpl c21a78df5af19cbb7f6f66f4ac6459d08aeb755a102db7176ca424ea6081aa1e
+F src/templates/config/ext/edit.tpl d3e657b46b5283e46621e6bb17208b51316c5586581fffeffc81f0a79c261473
+F src/templates/config/ext/import.tpl 6b4c385d0c81686fbad72f8ef274afc59add81e197b44127a9da87502b6ad7fa
+F src/templates/config/ext/index.tpl 30d78b814eaec87660c6faad9e1b41eb84275876e5a0df360df6d15bfd02164e
+F src/templates/config/ext/new.tpl 9a8b1a826f500dc0bfce7c25a8fc114dfdb33753ea606fa08355a9d9673c76cd
+F src/templates/config/fields/delete.tpl 7f5227ca29989fc776e37745f875b1d50baa85f3a2846bdb3ec9a90dadd55aad
+F src/templates/config/fields/edit.tpl 2651d7db047b2c64de95c65ea3a430f1ea8bf8f30dc3d87529674b3d91b600b2
+F src/templates/config/fields/index.tpl efd6618c4663447375a623f0b6126e91d16ee5fe077958641c799d60f57b315e
+F src/templates/config/fields/new.tpl 72353574ecf16f3e4771275f65a1e7ab7ccf872d8911a9e41f913278e7fe5738
+F src/templates/config/index.tpl 95475a082ca670a84dc14063f2541d0f6469070883eaf5db1b04bff40899a5b7
+F src/templates/config/server/index.tpl d91eb5a4e4abb2ac1351e6d3c6df3fcaea15eed2b087e912c9a43519cb49e734
+F src/templates/config/upgrade.tpl cf8e3610f7d1402d20ecb4a5dbef7820237d84c84ed873f2e78ba45f7903785b
+F src/templates/config/users/field_selector.tpl 4f5e4a822a803fa166638ac1489752d15cb1926dde15980db9d93cca7e442419
+F src/templates/config/users/index.tpl 8b9b97d305ab0439f2cb6295955e7f1abe66b08627e9030f06a9e0f30518074e
+F src/templates/docs/_nav.tpl 920527f86b1aefa5c699d82ab2ccddc5d98244969e06b3de69d771b7e3ed4698
+F src/templates/docs/action_delete.tpl 85a65c16cbcfb058acf21dad9a0d0526eb4242d6aeadbf6f5f030221fb477fb9
+F src/templates/docs/action_move.tpl cebb332df8dbcdc76a5deafb76f06acde18a3b7306f0544afd29e93682506600
+F src/templates/docs/action_zip.tpl 114d5fbcba613fa60010bea0f5747072c8b7c1fb24aa19bd50851efa7857283c
+F src/templates/docs/index.tpl 2fc0c3c4cec6fa948d4d28f79871cde72e76c0e427d6d7a3be5f2c1421db27a8
+F src/templates/docs/new_dir.tpl efb439d26cd579114e13870c35c39e8a71abb66f1c60618b39465d4e7b3908cb
+F src/templates/docs/new_doc.tpl 61bd4b47f99b57ebf71141fdc5360bcec4abb40ec811253cb04f8552448508d0
+F src/templates/docs/new_file.tpl a6c698189b67b187207759403493cd92af38d46dbf004d0ebbde51ad59a28d1b
+F src/templates/docs/search.tpl 56173f01c0aa5898bc01dd41016f93f87417737417d7b8f609d7e53b3db09300
+F src/templates/docs/trash.tpl 239164286cd7954f48f5bee144628bc3f3df51f079dce5cc00797bf6e789fd16
+F src/templates/docs/trash_delete.tpl 09f17d3438bc7406f8b7b8fbc32c5849a66c293b1d5ede463be41cccf413b777
+F src/templates/emails/login_changed.tpl aaf10e05bad319868dacd8a9fbfa9a321e41adcb46d2d106eba45312db5e396b
+F src/templates/emails/password_changed.tpl f2ac7406d790310e1854bc428cbc77f533ee662957b696b29c4d5b739d06766e
+F src/templates/emails/password_recovery.tpl 6a0f64f4f79cfd6dfc667bc29b71784866f797c762166eb168a1f151f5a99403
+F src/templates/emails/verify_email.tpl a4d3823daeb5ae9fed9bc85b6328235e9a22b8cc34239442a0fa85ad734406d8
+F src/templates/error.tpl 50ad744aad666beafb6b0b43225732e1337e53698c01b20a045ed12e5704c406
+F src/templates/index.html abe89f9bfb756bbdfb2f535420e10bb5625eb4e2
+F src/templates/index.tpl 994ad84e962abdf7aab481571b788f933395a7d19d8f0d56b56447420420b656
+F src/templates/install.tpl d95835b6de5de137de97570b723823c64efb15af8369cb8201d0e0123118275d
+F src/templates/legal.tpl 10bbe6d7b52ada7763bac4016dbb8706d0e0b59cdb1db3de03fc7b5a47906b5e
+F src/templates/login.tpl 8256c7641bd7315c96d7d2663379d07f4cde5f0d070c37c6c0874fcc8a191662
+F src/templates/login_app.tpl aacba63dfc74e513295fae6ffe1b9a8f65ac8432c2856fccc531714de327bd67
+F src/templates/login_otp.tpl ac9cba2f9f3656a31d14a951708740264b7da5834dfce3d59a91ea9d0a36e3ec
+F src/templates/me/_nav.tpl 680aee52295b9a6a4dd4a564aff5f9ad0f302fe897995564156b8297b0ae65a6
+F src/templates/me/edit.tpl 6962fa61d2acab7743ad26ab954f7647b8a8cce243cbfa791d27f7268c72bd0d
+F src/templates/me/export.tpl bf02d04af7613f6e11e76d59b99c03b227facd7887e3657d315eb7cf1d04de8b
+F src/templates/me/index.tpl 1e788bb34faa9ea957ea430fa611d871cda836931c5380f8b9f70ddb9c1bbcf4
+F src/templates/me/preferences.tpl 18d75a6ee3998d7133090664098702da75e74bc76804f0874b8650de82101255
+F src/templates/me/security.tpl 00429b96894ee5429e6d59bf0c040619986f5e66c0e76c488fbeeec6c7627a27
+F src/templates/me/services.tpl 87e02c49816875997252d3c25499d83e79078c965f83127c84cf628c867f3383
+F src/templates/optout.tpl 59ccf897026fae5afd48b0bcf9b4e34cba3c7397031fd559a9b33fe29fd46c38
+F src/templates/password.tpl 31ba1206d06499ce283bea1fe3dd4725ae01995e6ec7fb7f5bd3442e95ac4302
+F src/templates/password_change.tpl 6a5f87eb36c02174bc8dc45ba93c773856071c5e5ade90918b7a369223ce9dd8
+F src/templates/services/_nav.tpl 5e52af7d0643eaeda6abc9ce7aff3418e6a255b1660cc2dfb797014c198bef5c
+F src/templates/services/_service_form.tpl 7e9c55acbd09f74fe03f19ed55179a6c16d02461c986b609d0942a54aa924311
+F src/templates/services/delete.tpl 347481046351c43cd14d97623e42b2a6e6d5f90c9351d095f80ed1faeadf7dcf
+F src/templates/services/details.tpl 814e435b61db5974050841bb2392e5561fdac3b3eecac26ed3d4e4cb75331359
+F src/templates/services/edit.tpl 12c859a833be1022ad33a9230f04e18e89715a2311820417ac428b15496db120
+F src/templates/services/fees/_fee_form.tpl 5de507e3b93aba39dbc015200d08788ed3dbb52c18f808df2459da2ac0791493
+F src/templates/services/fees/delete.tpl 820c8a6b776f55cbfe57aa9581c0e3ec2f8e005ef9e4ef5c2ff4ba3c868f8e74
+F src/templates/services/fees/details.tpl 01c98ab39afc4587a7223f9a282b18a7752ad701bed14620f4d21ec11d0937b1
+F src/templates/services/fees/edit.tpl df78282e9779644ac753be3e9e5de47433cd73dc92d6166a9ce395df6a2cda57
+F src/templates/services/fees/index.tpl 46e7db2d1b3138dc855cb07c50927998a56d31eaf13f3cc578a95ba3fbf7a9ec
+F src/templates/services/import.tpl 4484803cc7fdfa52391a265e506579ff0b6ee6a4665f52166dede5c374b81672
+F src/templates/services/index.tpl 74054465a9f770b4d612764b5a11f224e4b985eb77d78a04313a01111422c7e6
+F src/templates/services/reminders/_form.tpl 2645d5042ac9b9e0c3fb6410edecc7e84eb5bb17eca65a0ad5336586d9ad1547
+F src/templates/services/reminders/delete.tpl 95bcd98fcf9bfd492c6dd55a80354f0d123b7916ea5cd68434df99c6ae2ae38e
+F src/templates/services/reminders/details.tpl 827126e9349ec5307985c739e71ae016f0a48e583f22f25ac33693d923ad6ef8
+F src/templates/services/reminders/edit.tpl bf5129f51cf1c454c1d9ca99ab0a5cf2baefc225c20fdcdbdeb1f97267a9a458
+F src/templates/services/reminders/index.tpl d2a030a07f66fd34e275e0b8f5d967ed173ee08f5656326913f994b04918cd4d
+F src/templates/services/reminders/new.tpl 8974fb9fde0b0d607997c6c56447bb30563fd1467800e5b1e4eead0c24e4b05d
+F src/templates/services/reminders/preview.tpl 62b7b595c29f01ce071a04deed0d1a34d6f9b989e2fe214e62e98850bdc677ba
+F src/templates/services/reminders/user.tpl 8f1d568b5d8b64ccdcc5d101df487b65b9c604c3be674c469b062a6ee7ff1b5c
+F src/templates/services/user/_choice_form.tpl 2bdf0b250d0c1fac8159530837e674ea6047428a800042f9ba3ab4272ed9bea2
+F src/templates/services/user/_service_user_form.tpl 2a5685417c79c0a723bfb8f340a7fef7f4c26d13a78fc5b5ae962e4911b4ac44
+F src/templates/services/user/add.tpl a9b93751c54e6350de13fd94e4771105eb7d1a027433286db51337bb7a61bf62
+F src/templates/services/user/delete.tpl 9a92a85389c44597036a2bdd27a6620e39470a5c1d642dbcdc56fd65be9759c9
+F src/templates/services/user/edit.tpl 3f7ef103e93c9d98599979bab0bf619436c7badba2d05bf406b06ce5e36bbf39
+F src/templates/services/user/index.tpl 3993d68104d477b5b3107602ce6d7dbd70b6081f478d34d598ff4f86e9a79dd7
+F src/templates/services/user/link.tpl 6f582c8e1fb60863e0942c3bcc630fd062992943ba8a0fd138417664423ea4c6
+F src/templates/services/user/payment.tpl d5e1991249dc65825bdc1513dc96a428ec23afd9bcbe726877346a20c4fbd2bb
+F src/templates/services/user/subscribe.tpl 956f4af913ed7b1c7356653c774fe96edea481f020b98f86214a788aafd5c7f6
+F src/templates/static/upgrade_post.html 189bab4a7794569385df112f9227bf928be00fb1071fa658d61a2b898b983fc6
+F src/templates/users/_details.tpl b3fb59fa846c704a4212369327aa9a96aad20e3dd345b3fc66675a690f970374
+F src/templates/users/_import_list.tpl c35d5796835eceb572e2a37741ce7ce1e4251114617a0879a06d96a749ed9eff
+F src/templates/users/_list_actions.tpl 9e89b82f0aad2b5ac99d19cf35c5495c397bf3dd69ed8fe3f203afde57a8e0ef
+F src/templates/users/_log_list.tpl 08b5d1bd093db3a61e1a8b0cc3a8ac56279612c1e88d9e4b6239525af0a168b8
+F src/templates/users/_nav.tpl 4e65aedb035f076b1d53d4092ca19d69f854ccc3b090f7e9efd3938ae2093812
+F src/templates/users/_nav_user.tpl 8a253455d6ae3d40ff81175d577eab1a2bd86df0de3dd0b7f31fb3cac628b885
+F src/templates/users/_password_form.tpl fb3df829ba483036bc3e09ab6d87797a787552eff5f1bece6aa67caa59ddc48f
+F src/templates/users/action.tpl 2f37b9ed9af60d94610f8cf5b309b90d9ad2b9b4024380de78b5defff423e735
+F src/templates/users/delete.tpl 31f1744fa2732f3a91ea89fc9cec1d0b640ed4b48255651dfbd7338a80800e0e
+F src/templates/users/details.tpl a4b7b4e4cb81fda977776195d423813dd0f21280412f769d89293430b608b5ed
+F src/templates/users/edit.tpl b1a8abad3507fed835d89540cf38e46841208a0d4f245e8d5b414a8cf667c86d
+F src/templates/users/edit_security.tpl 9f47b76628e1ffdf7862d1038d4a9afcc7e43f610bea22c472bb27b5b9b4d1eb
+F src/templates/users/import.tpl c82adaa55c5e94a4462544e7922927a3c5fae87160c09b4594fdac3d87f4116b
+F src/templates/users/index.tpl 98b52f2c45ffe371edb4ccc9d4f532c9be2c793cf2d0e4ea09c1c667ee9cf227
+F src/templates/users/log.tpl 6a72a51d09deab43fe3caee7d9d333908c4973898afde9023810983fe3da8e94
+F src/templates/users/mailing/_nav.tpl 63fc9f91241b6aa3ee139ea9a59b7a37787c505edf6b7c5daa02a6c1900c8e17
+F src/templates/users/mailing/block.tpl d3cb8291678bac15d24b99ae07b01f9a95f8af7c83b19661103b7f9ff4d13ec4
+F src/templates/users/mailing/delete.tpl 287d96382be6517ca133c8c8c97d747d57cd39eb2c276141b352d68f647b8fbd
+F src/templates/users/mailing/details.tpl fa6f584102239e203474f16537e40c741454387e85c003963dd9b105af588b3c
+F src/templates/users/mailing/index.tpl 7992bafc9ff2ef216d386139dc88b5d9ad869d56f2f72d6fb1b2d5969f7373f1
+F src/templates/users/mailing/new.tpl 8dc2925045c064e48694d79e455048bbc6f7764aa7c3eb9df71c098c61514a8f
+F src/templates/users/mailing/optout.tpl efbb38fa59ebae7425aea2ccb2b0413fbb1b52006cc00e8cea39f29037da0d4c
+F src/templates/users/mailing/recipient_data.tpl d5ad4ec244404a0c1371d98a774094939fe7326cea8c4c655543b25fd2d43fa5
+F src/templates/users/mailing/recipients.tpl 03df04746b6bdfe093e3ec8c89863356c21e2f1cb476c905fa2175a27b3319f0
+F src/templates/users/mailing/rejected.tpl 11f5f93f7147be28a02075406f3526f9fe74252159e982daee5394aacdd94167
+F src/templates/users/mailing/verify.tpl 3380d3fc0bc4b18c8158eb6c7ed41ad2d2e65dffa3b0ecd068f8e571a0cd09bf
+F src/templates/users/mailing/write.tpl a5ee5946c0ad554aae8c68da54a26372b5f34f3b99c52f95fada058036f365b2
+F src/templates/users/message.tpl 68d6edcd71bda70cb1025f88c83bed7def3b9afbb280274329075d7d6d3d4265
+F src/templates/users/new.tpl dfa68c990d769719a079218337375b16d23809e3dd708fe4d6891c380ea7802f
+F src/templates/users/search.tpl 865c43f3408d50382a733dc0189a1d5bbc7236519af66c8bf4a701c1b6ef85b0
+F src/templates/users/selector.tpl bb00612e9169adc9e57d81e746ed0fd3accffc3c278f05fa3e80ba2fbc18f249
+F src/templates/web/_attach.tpl 14872d8a8bd7a44a5b7975b53443a9fc34c8a4547cec4db75bd6309e8172218b
+F src/templates/web/_history.tpl 8e1b0437e7f81bde064cd017c739b98a7ac9ad54964034b0c27dcf2f56f833d0
+F src/templates/web/_list.tpl 471d580070e04c9c978441f7519706cc2548736e94fa7d0c916b560eb003f714
+F src/templates/web/_page.tpl 4156c42d29bfd2a0242641cccab8998a1b6e58042e9019317c43866c107a2602
+F src/templates/web/_selector.tpl 7ed2030acc7adb26b6b45c756f8eafa36db8cebcefbc6c963bd233ae423bb1e8
+F src/templates/web/all.tpl aa5ef2cd454b8d636f9ee398511951efbe1c91a375bd1384df02cbb6b3abc48b
+F src/templates/web/delete.tpl 7d5e7c30bf8b42d65bf566127b1fab38d0b4c664c48dd87693ea8e9d6c855f7d
+F src/templates/web/edit.tpl c8ff1d7a39618f813852c3e162fbd68b92463d4a340abaa6079d10505bc1c4d2
+F src/templates/web/index.tpl cda4822b30d24ea1af866c6381d2382d57227fce9a3efb1c55e8f9851d6aa997
+F src/templates/web/new.tpl fecf4f84d90d66dfa82029a59dc0dfad2e461df3f212fe0d30974fa4466b2435
+F src/templates/web/search.tpl 12d480905d1b460436854b7b3270bc57ecee5b9ba8ba2e3024432f804df0fd29
+F src/www/_route.php fbc1235739cf597dd07e10b6f685df089de5a43eff5c0d82d96f441934ecc906
+F src/www/admin/_inc.php ade6ec023564a91f2107826e7276a003d61e71fba7e154ad2d412dffe30a85e2
+F src/www/admin/_serviceworker.js 94c15e9c0d42c4acfa82da0e784beefea888c992422855f76fad69fb6f7e56bc
+F src/www/admin/acc/_inc.php cae640f5568028c3941a885ba88b9628b88dce1dd92ceb420b559de12c2213cc
+F src/www/admin/acc/accounts/all.php 259df489f0ce8dc2d43535b29a3d41e56d13deff3daf53dda037cdcb23950ee6
+F src/www/admin/acc/accounts/deposit.php 1551d00b01dccf41008bb41752dca083f13d572fc407596e378f2c5f295a5067
+F src/www/admin/acc/accounts/index.php 8e62e8fe2f71623c366d26527a6933d03687b1bed2045d957fde57edf712e31c
+F src/www/admin/acc/accounts/journal.php ec60c74084c422529d5af722160d65d5d1b48899272b6db8844cfcb314795e2e
+F src/www/admin/acc/accounts/reconcile.php e40db86c79a2184ee63aabcc46429d74bba6f463f9093c550f9cabb0ea7c4f30
+F src/www/admin/acc/accounts/reconcile_assist.php 479427fb8b43d89bbf55483037d87f8fdb522707e3e4c4258d985f2cb8634f68
+F src/www/admin/acc/accounts/simple.php 5d4d5e06061543007033e29a3b9c339cb22438c6ab73560f63cadb841c449808
+F src/www/admin/acc/accounts/users.php 40514630058b097e887dec8bb4896828cfdc913d9466f6d111cc71310863f7ef
+F src/www/admin/acc/charts/accounts/_inc.php 7d4861d729a9ab7a646b81e48cacee16697b805a4a191f9ccf09ccffceb8d166
+F src/www/admin/acc/charts/accounts/all.php 6cc19c0d16d21fbf77a0ab3498f1f19ebe26017b9a19017963115a2fe693b343
+F src/www/admin/acc/charts/accounts/delete.php a15cf614a6b912fb31ad7ac69e89638111b0f77c79e9bbcf0bc67a05051bc90a
+F src/www/admin/acc/charts/accounts/edit.php d996161f87fc99d8cc3ff9b89e6b816f72ac3d6a98b780422d4968cec4c8e8f2
+F src/www/admin/acc/charts/accounts/index.php 8d5b8bdfd1583f01e6fafdafbf9845b64a8d338255cdea3a7d2330bd5f76b901
+F src/www/admin/acc/charts/accounts/new.php 33f507b0f6d6453e5d7d774d06630a6598b3630293a9d2301f705f78d1b0fbd0
+F src/www/admin/acc/charts/accounts/selector.php 1b98a5ac982e09aafc948ef0c56e63cc3a2c82f1c5986f089be304c79af692aa
+F src/www/admin/acc/charts/delete.php 952a8b2bdcb95eaa49753cc04ae6e4bdf16fa86a34a59e4fd9f0b407504a4dde
+F src/www/admin/acc/charts/edit.php cc4c1ce9c62425bc968b13eabe202d4ec6e13ebff47e8834e277f957598eaf2b
+F src/www/admin/acc/charts/export.php ebcd95b86ed73f2d4000b7501c56b78ee461be9c7dac1cee651d5b739e77268c
+F src/www/admin/acc/charts/index.php c7d0deab0d719c658831e3924203e730783a12cbfcf607aa3a017a64a4ab809d
+F src/www/admin/acc/index.php 5cdc30cfce11aafc37e8f442e4e2e8ec0d1301712780037ef9f04191701f5c0f
+F src/www/admin/acc/projects/config.php 4fe87b3d295c986b65a46664d0394fa234d89390109c9a7beecff3c515be1ba1
+F src/www/admin/acc/projects/delete.php 7b5c59346a98c8c3d3f19d915e6d0921628b43c44d60e2a8971a8c4d101860a3
+F src/www/admin/acc/projects/edit.php 6c31beb81d04cc73658103b84812cf1e07d8f3376cc50ea4c9a5f248277f5fa8
+F src/www/admin/acc/projects/index.php 1273ca37f5f9a6c28131e0d9d9451d7ca83f3fe47d71c32921ca9805aaac1df1
+F src/www/admin/acc/reports/_inc.php d0cf8a6629a3c1d2d92d5ad20366bed2190ff5f34ffd369dae62321c10cac578
+F src/www/admin/acc/reports/balance_sheet.php 77da1df82dc55f4bdad7ec317fb34e077455473b4b1558c0c432d8f9b7424b2a
+F src/www/admin/acc/reports/graph_pie.php 754cacd3890ee75e14c511bef556ad4e2903605c16d5e988416448da6fd0caa6
+F src/www/admin/acc/reports/graph_plot.php fa120f9c68b525c42cdd5ffbe8af37b77f94b22e8f19a304eba20c156b8aefb8
+F src/www/admin/acc/reports/graph_plot_all.php df40ccf7889494d01e2d5ed8a35bc1fac196f8a8176f9fdb17d76494ab86931f
+F src/www/admin/acc/reports/graphs.php a0d771f15c29963fb4800625de9972da4767e520136295123d39aa5edd67f124
+F src/www/admin/acc/reports/journal.php a71fc0c396b22a9dd154f342943e54590f07f492a1ea5b8520d025367b9adee9
+F src/www/admin/acc/reports/ledger.php 110c942871607429aeebb96b06ff3578534718e1768536ff35ca4372d3426bb9
+F src/www/admin/acc/reports/statement.php e8f2524f96a8d1496c07548e72c786d750e344cd85398a16332d4e3f9a0eb80c
+F src/www/admin/acc/reports/trial_balance.php 21ba816df3c45af15a02e04e7c12e521e9ff92f819bf4e2f029c825b8b095813
+F src/www/admin/acc/saved_searches.php 073ce71141c97ad3df1df0432bc9ce504913d2dad2d02f91595cf7c6a88f769d
+F src/www/admin/acc/search.php 1536892f1316ed05faf741906e80852f921c5b94ea6839ec78d1ca2fe89b219a
+F src/www/admin/acc/transactions/actions.php fc2048c55d23863161b2e718c9194ce7e37f1e424ee81d5aa69a346e68acfbbd
+F src/www/admin/acc/transactions/creator.php e909867efed5e03a1372607c2d57d815c9ac461d3bb1aaf22526bdb6de496b6c
+F src/www/admin/acc/transactions/delete.php 302c38dc9e46ea4c688605f8252e2bbbeb014d44450f1e07d5dcc174ed2a2efc
+F src/www/admin/acc/transactions/details.php 1a82cb539aba1cefae849e91333f263f78b4f7ccb2bd4e1c9c313fe5467f6b17
+F src/www/admin/acc/transactions/edit.php 8c9a45805a0afd6b975068f1dd1051c08827fef6cc9bd23bde8d56f78e8136bb
+F src/www/admin/acc/transactions/lock.php 20bd2034e7d7f94f657853f4ebcb2352d9a96c38bdb7cf87c206c63a343f1e25
+F src/www/admin/acc/transactions/new.php 66324e1269604038bfb8276006d83dfba168311dab4b5b939fa3a4749f2b3b4d
+F src/www/admin/acc/transactions/pending.php 6f7db72b7ec7aad39dfbdea2e1417f56ed90598a9418da75d62ab0f0c7d8f8f3
+F src/www/admin/acc/transactions/selector.php c32b47d2fdfde59e3f0dfe2f9d84ae35dc7c468bd87ce7c83a764638890dbc03
+F src/www/admin/acc/transactions/service_user.php c55459a2ecc5768548a7baa68f44d2af137dc8243f8eac0bbacd0adb459c595f
+F src/www/admin/acc/transactions/user.php b8ff73c47d6fdad975badc0585fd399fe1d628744c20b62a79e788aaff59c068
+F src/www/admin/acc/years/balance.php e69827e7493236088c1a9f7bcdc8c2511fbff75e3f0cb48b62b9904d25a0a0fe
+F src/www/admin/acc/years/close.php 19f4c7b62ec6bd98cba9ddd446ad5243572dc674c974da98ebfd2110c10c286c
+F src/www/admin/acc/years/delete.php a0b5106cf351ba4917c8379830714254cc2bf37c1ead208b6495c6fab11b20fd
+F src/www/admin/acc/years/edit.php 12a6439d201ac147eaa8fd39855d53a81300f18a7f73bff025489b7055205651
+F src/www/admin/acc/years/export.php 188de31dff824e5682fdd3fc9e50f2ad28fa513224f185efa99896ffcda4945b
+F src/www/admin/acc/years/first_setup.php 7a2a8e3723fb979b191f3d4750dda2cbd1267b566d14341bcb63dc0ef7d4c9c0
+F src/www/admin/acc/years/import.php 5e69b590e1a6367821093cff4f247d11cc548af20adf9a66ef4040abd2392213
+F src/www/admin/acc/years/index.php d91200350501d0b9c3f908e83b52bfc2460929027154a635abeeae74b31a7ce9
+F src/www/admin/acc/years/new.php 2bce3c1c2bf136e22c790ce4261b511dbcb87ee25b96c7f7376cb6874de7bd37
+F src/www/admin/acc/years/select.php 4ad57b28fed100d7d6fa0fbc975a70c86ad3b0050f68b07989d8c5ab99cecc32
+F src/www/admin/common/files/_preview.php e1c2013500cd5e64b8879c7d74aa6560fedac0f78f76ad3693a85c5c52fe197d
+F src/www/admin/common/files/delete.php f1535255a7103a67862ffb5d813a7c28329c33e10de95dd3ce47072346ce2732
+F src/www/admin/common/files/edit.php 82ff52989824382fdeec8d2a760f59e7446d39254963075d743a3a2f76787227
+F src/www/admin/common/files/history.php a3da49e4e2fc55073facaa02d282c7dedcc4fd7ea888fbcac5a7499339774443
+F src/www/admin/common/files/preview.php 57467471f7339070f6cc4e593f882d385275160e55e6b2c51ee2461f5bcbeeff
+F src/www/admin/common/files/rename.php 9a722ed83d3c52f3bc4d418ed7e36fe9cf3ec9cb23dec06fde950efc2d80733c
+F src/www/admin/common/files/share.php cc2b2fe5cd433c909f1f62e1a9b77d9f8edbb861a244aa2a9d58ff47c9d30f54
+F src/www/admin/common/files/upload.php 3ac3d647062bf54b277a5a6511116c933534eb197f84c0679fa246d0dc155061
+F src/www/admin/common/saved_searches.php f6a7dfbe4fb521a0dbfcaec8f2c063656173c7ec44b0e8478eec8198d6f7801c
+F src/www/admin/common/search.php 874b0d6ba447e2165521f3eeb66b65d5f9d08050e952a26d5c8f1344e90fb253
+F src/www/admin/config/_inc.php 38dbaa8eceba69256ce410069345adbfb22d8fe7682f9df4decdacc6468f5186
+F src/www/admin/config/advanced/api.php 0867da54514a62f8f2d20520cce6ad570fc0d1b8d493e4af61310c29fa8dc7cb
+F src/www/admin/config/advanced/audit.php 03c22ee4a5984848ce4aec9056a0d2e69c4e869398622136556c0f5ecefaffc1
+F src/www/admin/config/advanced/errors.php 11eac7346acc44f433896d8c298e4e4984513b3dd1d35a09868e98ad7361ffd7
+F src/www/admin/config/advanced/index.php 62c60bb5dda9d2cafee5f506ed81fb6cf91ca9ab25f0b0e614034b59fd8f723f
+F src/www/admin/config/advanced/reopen.php ee337b79851396fdae8ca2ffe341b26ff15e980daee08b2faba1459e9599ce24
+F src/www/admin/config/advanced/reset.php 0393a990855aa3af0398e29c30872e6b19d7ff155c8e0267cd99fcea3b6ccafb
+F src/www/admin/config/advanced/sql.php 39e06fc2830f26a8a9414885140d13b866df48b5803df9cd51b3fafb42e66dea
+F src/www/admin/config/advanced/sql_debug.php 57db4c8a1e2372b8c2fc3b99fb9987f5fd1f4c9619bda792cb42422cc211fc17
+F src/www/admin/config/backup/auto.php 41acbdd9ca793c1328e384adc431b2a6cd861230c79b667be3b3564b3f4eaafa
+F src/www/admin/config/backup/documents.php e6be7da76e1f751c6ef46b47d5c9fc189e1079166838a76953498d8885b27e0f
+F src/www/admin/config/backup/index.php 62bf45aec0e6e343b5200cb48c38f4961abe964558801d88e910d2296aac95d9
+F src/www/admin/config/backup/restore.php 5b2b274e78a1a2c6292b8bd39dd28232a0e752bd4972b516afa7f8e3fa3cec37
+F src/www/admin/config/backup/versions.php 5ff829ca122c68a95617e7e311370d419a36b491648a57502481fd3c5c19e2ac
+F src/www/admin/config/categories/delete.php 916faad60b44a231ce8d030efbc7ef59c85a906bd527c06093ed11039f09d0a1
+F src/www/admin/config/categories/edit.php ee348e18d4d44bec1c85015b2da557cf9136ea2d26a4b570a4173b64977af2f4
+F src/www/admin/config/categories/index.php 448c26ecf521369c846c739883bf63f6fbbc693cd91853f257313ab2cce7cc08
+F src/www/admin/config/custom.php 11ac0ff0852393501dcd33b6fb49c1538b0cc6f4b8b68ee31da2877d720da618
+F src/www/admin/config/disk_usage.php a2b18d7edebe5fac6b733b629b2b1a3fac20b32351f1b40d3c4e1b37d7f0eb8d
+F src/www/admin/config/donnees/import.php 63fd2e55bf602e3d5fe16d6e70777b313af7f87c465aafdfe2a435dbbc88d443
+F src/www/admin/config/edit_file.php 692b6bbf107750afefe073622930a2e141d593554ea782a95561b172cc9fd025
+F src/www/admin/config/ext/delete.php 2da7be8aa33eefbfe67d49e0b48b1364f8c69ac0a96fbb1dbe5ffbe1986af7cd
+F src/www/admin/config/ext/details.php 566bc1c5fa2e61eda4fb87efcf1dfbd25290a6746f50302b856789c69496244a
+F src/www/admin/config/ext/diff.php 234a1118d048e5c1f3f91917db8f462b2e4bb28548dc0641940fc66b4adabbd7
+F src/www/admin/config/ext/edit.php b5243bc9140fe85b972b9d133b98563ade7dfea9f616321dcad6f186ab99848e
+F src/www/admin/config/ext/import.php 964ad852bebff092c8692925ef1d53dadea1c709fe97668376dd370074795b14
+F src/www/admin/config/ext/index.php bb4dbd0fa845c5e02d67679804aff26fccfbc5433837a852fb174f0446cc637c
+F src/www/admin/config/ext/new.php 0961b290769a6173873ee4774a8d5c7d5ccf55dd280c11e117a4f4e6ad74b9fb
+F src/www/admin/config/fields/delete.php 263592cb30b7504f7a0b427cee61bcf5081f29fd8a49fc06065a5d2012b94044
+F src/www/admin/config/fields/edit.php f93c22353dde208d4d2847a7212ac019d1a0f79c6ab929f6aa77b902079b2d4b
+F src/www/admin/config/fields/index.php 99f1f4d7bb810ab71630a63bd2fe256a23ca24e8d3805b3f0396e5af9957066a
+F src/www/admin/config/fields/new.php bca14e9b81f76a107bde1b529133a5a73b81f3eaa5b6af877a2ac6258d7473c1
+F src/www/admin/config/index.php 4db930d9b14080adfef2ce9e4ec8957a9440820dabe00c56a212180dd2ade8a8
+F src/www/admin/config/server/index.php 7f7388c4fe053ba5d6df01b7f618d756174c80cd007ddc8364f3219f8bc7a66b
+F src/www/admin/config/upgrade.php c5bb2b07cf8b4a1ec6ac86b863e69f887f5a1f39f1e16511fc960c6a60baf40a
+F src/www/admin/config/users/field_selector.php f4671d4b51d890b704dcc3922fe0306068931d338a9938cc696bad1d358f0376
+F src/www/admin/config/users/index.php 2f0fb565c5c00361c3764b238648f75292741a774f1727c747f50cc1e6c36358
+F src/www/admin/docs/_inc.php ac1a2736b738ea049b291d091d4990de822a3dad9da8738c72ec37fc33721a9d
+F src/www/admin/docs/action.php defab0b85f30183c24d18b0bf257f9265223ab0fef09de8dab16c26bb4a5307b
+F src/www/admin/docs/index.php 4b81f3ee6581fe9c332372443e971e05541ce8575edd986b75131258079023c0
+F src/www/admin/docs/new_dir.php d9a2d4e8ea9da76cde050fcdf7f753a897d5615684b8e5eb279acbacb794600c
+F src/www/admin/docs/new_doc.php 82bd169bdaf9da94cd93835dbf22f242f4578ef360b2ad373920877760c82484
+F src/www/admin/docs/new_file.php 46f2bffa0060f6adf4fea3c8234bd989749ed61c5c0189a10b8edb985930072c
+F src/www/admin/docs/search.php 60c138d0ec74854f837509f53a7cf89051107b80005b4eb936a89c83dc5c7fb0
+F src/www/admin/docs/trash.php d65494e835637c234604220b64b2dc42fceb2750d2cd9edfc4e993516719bb5c
+F src/www/admin/handle_bounce.php ac546e03a21ec3687a12482fed3753a8e4632fab632297807c72afa9ef5394d6
+F src/www/admin/index.php 6888125b475826e000e8c5a9dbe2e63c1a13bfc572c2bf3da862ac94ab00cd37
+F src/www/admin/install.php ec203de9f710ce484103d5b623a0c1633c42cf296590049724d81064d9638444
+F src/www/admin/legal.php 0a9422c75dcd77fa25491251bcd50133701cedea52b375ef4bb04dad99400a57
+F src/www/admin/login.php 54c2d517c3c4593d25ce4d214bcfad2e30f642d7b83ed41659dbbd975041861c
+F src/www/admin/login_app.php d686b28a90aed936f5cdcf7ff636b56a870c072cfa1b1b971bcf9dccc6411176
+F src/www/admin/login_otp.php ac1615acf75604122a55095eaebd3fefa798258b3aa922ea05b462a112568f26
+F src/www/admin/logout.php 5976597893d20d84a0b61f11aeb59ef4132db8a3ee4abf5ee02f67da2f95f1a9
+F src/www/admin/manifest.php bfc8e82584902075944a114a09c28e22228633cab6d1d397cb9d527b7e6868a6
+F src/www/admin/me/_inc.php 640b9e5ede51b34b04e9ae12dd8ce86a5880c0d419667613afbf9616eb29a66b
+F src/www/admin/me/edit.php 3fea9f4c5895e7c9fc87821eeed975600f87dae95801c4c31523e4ae6cce5edc
+F src/www/admin/me/export.php 64f543bd103a8fd984c6038d56e3f2df7f7f8a49a249551296f7dc91ef2d9da0
+F src/www/admin/me/index.php 8a7bc848f78d5c99c98316d33cd3037d02aefb29a777df6164740d1eef48b952
+F src/www/admin/me/preferences.php 8ae1dd650e1f59fb3b1ecfe633ee7152a47e220ad45019220cee58e7464dc074
+F src/www/admin/me/security.php ed288514fbc34bddf0f0c519c296915d8f24a00d235734a603d72996588d9750
+F src/www/admin/me/services.php 01519cc713f0657c8f261dbb184975de3ed48a57fce0ebfb3ec0f4b6ad06b7b7
+F src/www/admin/optout.php a8e32e6d40ce7352c42607250ce195bdf0ecce812ff851df511efbd770839262
+F src/www/admin/password.php ba2d7caf0bfa0c87955adc16d18f91d43f5582dccd5b6666e0ac8cdfdc0df695
+F src/www/admin/services/_inc.php 2ee23bab80c9f3a0e11e0952eb9d4b7e08e6232ff3a8460f2b4f3e994a92a084
+F src/www/admin/services/delete.php 9505dfb7053aa03d3a4c923f2d6aa08626a9b40bfda37669846f42a43217b9cc
+F src/www/admin/services/details.php e1c3c955c42f1cd8541bba05ace6d18d2d06c982c336c134eddcb9c36161d150
+F src/www/admin/services/edit.php 6480b8b88194204854bf074dbd00e90db82aab47a1dd19012cf567b06453ce42
+F src/www/admin/services/fees/delete.php fb4d08069c863250ad7507a076f1c73ab8456fb42088d789cd45fd65e278c5c2
+F src/www/admin/services/fees/details.php 1a695a0e563656c0aad0de57e4deaa9fed8c0ff78022cffcd85478eb944e756b
+F src/www/admin/services/fees/edit.php 29f6e346ecd4120640f1bee44ed5e0bb6965cc0d328f5bf0c278fd4114228adf
+F src/www/admin/services/fees/index.php 66ec678b3e5abe953b0a10b3a07a58579f254c295d96fb13be41b6392e1627f9
+F src/www/admin/services/import.php 8b45124880cab3293a0358558e3fd69b337806d53616889a2a76629979591dc2
+F src/www/admin/services/index.php 1c9db068ebf1bc219861aea1be9e6b3248bff9a3846ce885338af0fbd3d10757
+F src/www/admin/services/reminders/delete.php 7fa839c1bc9119a3d1f76f536f2305bb2bae21ad7d76fe2a7c18b933d6be12ac
+F src/www/admin/services/reminders/details.php 7fd507e005d1a5f1cfa790059ee5075c89af3e03ac4ea20828e4dd92493cc285
+F src/www/admin/services/reminders/edit.php 1cec0cd9241342c30894289539220e73ace46cdd5e29f474449f52cae0fba338
+F src/www/admin/services/reminders/index.php cc83df448673e653b80c81b56830848d9dba6dad747184b8a7ab078f4d46f92d
+F src/www/admin/services/reminders/new.php 34e936f9f4e9f53cb9bf1aef8f418bf27804a926aff90a2375840a4ca116b94d
+F src/www/admin/services/reminders/preview.php b614113fe991e2df7b24761e7f3cc91bad48bdf6133eabc4ccf954e57c0278dc
+F src/www/admin/services/reminders/user.php 491234f0df7bf9ffebf8365fea031fa00750f6ce2c1935f2bc44d30783a8e224
+F src/www/admin/services/user/_form.php 6c01868431afe4dd2cdcf392657ad64ae1790c80ceb3fbdba3a22f4e7cd5b848
+F src/www/admin/services/user/add.php f211c6b3cd75cd0b82fb587937b7f1bbdbd98c057d38cd6e3c57e1b62fc1948f
+F src/www/admin/services/user/delete.php 258ad809c74de1c88ef22ff8c673276f73ada517513967b6603fe9826fa88360
+F src/www/admin/services/user/edit.php ac51e77e6c5f5bd531742d1b11caf3396270cfb4b65e7087195f5e17d5c43a8f
+F src/www/admin/services/user/index.php f8e9112ec82b953c448f64f09ce2aa35a09d592cc10ff0e10d659e591a9003f9
+F src/www/admin/services/user/link.php fadf825ef461c07fc967a4d93dcd95fbdfcdc28132a89cbe527debafd44b8831
+F src/www/admin/services/user/payment.php ffae6c1087e650bad783d3500a117b8087eadce740b3bd86f193ed9d8e15a79b
+F src/www/admin/services/user/subscribe.php f757166bd54bc23ad61f172357669333569fb6acee4f427700fbd8c3dc0b8428
+F src/www/admin/static/admin.css 8d4155645ad7b6efdad7439b160751f3a52fbf2c992d7ae9d0c91b741104dafb
+F src/www/admin/static/bg.png bb11a55a0d2630f7f225e01109c2fc8dddf1e9e6c989ef1d17d18852703f8438
+F src/www/admin/static/bg_dev.png 890be2cba33ae3a2e874ceb33167005cab3474a1e61810baad37ad2b46920dd1
+F src/www/admin/static/doc/api.html c49e5d658057d25634c7ecf725615b7670fa9882e108be1298177f354d5d1378
+F src/www/admin/static/doc/brindille.html 175273e804f9e2505e0a78a4f3550c51d8442924c43ab39bee66c8c1330914db
+F src/www/admin/static/doc/brindille_functions.html 0ab56fd11e704f4a023f5651defcd786f5d29bddfa50049e7c0a3d3fd398d2d2
+F src/www/admin/static/doc/brindille_modifiers.html e69ee882906f75890cd1474892259f4e011b0b00356b9de2bda40350992b94ec
+F src/www/admin/static/doc/brindille_sections.html 0d86861bfa5a41855a4620713effda34b830e86a85f1ae1bc4b22070885be07c
+F src/www/admin/static/doc/keyboard.html 55dd27ea5d6efc22202a7ed7b72544c1ec8a4b3a36f4f684f6d096f53ef62821
+F src/www/admin/static/doc/markdown.html dd127085d32ec6a1d20fdae1fa83c61ed7f38e02b331a6bb0850d3877bd63993
+F src/www/admin/static/doc/markdown_quickref.html a03b76328e070ee482bdc0e512fbe13c40eeafcbfecd20b98b2f8bcd59121e56
+F src/www/admin/static/doc/modules.html c143ed67d2bd4e5b741fdef76db80b9bf5af21d52d33e90487d3f0cca8ee0d45
+F src/www/admin/static/doc/shapes.png cb46b164ccc62541b94dfa202494a947b7147f083701bfe244db5c3243178d9e
+F src/www/admin/static/doc/skriv.html 1fdf3f7ccd0f1b3a2bf2eebed2c2d9f699b9d246bd320ccf1d56b2402e1273c4
+F src/www/admin/static/doc/web.html fe4a7a605925630db3cb5be5b13278261a5807bd683d98943a6f9a24d3209513
+F src/www/admin/static/favicon.png 18c14f40c3ed6d4d73ceb5319c55b05cff7b0677b52ad79ba28d69ef63036aee
+F src/www/admin/static/font/config.json 5f5d1fe7aaec836ed53d819e312987df71d5ac596edae6662a4ed81b8c8ccd6c
+F src/www/admin/static/font/paheko.css 73f799c34ebf03ef74668241b776c8f564409f0fc0b1f71147600dadcf00b0b5
+F src/www/admin/static/font/paheko.eot 10c942b5f295de52b6f26dc4f595ab669c206af85083b447ac19cd20116d4050
+F src/www/admin/static/font/paheko.svg aa7009cbc76c9ade6bb34f179e97004c542d121afdd3e8fb924a3cb7726b1a78
+F src/www/admin/static/font/paheko.ttf 620692b9a4ec07f335a71aeded66b2c82ddfe5205c5087d0bb93d844df349ffc
+F src/www/admin/static/font/paheko.woff 13bf306d815f923ee487f6db4af254b2589f9fcf0cd42f81f066600a69307f09
+F src/www/admin/static/font/paheko.woff2 60c9a88a0493737e514eb824ac01c6f289adba789986cce918abac3a2694bf84
+F src/www/admin/static/handheld.css 04e6d592984da5113d3cd6390d681c00dcdcde82aff859a7a89582b4fd2eb99c
+F src/www/admin/static/icon.png 1387768a8bcff7465be6bb1b4afa8d6d9c22126655288c1edfbb614d86e575f5
+F src/www/admin/static/pics/img_center.png c93c143b47b8d5a7e2caf3acce64251c4716b9a5
+F src/www/admin/static/pics/img_flow.png d247e7abf93a2c37d2ba402be08ec432bb0d7bc1
+F src/www/admin/static/pics/img_left.png e43aa44c9fe1b5508a2af6c1ec124654999b6c6b
+F src/www/admin/static/pics/img_right.png e2021d96729d6f28fcc946b5ed6742f6732a967e
+F src/www/admin/static/print.css 5b83453f12d3aea1f8d5f9106a85a9621bd2bf347003e21ba5269706e9476bbe
+F src/www/admin/static/scripts/accounting.js 7062c9800f0535a3cebec4f44f778d512c4e1806d160e51c1ba3f087bb4318ca
+F src/www/admin/static/scripts/accounting_setup.js 11af0b9e261c75dd30f0e17e532e3b3bf5a907873b09bd8fbbf7e6c8cea140a3
+F src/www/admin/static/scripts/accounts_list.js c39abd92100678388e0bebb16a0a2c5b041c44e07aaa032bb3693c837184404d
+F src/www/admin/static/scripts/advanced_search.js 38b7503d3ad679f89196e723516ba95d6c8ee67548ca56fe4408462afab89c10
+F src/www/admin/static/scripts/auto_logout.js 9e0c4517de170d9d24c29eaf8f73b21a783fe77ecb2f769eae6edf7ad3c12aaa
+F src/www/admin/static/scripts/code_editor.css f56ab02df74b8e494feccad9d611547dab875223272ccd4bb50ea1ab16557fe1
+F src/www/admin/static/scripts/code_editor.js 7f7383a25f337f58bc75fffc25c6303ef9862089fe6fe29d36ed20c8a992aaa3
+F src/www/admin/static/scripts/color_helper.js 5c89caa19b524d7f54eb9361968b043a641c2772c76e3472714f955ddc87373a
+F src/www/admin/static/scripts/config_fields.js 4eb6a786648c1d35b9a9c61b2973fccf0fb79460cf8feaa9e91e3762ff0c57a1
+F src/www/admin/static/scripts/dragdrop-table.js 9fa37ece4c56e3c3025a0cdc87993dba2beee4dedf12d853c85e899df9cf9d51
+F src/www/admin/static/scripts/file_drag.js 4bb2ca2e887ba23d1ec02ef95d7dee7a8eaa50b2f71bfcdfb0f4e71d425a1a16
+F src/www/admin/static/scripts/file_input.js 34388e3acf6a87b384f392c207509542cad1d8de641f9e636596ca001733efcc
+F src/www/admin/static/scripts/global.js c5de6536d3cc1ba2e542ee15a6b36062d2a8d9da336c6c8c81be0dee35ee2ba2
+F src/www/admin/static/scripts/homescreen.js 80dc4e87743bf7061484eb96cb9c546c27fe4b756935eca7851fd5aa53ab1e70
+F src/www/admin/static/scripts/lib/code_editor.min.js b93b17e625c99e610d6fd3d8dd17d95421f6c6ff74211448a52c2feed464e21a
+F src/www/admin/static/scripts/lib/datepicker2.min.js a4c81fc228bbb79c906350181d215a1ab36d44d97f632582b5314a0ba3529163
+F src/www/admin/static/scripts/lib/gibberish-aes.min.js 8c82e940cf5cf6fb68433bdc6c3a62c71583a60d
+F src/www/admin/static/scripts/lib/query_builder.js 2535abc10734bbdb7afeee16243047b67c067207f519821b6228fbbd66f9c057
+F src/www/admin/static/scripts/lib/text_editor.min.js efdbdc1b49c459f111e32708e17aa201749308f683a661c75fe33c6eb9b17033
+F src/www/admin/static/scripts/lib/unzipit.min.js 8729f4e48a77faee72b09d2503934f705fb5befab597262230c7c26836f52162
+F src/www/admin/static/scripts/lib/webdav.css e95322578b2e34bde605ec29148d22faa17c7c01c2216f42f17298a1a77788d7
+F src/www/admin/static/scripts/lib/webdav.fr.js 5101d45a9c20993e773d59bd185c3b93503fb4d2c4256f1f2c0bab6bc7bc9653
+F src/www/admin/static/scripts/lib/webdav.js 986d04833f6c2fbefb127ca4d6ea663f6977c71233bb996b7445cf9f48236c14
+F src/www/admin/static/scripts/password.js 6b890f3fa7aaef7c69b83a52cd6fc271984159983b52bc35bd0ead7dcad604e1
+F src/www/admin/static/scripts/selector.js 62447412c8b62259e1b26e3555204373bc6d17de17a694f25333c13e56c69d0a
+F src/www/admin/static/scripts/service_form.js 272a6811a71259018ee6713057e96dbcdd1cafb01d332950b8b4438ebb6b5a21
+F src/www/admin/static/scripts/unzip_restore.js 67bdb4b62bc91deb97f2ec9334a9647908d5d7a03b00eda0614c5940ad6131c9
+F src/www/admin/static/scripts/web_editor.css d40ba89e0042da03716a45e738870a87810bdd68719a99a442be455ee600af13
+F src/www/admin/static/scripts/web_editor.js 2a468ebe296331790911d59c5640d9844620a0a14cb67dc823d90b7f26c95fd2
+F src/www/admin/static/scripts/web_encryption.js 3ddbcf8aee1a21ecf5c76b969ba3a736442e40ec3f7407c23bb4ae8bdbf1b89d
+F src/www/admin/static/scripts/web_files.js bb326d9c953442cefb014dafd4ce785fe9e866d618af3497796c558905f9cde8
+F src/www/admin/static/scripts/web_gallery.js 945b4701755b4a3190cf4c436d2086a5e76bc3989f6ff028bd6dd1e03656320a
+F src/www/admin/static/styles/00-reset.css f7194bdd794e5c85bf67854be876408609a39d66cb36591db021218fc843172e
+F src/www/admin/static/styles/01-layout.css ceed4a07a76b4b1754397bc30e31f6d74694fd65126d99f5e27f5b821af98909
+F src/www/admin/static/styles/02-common.css 796f6907d659d94c8d6b9e34fe403090721a41e63862d91b95960d4d66a04d55
+F src/www/admin/static/styles/03-forms.css 98b68091275f1441772bb4bffa56d24ab57fe8903ae1e3757dd015545a3151c7
+F src/www/admin/static/styles/04-dialogs.css b54d6fcc0dbc2889a866637974a6b20b723ea656951b5f81ade29f724e127cab
+F src/www/admin/static/styles/05-navigation.css d163cfca22534f45eadf87a33b64cf00c3145b977d99018317153c8844ca13d9
+F src/www/admin/static/styles/06-tables-export.css e8aa7570c2347baaa726574c1b9ba1f1d8af26afa05ffedddc086e120cf70f22
+F src/www/admin/static/styles/07-tables.css 6e376f916abbf49fcb4dbaa4364f04fbe6eee2d86d81ad3072aeef52101b7373
+F src/www/admin/static/styles/10-accounting.css 7ceb2aea3c8c62d597d5690a537d39ede6260a0e86f67c07113d9d3fffab90a0
+F src/www/admin/static/styles/config.css c03629a39ab9a4c0c22aecba2bd3a5ff25531d6d23910a4f3d9998f13f17bb99
+F src/www/admin/static/styles/web.css 2a54776ca23bdf24ad898ed225a13d6332c9aae1284ccacac71e3cd71850c44f
+F src/www/admin/upgrade.php 513c93bccadb2aa60f0a1357e663d41eafcd2bf4e5c5d0cd73afb344700c1132
+F src/www/admin/users/_inc.php abdbaa2f4ec0c59547ae197a8d648db4027dfd6c1b2de44090a85643dc73adb5
+F src/www/admin/users/action.php ee26f54d10acca46fae2bfc23c478abaee4dd98bed45662dd1aa5b27ef18aa59
+F src/www/admin/users/delete.php c708200474b17b8b7629d3b4ba663761e61e498b4725cdfb20fb4222825bc96d
+F src/www/admin/users/details.php c1d42dd32cd69c9486d75eedca25a6cc5ec50b83b0763523ddc20a7ffddbc4d1
+F src/www/admin/users/edit.php af964e43999c4d546360aa5055d595816c73b0dd02c24466a54f7fc647ac2d04
+F src/www/admin/users/edit_security.php 675d9bee134106cba182829560ef928d1acdfb8ac5dbd94877246f916d519f60
+F src/www/admin/users/import.php 09125f9e764153dac1ed5dbcf248f6fb03bbd01e9b4db3e6d4506ea49f101b08
+F src/www/admin/users/index.php 08e654e48d389a4d19e59547f410ddf61a7f412824c581ea908529fe16b83f39
+F src/www/admin/users/log.php 7ca90afa14599af6c6dc622392b405d22bf6d53b43c219f2f6d0a974028bb4f6
+F src/www/admin/users/mailing/_inc.php 57578032cef46ba1018a6d63b2a9d5c521215e3f474b3b60943bb931abd1a1a3
+F src/www/admin/users/mailing/block.php 0a3c768560a5f44f08cecebd15853abb6cb5e4e0569049d3fae5511a9d5715cd
+F src/www/admin/users/mailing/delete.php 829abccebc1d9f907b56987c450710fbebef2ed38052109cc7882f1d9ec492ce
+F src/www/admin/users/mailing/details.php 4d906518380e5b008eab1fddd91d711b47f14d86313a4a76c41df1bc251a0b22
+F src/www/admin/users/mailing/index.php 1da23b3440d917cdb0757c036ab7a43e8a16864bc9147b769431db98335e788a
+F src/www/admin/users/mailing/new.php 9438ffe5e76306bd9e09a06f962ce9b5483b58b54303beac91509793ba7735b9
+F src/www/admin/users/mailing/optout.php f2e3a3544878b7e15e9d268e8a56627f726e1aa4317e37a7274070a6d2c0973f
+F src/www/admin/users/mailing/recipient_data.php 7eb9cf0ebd67d3d7626e6dcee9faa3607de7fdf292c4437ff205642ed38dc24c
+F src/www/admin/users/mailing/recipients.php 0eab354cfc17b56a7eb2dbdd126b957464eac120fcd0add5ca840f71c75af2bd
+F src/www/admin/users/mailing/rejected.php 65ed7e261c552c9ce5e01abd171a8e973f55ea05f071463a69b0a3c5ba03b6b7
+F src/www/admin/users/mailing/verify.php 0d8c00532429618bdb1ca518d761c874132e039027901a1555cb454e24331563
+F src/www/admin/users/mailing/write.php 70659ff0aaffb58ea98585036a4c6f975ef71ae61abd0d2bdf94dc9b1a6f4246
+F src/www/admin/users/message.php ef50b2da49b35e1865b21073cb28de7efb8c90dd7eb90cfba8754ccb65ab5ba7
+F src/www/admin/users/new.php 85a684dbc6faa52ad3e2b34b4c75cbedb0cc2a9cf9360b8f97a7f8f3d53073aa
+F src/www/admin/users/saved_searches.php cbae2bdad6c708a6a0af69e1c867c7728315e8bf34a1461c69d1e7d40bfdf71d
+F src/www/admin/users/search.php 7d8a0ea291962cb262c1bf0aa75ac6fbc3de295b2f1a2c6bb6aaf4a3bbcaeba6
+F src/www/admin/users/selector.php d06d23c96d9d0001bb665307f9836c1dfa29527108aa05e3366a13f238ba0405
+F src/www/admin/web/_attach.php 5ce5c5a6ce3ea74583d084dbf70837cdfe11f6e98e5b2e4e13fcf4612ef93d9c
+F src/www/admin/web/_inc.php 61b38f575aab20dbfea9a4e16a3583d4e8fb4dda74015253296e23e6230ade5b
+F src/www/admin/web/_selector.php acf3fd557a77f05c39ad81736d16621b8d8a714ebf79ebef5da2b8d9c3ae36aa
+F src/www/admin/web/all.php 6497e3ef800f01db03e0cf88c47415cdad8531bd38381182ebe19d061850eaa3
+F src/www/admin/web/delete.php 6c70805189cbe52279a094db532b1ebfd75eec55ae4b24ae550687e2cb822042
+F src/www/admin/web/edit.php 82ce1edb8719890cf118fc1b878b5047f74467a6a0c5a117c7ad8a51180b11b3
+F src/www/admin/web/index.php 1bb6321afc904b6a23fb092482bcc701141177fb3709788cd34b10eacd7dab6b
+F src/www/admin/web/new.php f938b8c559cd001605ea6911b2702dfddfa29e751079f9cc9fb1a0b95027c29d
+F src/www/admin/web/search.php 98e07fd20629e0585ed4b4664bd9fc6b44e8dccb6d6cb28b8b52d707becd68a9
+F src/www/index.php 7864c3a4fdac4b2976337429d1e8c00f45889f2e87ee3d6e6eabe264b9b2b24c
+F tests/phpstan.config.php 584e38c18ba41beddd42f3950fea3507db5ca1c3a7acf8e1463877b202f74622
+F tests/phpstan.neon a05dee1190e8b78ccf641bda959d0470082e65e6bce065fb275981bac8103e7b
+F tests/psalm.xml cfd86f9bdcd8d968674aa49bd509b8591bd82fa73690c2e92ac1a07f74c3d12c
+F tests/run.php 43c2b61a01dd66ef5dec76d3164a5313f113ad4f
+F tests/unit_tests/01_basic/db.php 66b9155618cf937761664cbe5fa03e9dc830a68a
+F tests/unit_tests/01_basic/paths.php 819d6c04bf6a904ddf21a211feb451f6faf1045a19fe91a99fd5937b76893fe0
+F tests/unit_tests/01_basic/version.php 3d5d415dd958f5f7504c503fb8ae8aa4d37e8618c77b366618e5cf11ad2cbf6a
+F tests/unit_tests/02_accounting/money.php b5a7b488bbeba2c166982967b2f613190828ab1a
+F tools/categories_as_projects.sh 02cc9257edd231e54c643cbddb0cd119e09bf3d9 x
+F tools/construire_plan_comptable.php 4618b08fe874943c7c9cd161b3a90fd85695453f
+F tools/doc_md_to_html.php 72736f48da3c23135a273d1c848e71d52de4ebdc1d60d0e3a36e4b4574b19ce9
+F tools/extract-credit-mutuel-csv.php 853d37e661a71c9160d27897930f57718d52839dd57a7c9c7a46f994044852fc
+F tools/extract-paypal-csv.php bc690606745e3d6b14352a4e8c89d3a01e6dc11c4b7cad3ea8fdd1d49b96c190
+F tools/factory/config.local.php c21a4081a63a046ba40cca869bc0a324e9c598e3bfd3e8b66fce25632772c7f1
+F tools/factory/factory_cron.sh c03a756dec91466571735bea3bdab332e4d8a964bce75b8788d3c40aeacb208a x
+F tools/factory/factory_cron_emails.sh 7427ba5d8c347a12be09f1017abc04e12a5726eae96c84bcae26dc9151bdaa26
+F tools/factory/factory_upgrade.sh 21327b0cf6c61a5301588ad990664efd5e365a3e6bdb63073a8adb0b6876e2fe
+F tools/fossil-verify.sh abbb39b79418d14364a689bd381686e217df35c9ba8dfbf4f7f18e9feebd95df x
+F tools/make_installer.php 8b0d272bf113a74dc9cce91213bc875229858010a8f1a3830151755af3520d7d
+F tools/plugin_check_0.8.php df13be68be836729f96a26ec5cb33133d5e6bbce
+P c64b200ed0d55f953085918860f994bc493073f369a662b16b0237529a4f59da
+R cd6c0ed44b4e707dd10ba8d5b899143a
+T +sym-stable *
+U bohwaz
+Z 16eef90f5a5efddd361afd30c6f510a9
+# Remove this line to create a well-formed Fossil manifest.
+-----BEGIN PGP SIGNATURE-----
+
+iQIzBAEBCgAdFiEExJkkebouil1CwYIBksnacbiI6jQFAmWZWWAACgkQksnacbiI
+6jRrsBAAjj7Qakj2W6+2nL409awN9Hh+z442p85n6mwMMEIVo6mgV5XqhxKBTvlX
+posVcQkgR7EuPqzh6C5SfL43CAzZtWIjPrNuD+YtocXC1CQxp6OVbN7AzJTP//xq
+6O1HqI+Rld6Owk4aUmWze6p26fsYQ3we0bM35tZe6D7Kg2UINcpWqaKUH3OwObuO
+pl5Emsh4XZSPtF4XQ7GtLuwznOOmtRwY9r0Q7mIwecOMDWz1XRuXhLnaz9a+wc+z
+3OtLBj+DEiClrJcSV6jt58smkvppXFMAx6fP8CsnhUvB/kuKYcyN0vleLzepJIbY
+8E98KqR5WaF8XqZPtR2TzgwBEUtFxOco3CPJy5fPoNLGYvh/3US3UG3ksp3FTaY2
+BEH/wDqcx9/PLmWaJ9Ug8NSuo2STu3opG4xxNBCvuRxeOwdai6gJN0zTi5YS44nl
+MBh5zN9zjJ2TT3JnxkEC9U0ryec9dc8VbM1cM+Ww7bEPFNvA5/mYHBTbN1HdM3Rw
+g7Pbhqh/ERunRtz32NW8w10U9FtGHp8cjKOk7AX3gCdggmJRhDlLcmD2ZON/gmPu
+z2t6nYFFtmANXFwr5stfLkEIEy4byBEwX7V/IfAanfC7lAioy++mfVlvyJLxKJG1
+c7wPxLZf9Wkdh2F4t3UXMwWihtdDKzMfKxe7NtRNbvQC4LNzEPs=
+=/3/y
+-----END PGP SIGNATURE-----
diff --git a/manifest.uuid b/manifest.uuid
new file mode 100644
index 0000000..9a7cbca
--- /dev/null
+++ b/manifest.uuid
@@ -0,0 +1 @@
+e39bc64066ab87376ad4450bb15dc71ed8f142d78e16d61abcce7e8655a72283
diff --git a/remove_check_fees_on_service_user_import.patch b/remove_check_fees_on_service_user_import.patch
new file mode 100644
index 0000000..d5842dd
--- /dev/null
+++ b/remove_check_fees_on_service_user_import.patch
@@ -0,0 +1,16 @@
+Index: src/include/lib/Paheko/Services/Services_User.php
+==================================================================
+--- src/include/lib/Paheko/Services/Services_User.php
++++ src/include/lib/Paheko/Services/Services_User.php
+@@ -142,11 +142,11 @@
+
+ $id_fee = null;
+
+ if (!empty($row->fee)) {
+ foreach ($fees as $fee) {
+- if (strcasecmp($fee->label, $row->fee) === 0 && $fee->id === $id_service) {
++ if (strcasecmp($fee->label, $row->fee) === 0) {
+ $id_fee = $fee->id;
+ break;
+ }
+ }
diff --git a/src/.htaccess.www b/src/.htaccess.www
new file mode 100644
index 0000000..711ce5b
--- /dev/null
+++ b/src/.htaccess.www
@@ -0,0 +1,34 @@
+# Désactiver le multiviews (conflit avec /admin/plugin.php) et les index (sécurité)
+Options -MultiViews -Indexes
+DirectoryIndex disabled
+DirectoryIndex index.php index.html
+
+# Au cas où
+
+ RedirectMatch 403 /include/
+ RedirectMatch 403 /templates/
+ RedirectMatch 403 ^/scripts/
+ RedirectMatch 403 /data/
+ RedirectMatch 403 /.*\.log
+ RedirectMatch 403 /(README|VERSION|COPYING|Makefile|cron\.php)
+ RedirectMatch 403 /config\.(.*)\.php
+ RedirectMatch 403 /sous-domaine\.html
+ RedirectMatch 403 _inc\.php
+
+
+# Redirection dynamique, pour les installations sans vhost dédié
+# Objectif: supprimer le /www/ de l'URL
+# Note: il est probable qu'il soit nécessaire d'adapter la configuration
+# à votre hébergeur !
+
+
+ RewriteEngine on
+ ## Remplacer dans les lignes suivantes
+ ## /paheko/ par le nom du sous-répertoire où est installé Paheko
+ RewriteBase /paheko/
+ FallbackResource /paheko/www/_route.php
+
+ ## Ne pas modifier les lignes suivantes, les décommenter simplement !
+ RewriteCond %{REQUEST_URI} !www/
+ RewriteRule ^(.*)$ www/$1 [QSA,L]
+
diff --git a/src/.php-version b/src/.php-version
new file mode 100644
index 0000000..5029165
--- /dev/null
+++ b/src/.php-version
@@ -0,0 +1 @@
+8.1.26
diff --git a/src/Makefile b/src/Makefile
new file mode 100644
index 0000000..6c11447
--- /dev/null
+++ b/src/Makefile
@@ -0,0 +1,114 @@
+.PHONY: dev-server release deps publish check-dependencies test minify phpstan www htaccess modules installer plugins
+KD2_FILE := https://fossil.kd2.org/kd2fw/uv/KD2-7.4.zip
+MODULES_FILE := https://fossil.kd2.org/paheko-modules/zip/trunk/modules.zip
+PLUGINS_FILE := https://fossil.kd2.org/paheko-plugins/zip/trunk/plugins.zip
+
+deps:
+ $(eval TMP_KD2=$(shell mktemp -d))
+ #cd ${TMP_KD2}
+
+ wget ${KD2_FILE} -O ${TMP_KD2}/kd2.zip
+
+ rm -rf "include/lib/KD2"
+ unzip "${TMP_KD2}/kd2.zip" -d include/lib
+
+ rm -rf ${TMP_KD2}
+
+modules:
+ wget ${MODULES_FILE} -O modules.zip
+ unzip -u modules.zip
+ rm -f modules.zip
+
+plugins:
+ wget ${PLUGINS_FILE} -O plugins.zip
+ unzip -u plugins.zip -d data
+ rm -f plugins.zip
+
+dev-server:
+ php -S localhost:8082 -d upload_max_filesize=256M -d post_max_size=256M -t www www/_route.php
+
+test:
+ find . -name '*.php' -not -path './data/*' -print0 | xargs -0 -n1 php -l > /dev/null
+
+phpstan:
+ phpstan.phar analyze -c ../tests/phpstan.neon include www
+
+psalm:
+ @# This is required by psalm, but useless
+ @-mkdir vendor
+ @-echo '{"require": {}}' > vendor/autoload.php
+ psalm.phar -c ../tests/psalm.xml
+
+doc:
+ php ../tools/doc_md_to_html.php
+
+htaccess:
+ # Removing DOCUMENT_ROOT is important for the cache when using .htaccess, keep it!
+ cat apache-vhost.conf \
+ | sed 's/#RewriteBase/RewriteBase/' \
+ | sed 's/RewriteCond %{DOCUMENT_ROOT}%{REQUEST_/RewriteCond %{REQUEST_/' \
+ > www/.htaccess
+ cat apache-htaccess.conf >> www/.htaccess
+
+release: test minify doc
+ $(eval VERSION=$(shell cat VERSION))
+ rm -rf /tmp/paheko-build
+ mkdir -p /tmp/paheko-build
+ fossil zip ${VERSION} /tmp/paheko-build/src.zip --name paheko
+ unzip -d /tmp/paheko-build /tmp/paheko-build/src.zip
+ cd include/lib; \
+ rsync --files-from=dependencies.list -r ./ /tmp/paheko-build/paheko/src/include/lib/
+ mv www/admin/static/mini.css /tmp/paheko-build/paheko/src/www/admin/static/admin.css
+ # Generate .htaccess file
+ cd /tmp/paheko-build/paheko/src && make htaccess
+ cd /tmp/paheko-build/paheko/src/www/admin/static; \
+ rm -f font/*.css font/*.json
+ cd /tmp/paheko-build/paheko/src; \
+ rm -f Makefile include/lib/KD2/data/countries.en.json
+
+ # Download modules and only keep the stable ones
+ cd /tmp/paheko-build/paheko/src && \
+ wget https://fossil.kd2.org/paheko-modules/zip/trunk/modules.zip && \
+ unzip -o modules.zip && \
+ rm -rf `find modules/ -name 'ignore' -type f -execdir pwd \;` && \
+ rm -f modules.zip
+
+ # Download plugins and only keep the stable ones
+ cd /tmp/paheko-build/paheko/src/data && \
+ wget https://fossil.kd2.org/paheko-plugins/zip/dev/plugins.zip && \
+ unzip -o plugins.zip && \
+ rm -rf `find plugins/ -name 'ignore' -type f -execdir pwd \;` && \
+ rm -f plugins.zip
+
+ mv /tmp/paheko-build/paheko/src /tmp/paheko-build/paheko-${VERSION}
+ tar czvfh ../build/paheko-${VERSION}.tar.gz --hard-dereference -C /tmp/paheko-build paheko-${VERSION}
+
+deb:
+ cd ../build/debian; ./makedeb.sh
+
+windows:
+ cd ../build/windows; make installer
+
+publish: installer release deb windows
+ $(eval VERSION=$(shell cat VERSION))
+ cd ../build && gpg --armor -u dev@paheko.cloud --detach-sign paheko-${VERSION}.tar.gz
+ fossil uv sync
+ #fossil uv ls | fgrep -v 'paheko-0.8.5' | grep '^paheko-.*\.(tar\.bz2|deb)' | xargs fossil uv rm
+ cd ../build && \
+ fossil uv add paheko-${VERSION}.tar.gz && \
+ fossil uv add paheko-${VERSION}.tar.gz.asc
+ cd ../build/debian && fossil uv add paheko-${VERSION}.deb
+ cd ../tools && fossil uv add install.php && rm install.php
+ fossil uv sync
+ cd ../build/windows && make publish
+
+check-dependencies:
+ grep -hEo '^use \\?KD2\\[^; ]+|\\KD2\\[^\(:; ]+' -R include/lib/Garradin www | sed -r 's/^use \\?KD2\\|^\\KD2\\//' | sort | uniq
+
+installer:
+ cd ../tools && php make_installer.php > install.php
+
+minify:
+ cat `ls www/admin/static/styles/[0-9]*.css` | sed 's/\.\.\///' > www/admin/static/mini.css
+ @# Minify is only gaining 500 gzipped bytes (4kB uncompressed) but making things hard to read/hack
+ @#yui-compressor --nomunge www/admin/static/mini.css -o www/admin/static/mini.css
diff --git a/src/VERSION b/src/VERSION
new file mode 100644
index 0000000..95b25ae
--- /dev/null
+++ b/src/VERSION
@@ -0,0 +1 @@
+1.3.6
diff --git a/src/apache-htaccess.conf b/src/apache-htaccess.conf
new file mode 100644
index 0000000..d536430
--- /dev/null
+++ b/src/apache-htaccess.conf
@@ -0,0 +1,21 @@
+# Sinon le reste marchera, sauf les clients OC/NC
+
+ # FallbackResource has a bug before Apache 2.4.15, requiring to disable DirectoryIndex
+ # see https://bz.apache.org/bugzilla/show_bug.cgi?id=58292
+ # and https://serverfault.com/questions/559067/apache-hangs-for-five-seconds-with-fallbackresource-when-accessing
+ DirectoryIndex disabled
+ DirectoryIndex index.php
+
+ # Redirect non-existing URLs to the router
+ FallbackResource /_route.php
+
+ # FallbackResource does not work for URLs ending with ".php"
+ # see https://stackoverflow.com/a/66136226
+ ErrorDocument 404 /_route.php
+
+ # NextCloud/ownCloud clients cannot work without mod_rewrite
+
+ Redirect 501 /remote.php
+ Redirect 501 /status.php
+
+
diff --git a/src/apache-vhost.conf b/src/apache-vhost.conf
new file mode 100644
index 0000000..5124d27
--- /dev/null
+++ b/src/apache-vhost.conf
@@ -0,0 +1,112 @@
+Options -Indexes -Multiviews +FollowSymlinks
+
+DirectoryIndex index.php index.html
+
+# Some security
+
+ RedirectMatch 404 _inc\.php
+
+
+# Recommended, if you have xsendfile module
+# see https://tn123.org/mod_xsendfile/
+# Also enable X-SendFile in config.local.php
+#
+#
+#
+# XSendFile On
+# XSendFilePath /home/paheko/
+#
+#
+
+# This is to avoid caching mismatch when using mod_deflate
+# see https://github.com/symfony/symfony-docs/issues/12644
+
+ FileETag None
+
+
+# Allow uploads up to 256 MB where it's required
+
+
+ php_value post_max_size 256M
+ php_value upload_max_filesize 256M
+
+
+
+ php_value post_max_size 256M
+ php_value upload_max_filesize 256M
+
+
+
+
+ SetEnv PHP_VALUE "post_max_size=256M"
+
+ # There is no way to pass multiple PHP ini settings via PHP_VALUE :-(
+ # so we use PHP_ADMIN_VALUE here. It works unless we have more than 2 settings to change.
+ SetEnv PHP_ADMIN_VALUE "upload_max_filesize=256M"
+
+
+
+
+
+ AddDefaultCharset utf-8
+ AddCharset utf-8 .html .css .js .txt
+
+ RewriteEngine On
+ #RewriteBase /
+
+ RewriteRule \.cache - [R=404]
+ RewriteRule \.well-known/assetlinks.json - [R=404]
+
+ # Stop rewrite for /admin URL, except for /admin/p/ (plugins)
+ RewriteCond %{REQUEST_URI} ^/?admin(?!/p/)
+ RewriteRule ^ - [END]
+
+ # Skip directly to router if possible
+ # Do not try cache if method is not GET or HEAD
+ RewriteCond %{REQUEST_METHOD} !GET|HEAD [OR]
+
+ # Do not try to get from cache if URL is private, or belongs to modules/plugins
+ RewriteCond %{REQUEST_URI} ^/admin|^/?(?:dav|wopi|p|m|api)/|\.php$ [OR]
+
+ # NextCloud routes
+ RewriteCond %{REQUEST_URI} ^/?(?:remote\.php|index\.php|ocs|avatars|status\.php)/ [OR]
+
+ # Private files are not part of the cache
+ RewriteCond %{REQUEST_URI} ^/?(?:documents|user|transaction|ext|attachments|versions)/
+
+ # Skip, go to router directly
+ RewriteRule ^ - [skip=8]
+
+ # Store MD5 hashes in environment variables
+ RewriteCond %{REQUEST_URI} ^(.+)(?:\?|$)
+ RewriteRule ^ "-" [E=CACHE_URI:%1]
+ # Extract file extension (required for Apache to serve the correct mimetype)
+ RewriteCond %{REQUEST_URI} (\.[a-z0-9]+)(?:\?|$)
+ RewriteRule ^ "-" [E=CACHE_EXT:%1]
+ # If no extension, default to .html
+ RewriteCond %{REQUEST_URI} !\.[a-z0-9]+(?:\?|$)
+ RewriteRule ^ "-" [E=CACHE_EXT:.html]
+ RewriteCond expr "md5(%{ENV:CACHE_URI}) =~ /^(.+)$/"
+ RewriteRule ^ "-" [E=CACHE_URI_MD5:%1]
+ RewriteCond expr "md5(tolower(%{HTTP_HOST})) =~ /^((.{2}).+)$/"
+ RewriteRule ^ "-" [E=CACHE_HOST_MD5:%1,E=CACHE_HOST2_MD5:%2]
+ RewriteCond /.cache/%{ENV:CACHE_HOST_MD5}/%{ENV:CACHE_URI_MD5} (.+)
+ RewriteRule ^ "-" [E=CACHE_PATH:%1]
+
+ # Serve symlinks for files
+ RewriteCond %{QUERY_STRING} ="" [OR]
+ RewriteCond %{QUERY_STRING} ^h=[a-f0-9]+$
+ RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} -l
+ RewriteRule ^ %{ENV:CACHE_PATH}%{ENV:CACHE_EXT} [END]
+
+ # Do not try cache for pages if user is logged-in
+ RewriteCond %{HTTP_COOKIE} !pko=
+ # Serve static HTML pages
+ RewriteCond %{QUERY_STRING} =""
+ RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} -f
+ RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} !-l
+ RewriteRule ^ %{ENV:CACHE_PATH}%{ENV:CACHE_EXT} [END]
+
+ # Redirect to router
+ RewriteRule ^ /_route.php [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},END,QSA]
+
diff --git a/src/config.dist.php b/src/config.dist.php
new file mode 100644
index 0000000..adc06b5
--- /dev/null
+++ b/src/config.dist.php
@@ -0,0 +1,905 @@
+ ['_name' => 'bohwaz'],
+ * 'permissions' => ['users' => 9, 'config' => 9]
+ * ];
+ *
+ * Défault : null (connexion automatique désactivée)
+ */
+
+//const LOCAL_LOGIN = null;
+
+/**
+ * Autoriser (ou non) l'import de sauvegarde qui a été modifiée ?
+ *
+ * Si mis à true, un avertissement et une confirmation seront demandés
+ * Si mis à false, tout fichier SQLite importé qui ne comporte pas une signature
+ * valide (hash SHA1) sera refusé.
+ *
+ * Ceci ne s'applique qu'à la page "Sauvegarde et restauration" de l'admin,
+ * il est toujours possible de restaurer une base de données non signée en
+ * la recopiant à la place du fichier association.sqlite
+ *
+ * Défaut : true
+ */
+
+//const ALLOW_MODIFIED_IMPORT = true;
+
+/**
+ * Répertoire où se situe le code source de Paheko
+ *
+ * Défaut : répertoire racine de Paheko (__DIR__)
+ */
+
+//const ROOT = __DIR__;
+
+/**
+ * Répertoire où sont situées les données de Paheko
+ * (incluant la base de données SQLite, les sauvegardes, le cache, les fichiers locaux et les plugins)
+ *
+ * Défaut : sous-répertoire "data" de la racine
+ */
+
+//const DATA_ROOT = ROOT . '/data';
+
+/**
+ * Répertoire où est situé le cache,
+ * exemples : graphiques de statistiques, templates Brindille, etc.
+ *
+ * Défaut : sous-répertoire 'cache' de DATA_ROOT
+ */
+
+//const CACHE_ROOT = DATA_ROOT . '/cache';
+
+/**
+ * Répertoire où est situé le cache partagé entre instances
+ * Paheko utilisera ce répertoire pour stocker le cache susceptible d'être partagé entre instances, comme
+ * le code PHP généré à partir des templates Smartyer.
+ *
+ * Défaut : sous-répertoire 'shared' de CACHE_ROOT
+ */
+
+//const SHARED_CACHE_ROOT = CACHE_ROOT . '/shared';
+
+/**
+ * Motif qui détermine l'emplacement des fichiers de cache du site web.
+ *
+ * Le site web peut créer des fichiers de cache pour les pages et catégories.
+ * Ensuite le serveur web (Apache) servira ces fichiers directement, sans faire
+ * appel au PHP, permettant de supporter beaucoup de trafic si le site web
+ * a une vague de popularité.
+ *
+ * Certaines valeurs sont remplacées :
+ * %host% = hash MD5 du hostname (utile en cas d'hébergement de plusieurs instances)
+ * %host.2% = 2 premiers caractères du hash MD5 du hostname
+ *
+ * Utiliser NULL pour désactiver le cache.
+ *
+ * Défault : CACHE_ROOT . '/web/%host%'
+ *
+ * @var null|string
+ */
+
+//const WEB_CACHE_ROOT = CACHE_ROOT . '/web/%host%';
+
+/**
+ * Emplacement du fichier de base de données de Paheko
+ *
+ * Défaut : DATA_ROOT . '/association.sqlite'
+ */
+
+//const DB_FILE = DATA_ROOT . '/association.sqlite';
+
+/**
+ * Emplacement de stockage des plugins
+ *
+ * Défaut : DATA_ROOT . '/plugins'
+ */
+
+//const PLUGINS_ROOT = DATA_ROOT . '/plugins';
+
+/**
+ * Signaux système
+ *
+ * Permet de déclencher des signaux sans passer par un plugin.
+ * Le fonctionnement des signaux système est strictment identique aux signaux des plugins.
+ * Les signaux système sont exécutés en premier, avant les signaux des plugins.
+ *
+ * Format : pour chaque signal, un tableau comprenant une seule clé et une seule valeur.
+ * La clé est le nom du signal, et la valeur est la fonction.
+ *
+ * Défaut: [] (tableau vide)
+ */
+//const SYSTEM_SIGNALS = [['files.delete' => 'MyNamespace\Signals::deleteFile'], ['entity.Accounting\Transaction.save.before' => 'MyNamespace\Signals::saveTransaction']];
+
+/**
+ * Adresse URI de la racine du site Paheko
+ * (doit se terminer par un slash)
+ *
+ * Défaut : découverte automatique à partir de SCRIPT_NAME
+ */
+
+//const WWW_URI = '/asso/';
+
+/**
+ * Adresse URL HTTP(S) publique de Paheko
+ *
+ * Défaut : découverte automatique à partir de HTTP_HOST ou SERVER_NAME + WWW_URI
+ * @var null|string
+ */
+
+//const WWW_URL = 'http://paheko.chezmoi.tld' . WWW_URI;
+
+/**
+ * Adresse URL HTTP(S) de l'admin Paheko
+ *
+ * Note : il est possible d'avoir un autre domaine que WWW_URL.
+ *
+ * Défaut : WWW_URL + 'admin/'
+ * @var null|string
+ */
+
+//const ADMIN_URL = 'https://admin.paheko.chezmoi.tld/';
+
+/**
+ * Affichage des erreurs
+ * Si "true" alors un message expliquant l'erreur et comment rapporter le bug s'affiche
+ * en cas d'erreur. Sinon rien ne sera affiché.
+ *
+ * Défaut : TRUE (pour aider le debug de l'auto-hébergement)
+ *
+ * Il est fortement conseillé de mettre cette valeur à FALSE en production !
+ */
+
+//const SHOW_ERRORS = false;
+
+/**
+ * Envoi des erreurs par e-mail
+ *
+ * Si renseigné, un email sera envoyé à l'adresse indiquée à chaque fois qu'une erreur
+ * d'exécution sera rencontrée.
+ * Si "false" alors aucun email ne sera envoyé.
+ * Note : les erreurs sont déjà toutes loguées dans error.log à la racine de DATA_ROOT
+ *
+ * Défaut : false
+ */
+
+//const MAIL_ERRORS = false;
+
+/**
+ * Envoi des erreurs à une API compatible AirBrake/Errbit/Paheko
+ *
+ * Si renseigné avec une URL HTTP(S) valide, chaque erreur système sera envoyée
+ * automatiquement à cette URL.
+ *
+ * Si laissé à null, aucun rapport ne sera envoyé.
+ *
+ * Paheko accepte aussi les rapports d'erreur venant d'autres instances.
+ *
+ * Pour cela utiliser l'URL https://login:password@paheko.site.tld/api/errors/report
+ * (voir aussi API_USER et API_PASSWORD)
+ *
+ * Les erreurs seront ensuite visibles dans
+ * Configuration -> Fonctions avancées -> Journal d'erreurs
+ *
+ * Défaut : null
+ */
+
+//const ERRORS_REPORT_URL = null;
+
+/**
+ * Template HTML d'erreur personnalisé (en production)
+ *
+ * Si SHOW_ERRORS est à FALSE un message d'erreur générique (sans détail technique)
+ * est affiché. Il est possible de personnaliser ce message avec cette constante.
+ *
+ * Voir include/init.php pour le template par défaut.
+ */
+
+// const ERRORS_TEMPLATE = null;
+
+/**
+ * Loguer / envoyer par mail les erreurs utilisateur ?
+ *
+ * Si positionné à 1, *toutes* les erreurs utilisateur (champ mal rempli dans un formulaire,
+ * formulaire dont le token CSRF a expiré, etc.) seront loguées et/ou envoyées par mail
+ * (selon le réglage choisit ci-dessus).
+ *
+ * Si positionné à 2, alors l'exception sera remontée dans la stack, *et* loguée/envoyée.
+ *
+ * Utile pour le développement.
+ *
+ * Défaut : 0 (ne rien faire)
+ * @var int
+ */
+
+// const REPORT_USER_EXCEPTIONS = 0;
+
+/**
+ * Activation des détails techniques (utile en auto-hébergement) :
+ * - version de PHP
+ * - page permettant de visualiser les erreurs présentes dans le error.log
+ * - permettre de migrer d'un stockage de fichiers à l'autre
+ * - vérification de nouvelle version (sur la page configuration)
+ *
+ * Ces infos ne sont visibles que par les membres ayant accès à la configuration.
+ *
+ * Défaut : true
+ * (Afin d'aider au rapport de bugs des instances auto-hébergées)
+ */
+
+//const ENABLE_TECH_DETAILS = true;
+
+/**
+ * Activation du log SQL (option de développement)
+ *
+ * Si cette constante est renseignée par un chemin de fichier SQLite valide,
+ * alors *TOUTES* les requêtes SQL et leur contenu sera logué dans la base de données indiquée.
+ *
+ * Cette option permet ensuite de parcourir les requêtes via l'interface dans
+ * Configuration -> Fonctions avancées -> Journal SQL pour permettre d'identifier
+ * les requêtes qui mettent trop de temps, et comment elles pourraient
+ * être améliorées. Visualiser les requêtes SQL nécessite d'avoir également activé
+ * ENABLE_TECH_DETAILS.
+ *
+ * ATTENTION : cela signifie que des informations personnelles (mot de passe etc.)
+ * peuvent se retrouver dans le log. Ne pas utiliser à moins de tester en développement.
+ * Cette option peut significativement ralentir le chargement des pages.
+ *
+ * Défaut : null (= désactivé)
+ * @var string|null
+ */
+// const SQL_DEBUG = __DIR__ . '/debug_sql.sqlite';
+
+/**
+/**
+ * Mode de journalisation de SQLite
+ *
+ * Paheko recommande le mode 'WAL' de SQLite, qui permet à SQLite
+ * d'être extrêmement rapide.
+ *
+ * Cependant, sur certains hébergeurs utilisant NFS, ce mode peut
+ * provoquer dans certains cas une corruption de la base de données.
+ *
+ * Pour éviter un souci de corruption, depuis la version 1.2.4 'TRUNCATE' est
+ * le mode par défaut.
+ *
+ * Celui-ci ne présente pas de risque, mais la base de données est alors plus
+ * lente.
+ *
+ * Si votre hébergement n'utilise pas NFS, il est recommandé de mettre 'WAL'
+ * ici, cela rendra Paheko beaucoup plus rapide.
+ *
+ * @see https://www.sqlite.org/pragma.html#pragma_journal_mode
+ * @see https://www.sqlite.org/wal.html
+ * @see https://stackoverflow.com/questions/52378361/which-nfs-implementation-is-safe-for-sqlite-database-accessed-by-multiple-proces
+ *
+ * Défaut : 'TRUNCATE'
+ * @var string
+ */
+//const SQLITE_JOURNAL_MODE = 'TRUNCATE';
+
+/**
+ * Activation du log HTTP (option de développement)
+ *
+ * Si cette constante est renseignée par un fichier texte, *TOUTES* les requêtes HTTP
+ * ainsi que leur contenu y sera enregistré.
+ *
+ * C'est surtout utile pour débuguer les problèmes de WebDAV par exemple.
+ *
+ * ATTENTION : cela signifie que des informations personnelles (mot de passe etc.)
+ * peuvent se retrouver dans le log. Ne pas utiliser à moins de tester en développement.
+ *
+ * Default : null (= désactivé)
+ * @var string|null
+ */
+// const HTTP_LOG_FILE = __DIR__ . '/http.log';
+
+/**
+ * Activer la possibilité de faire une mise à jour semi-automatisée
+ * depuis fossil.kd2.org.
+ *
+ * Si mis à TRUE, alors un bouton sera accessible depuis le menu "Configuration"
+ * pour faire une mise à jour en deux clics.
+ *
+ * Il est conseillé de désactiver cette fonctionnalité si vous ne voulez pas
+ * permettre à un utilisateur de casser l'installation !
+ *
+ * Si cette constante est désactivée, mais que ENABLE_TECH_DETAILS est activé,
+ * la vérification de nouvelle version se fera quand même, mais plutôt que de proposer
+ * la mise à jour, Paheko proposera de se rendre sur le site officiel pour
+ * télécharger la mise à jour.
+ *
+ * Défaut : true
+ *
+ * @var bool
+ */
+
+//const ENABLE_UPGRADES = true;
+
+/**
+ * Utilisation de cron pour les tâches automatiques
+ *
+ * Si "true" on s'attend à ce qu'une tâche automatisée appelle
+ * les scripts suivants:
+ * - scripts/cron.php toutes les 24 heures (envoi des rappels de cotisation,
+ * création des sauvegardes)
+ * - scripts/emails.php toutes les 5 minutes environ (envoi des emails en attente)
+ *
+ * Si "false", les actions de scripts/cron.php seront effectuées quand une personne
+ * se connecte. Et les emails seront envoyés instantanément (ce qui peut ralentir ou
+ * planter si un message a beaucoup de destinataires).
+ *
+ * Défaut : false
+ */
+
+//const USE_CRON = false;
+
+/**
+ * Activation de l'envoi de fichier directement par le serveur web.
+ * (X-SendFile)
+ *
+ * Permet d'améliorer la rapidité d'envoi des fichiers.
+ * Supporte les serveurs web suivants :
+ * - Apache avec mod_xsendfile (paquet libapache2-mod-xsendfile)
+ * - Lighttpd
+ *
+ * N'activer que si vous êtes sûr que le module est installé et activé (sinon
+ * les fichiers ne pourront être vus ou téléchargés).
+ * Nginx n'est PAS supporté, car X-Accel-Redirect ne peut gérer que des fichiers
+ * qui sont *dans* le document root du vhost, ce qui n'est pas le cas ici.
+ *
+ * Pour activer X-SendFile mettre dans la config du virtualhost de Paheko:
+ * XSendFile On
+ * XSendFilePath /var/www/paheko
+ *
+ * (remplacer le chemin par le répertoire racine de Paheko)
+ *
+ * Détails : https://tn123.org/mod_xsendfile/
+ *
+ * Défaut : false
+ */
+
+//const ENABLE_XSENDFILE = false;
+
+/**
+ * Serveur NTP utilisé pour les connexions avec TOTP
+ * (utilisé seulement si le code OTP fourni est faux)
+ *
+ * Désactiver (false) si vous êtes sûr que votre serveur est toujours à l'heure.
+ *
+ * Défaut : fr.pool.ntp.org
+ */
+
+//const NTP_SERVER = 'fr.pool.ntp.org';
+
+/**
+ * Désactiver l'envoi d'e-mails
+ *
+ * Si positionné à TRUE, l'envoi d'e-mail ne sera pas proposé, et il ne sera
+ * pas non plus possible de récupérer un mot de passe perdu.
+ * Les parties de l'interface relatives à l'envoi d'e-mail seront cachées.
+ *
+ * Ce réglage est utilisé pour la version autonome sous Windows, car Windows
+ * ne permet pas l'envoi d'e-mails.
+ *
+ * Défaut : false
+ * @var bool
+ */
+
+//const DISABLE_EMAIL = false;
+
+
+/**
+ * Hôte du serveur SMTP, mettre à false (défaut) pour utiliser la fonction
+ * mail() de PHP
+ *
+ * Défaut : false
+ */
+
+//const SMTP_HOST = false;
+
+/**
+ * Port du serveur SMTP
+ *
+ * 25 = port standard pour connexion non chiffrée (465 pour Gmail)
+ * 587 = port standard pour connexion SSL
+ *
+ * Défaut : 587
+ */
+
+//const SMTP_PORT = 587;
+
+/**
+ * Login utilisateur pour le server SMTP
+ *
+ * mettre à null pour utiliser un serveur local ou anonyme
+ *
+ * Défaut : null
+ */
+
+//const SMTP_USER = 'paheko@monserveur.com';
+
+/**
+ * Mot de passe pour le serveur SMTP
+ *
+ * mettre à null pour utiliser un serveur local ou anonyme
+ *
+ * Défaut : null
+ */
+
+//const SMTP_PASSWORD = 'abcd';
+
+/**
+ * Sécurité du serveur SMTP
+ *
+ * NONE = pas de chiffrement
+ * SSL = connexion SSL native
+ * TLS = connexion TLS native (le plus sécurisé)
+ * STARTTLS = utilisation de STARTTLS (moyennement sécurisé)
+ *
+ * Défaut : STARTTLS
+ */
+
+//const SMTP_SECURITY = 'STARTTLS';
+
+/**
+ * Nom du serveur utilisé dans le HELO SMTP
+ *
+ * Si NULL, alors le nom renseigné comme SERVER_NAME (premier nom du virtual host Apache)
+ * sera utilisé.
+ *
+ * Defaut : NULL
+ *
+ * @var null|string
+ */
+
+//const SMTP_HELO_HOSTNAME = 'mail.domain.tld';
+
+/**
+ * Adresse e-mail destinée à recevoir les erreurs de mail
+ * (adresses invalides etc.) — Return-Path
+ *
+ * Si laissé NULL, alors l'adresse e-mail de l'association sera utilisée.
+ * En cas d'hébergement de plusieurs associations, il est conseillé
+ * d'utiliser une adresse par association.
+ *
+ * Voir la documentation de configuration sur des exemples de scripts
+ * permettant de traiter les mails reçus à cette adresse.
+ *
+ * Défaut : null
+ */
+
+//const MAIL_RETURN_PATH = 'returns@monserveur.com';
+
+
+/**
+ * Adresse e-mail expéditrice des messages (Sender)
+ *
+ * Si vous envoyez des mails pour plusieurs associations, il est souhaitable
+ * de forcer l'adresse d'expéditeur des messages pour passer les règles SPF et DKIM.
+ *
+ * Dans ce cas l'adresse de l'association sera indiquée en "Reply-To", et
+ * l'adresse contenue dans MAIL_SENDER sera dans le From.
+ *
+ * Si laissé NULL, c'est l'adresse de l'association indiquée dans la configuration
+ * qui sera utilisée.
+ *
+ * Défaut : null
+ */
+
+//const MAIL_SENDER = 'associations@monserveur.com';
+
+/**
+ * Mot de passe pour l'accès à l'API permettant de gérer les mails d'erreur
+ * (voir MAIL_RETURN_PATH)
+ *
+ * Cette adresse HTTP permet de gérer un bounce email reçu en POST.
+ * C'est utile si votre serveur de mail est capable de faire une requête HTTP
+ * à la réception d'un message.
+ *
+ * La requête bounce doit contenir un paramètre "message", contenant l'intégralité
+ * de l'email avec les entêtes.
+ *
+ * Si on définit 'abcd' ici, il faudra faire une requête comme ceci :
+ * curl -F 'message=@/tmp/message.eml' https://bounce:abcd@monasso.com/admin/handle_bounce.php
+ *
+ * En alternative le serveur de mail peut aussi appeler le script
+ * 'scripts/handle_bounce.php'
+ *
+ * Défaut : null (l'API handlebounce est désactivée)
+ *
+ * @type string|null
+ */
+
+//const MAIL_BOUNCE_PASSWORD = null;
+
+/**
+ * Couleur primaire de l'interface admin par défaut
+ * (peut être personnalisée dans la configuration)
+ *
+ * Défaut : #20787a
+ */
+
+//const ADMIN_COLOR1 = '#20787a';
+
+/**
+ * Couleur secondaire de l'interface admin
+ * Défaut : #85b9ba
+ */
+
+//const ADMIN_COLOR2 = '#85b9ba';
+
+/**
+ * Image de fond par défaut de l'interface admin
+ *
+ * Cette URL doit être absolue (http://...) ou relative à l'admin (/admin/static...)
+ *
+ * Attention si l'image est sur un domaine différent vous devrez activer l'entête CORS:
+ * Access-Control-Allow-Origin "*"
+ *
+ * sinon la personnalisation des couleurs ne fonctionnera pas
+ *
+ * Défaut : [ADMIN_URL]static/bg.png
+ */
+
+//const ADMIN_BACKGROUND_IMAGE = 'https://mon-asso.fr/fond_paheko.png';
+
+/**
+ * Forcer l'image de fond et couleurs dans l'interface d'administration
+ *
+ * Si positionné à TRUE, les couleurs et l'image de fond définies dans la configuration
+ * seront ignorés.
+ *
+ * Utile pour s'assurer qu'on est sur une instance de test par exemple.
+ *
+ * Défault : false
+ * @var bool
+ */
+//const FORCE_CUSTOM_COLORS = false;
+
+/**
+ * Désactiver le formulaire d'installation
+ *
+ * Si TRUE, alors le formulaire d'installation renverra une erreur.
+ *
+ * Utile pour une installation multi-associations.
+ *
+ * Défaut : false
+ * @var bool
+ */
+//const DISABLE_INSTALL_FORM = false;
+
+/**
+ * Stockage des fichiers
+ *
+ * Indiquer ici le nom d'une classe de stockage de fichiers
+ * (parmis celles disponibles dans lib/Paheko/Files/Backend)
+ *
+ * Indiquer NULL si vous souhaitez stocker les fichier dans la base
+ * de données SQLite (valeur par défaut).
+ *
+ * Classes de stockage possibles :
+ * - SQLite : enregistre dans la base de données (défaut)
+ * - FileSystem : enregistrement des fichiers dans le système de fichier
+ *
+ * ATTENTION : activer FileSystem ET ne pas utiliser de sous-domaine (vhost dédié)
+ * ferait courir de graves risques de piratage à votre serveur web si vous ne protégez
+ * pas correctement le répertoire de stockage des fichiers !
+ *
+ * Défaut : null
+ */
+
+//const FILE_STORAGE_BACKEND = null;
+
+/**
+ * Configuration du stockage des fichiers
+ *
+ * Indiquer dans cette constante la configuration de la classe de stockage
+ * des fichiers.
+ *
+ * Valeurs possibles :
+ * - SQLite : aucune configuration possible
+ * - FileSystem : (string) chemin du répertoire où doivent être stockés les fichiers
+ *
+ * Pour migrer d'un stockage de fichiers à l'autre,
+ * voir Configuration > Avancé (accessible uniquement si ENABLE_TECH_DETAILS est à true)
+ *
+ * Défaut : null
+ */
+
+//const FILE_STORAGE_CONFIG = null;
+
+/**
+ * Forcer le quota disponible pour les fichiers
+ *
+ * Si cette constante est renseignée (en octets) alors il ne sera
+ * pas possible de stocker plus que cette valeur.
+ * Tout envoi de fichier sera refusé.
+ *
+ * Défaut : null (dans ce cas c'est le stockage qui détermine la taille disponible, donc généralement l'espace dispo sur le disque dur !)
+ */
+
+//const FILE_STORAGE_QUOTA = 10*1024*1024; // Forcer le quota alloué à 10 Mo, quel que soit le backend de stockage
+
+/**
+ * FILE_VERSIONING_POLICY
+ * Forcer la politique de versionnement des fichiers.
+ *
+ * null: laisser le choix de la politique (dans la configuration)
+ * 'none': ne rien conserver
+ * 'min': conserver 5 versions (1 minute, 1 heure, 1 jour, 1 semaine, 1 mois)
+ * 'avg': conserver 20 versions
+ * 'max': conserver 50 versions
+ *
+ * Note : indiquer 'none' fait qu'aucune nouvelle version ne sera créée,
+ * mais les versions existantes sont conservées.
+ *
+ * Si ce paramètre n'est pas NULL, alors il faudra aussi définir FILE_VERSIONING_MAX_SIZE.
+ *
+ * Défaut : null (laisser le choix dans la configuration)
+ *
+ * @var null|string
+ */
+
+//const FILE_VERSIONING_POLICY = 'min';
+
+/**
+ * FILE_VERSIONING_MAX_SIZE
+ * Forcer la taille maximale des fichiers à versionner (en Mio)
+ *
+ * N'a aucun effet si le versionnement de fichiers est désactivé.
+ *
+ * Défaut : null (laisser le choix de la taille dans la configuration)
+ *
+ * @var int|null
+ */
+
+//const FILE_VERSIONING_MAX_SIZE = 10;
+
+/**
+ * Adresse de découverte d'un client d'édition de documents (WOPI)
+ * (type OnlyOffice, Collabora, MS Office)
+ *
+ * Cela permet de savoir quels types de fichiers sont éditables
+ * avec l'éditeur web.
+ *
+ * Si NULL, alors l'édition de documents est désactivée.
+ *
+ * Défaut : null
+ */
+
+//const WOPI_DISCOVERY_URL = 'http://localhost:9980/hosting/discovery';
+
+/**
+ * PDF_COMMAND
+ * Commande qui sera exécutée pour créer un fichier PDF à partir d'un HTML.
+ *
+ * Si laissé sur 'auto', Paheko essaiera de détecter une solution entre
+ * PrinceXML, Chromium, wkhtmltopdf ou weasyprint (dans cet ordre).
+ * Si aucune solution n'est disponible, une erreur sera affichée.
+ *
+ * Il est possible d'indiquer NULL pour désactiver l'export en PDF.
+ *
+ * Il est possible d'indiquer uniquement le nom du programme :
+ * 'chromium', 'prince', 'weasyprint', ou 'wkhtmltopdf'.
+ * Dans ce cas, Paheko utilisera les paramètres par défaut de ce programme.
+ *
+ * Alternativement, il est possible d'indiquer la commande complète avec
+ * les options, par exemple '/usr/bin/chromium --headless --print-to-pdf=%2$s %1$s'
+ * Dans ce cas :
+ * - %1$s sera remplacé par le chemin du fichier HTML existant,
+ * - %2$s sera remplacé par le chemin du fichier PDF à créer.
+ *
+ * Si vous utilisez une extension pour générer les PDF (comme DomPDF), alors
+ * laisser cette constante sur 'auto'.
+ *
+ * Exemples :
+ * 'weasyprint'
+ * 'wkhtmltopdf -q --print-media-type --enable-local-file-access %s %s'
+ *
+ * Si vous utilisez Prince, un message mentionnant l'utilisation de Prince
+ * sera joint aux e-mails utilisant des fichiers PDF, conformément à la licence :
+ * https://www.princexml.com/purchase/license_faq/#non-commercial
+ *
+ * Défaut : 'auto'
+ * @var null|string
+ */
+//const PDF_COMMAND = 'auto';
+
+/**
+ * PDF_USAGE_LOG
+ * Chemin vers le fichier où enregistrer la date de chaque export en PDF
+ *
+ * Ceci est utilisé notamment pour estimer le prix de la licence PrinceXML.
+ *
+ * Défaut : NULL
+ * @var null|string
+ */
+//const PDF_USAGE_LOG = null;
+
+/**
+ * CALC_CONVERT_COMMAND
+ * Outil de conversion de formats de tableur vers un format propriétaire
+ *
+ * Paheko gère nativement les exports en ODS (OpenDocument : LibreOffice)
+ * et CSV, et imports en CSV.
+ *
+ * En indiquant ici le nom d'un outil, Paheko autorisera aussi
+ * l'import en XLSX, XLS et ODS, et l'export en XLSX.
+ *
+ * Pour cela il procédera simplement à une conversion entre les formats natifs
+ * ODS/CSV et XLSX ou XLS.
+ *
+ * Note : installer ces commandes peut introduire des risques de sécurité sur le serveur.
+ *
+ * Les outils supportés sont :
+ * - ssconvert (apt install gnumeric) (plus rapide)
+ * - unoconv (apt install unoconv) (utilise LibreOffice)
+ * - unoconvert (https://github.com/unoconv/unoserver/) en spécifiant l'interface
+ *
+ * Défault : null (= fonctionnalité désactivée)
+ * @var string|null
+ */
+//const CALC_CONVERT_COMMAND = 'unoconv';
+//const CALC_CONVERT_COMMAND = 'ssconvert';
+//const CALC_CONVERT_COMMAND = 'unoconvert --interface localhost --port 2022';
+
+/**
+ * DOCUMENT_THUMBNAIL_COMMANDS
+ * Indique les commandes à utiliser pour générer des miniatures pour les documents
+ * (LibreOffice, OOXML, PDF, SVG, vidéos, etc.)
+ *
+ * Les options possibles sont (par ordre de rapidité) :
+ * - mupdf : les miniatures PDF/SVG/XPS/EPUB sont générées avec mutool
+ * (apt install mupdf-tools)
+ * - collabora : les miniatures de documents bureautiques sont générées
+ * par le serveur Collabora, via l'API dont l'URL est indiquée dans WOPI_DISCOVERY_URL
+ * - unoconvert : les miniatures des documents Office/LO sont générées
+ * avec unoconvert
+ * - ffmpeg : les miniatures de vidéos seront générées avec ffmpeg
+ *
+ * Bien que Collabora/Unoconvert puissent générer des miniatures de PDF, il est plutôt
+ * conseillé d'utiliser mupdf quand même, il est plus rapide et léger.
+ *
+ * Note : cette option créera de nombreux fichiers de cache, et risque d'augmenter
+ * la charge serveur de manière importante.
+ *
+ * Défaut : null (fonctionnalité désactivée)
+ * @var null|array
+ */
+
+//const DOCUMENT_THUMBNAIL_COMMANDS = ['mupdf', 'collabora', 'ffmpeg'];
+
+/**
+ * PDFTOTEXT_COMMAND
+ * Outil de conversion de PDF au format texte.
+ *
+ * Utilisé pour indexer un fichier PDF pour pouvoir rechercher dans son contenu
+ * parmi les documents.
+ *
+ * Il est possible de spécifier ici la commande suivante :
+ * - mupdf (apt install mupdf-tools)
+ *
+ * Toute autre commande sera ignorée.
+ *
+ * Défaut : null (= fonctionnalité désactivée)
+ */
+//const PDFTOTEXT_COMMAND = 'mupdf';
+
+/**
+ * API_USER et API_PASSWORD
+ * Login et mot de passe système de l'API
+ *
+ * Une API est disponible via l'URL https://login:password@paheko.association.tld/api/...
+ * Voir https://fossil.kd2.org/paheko/wiki?name=API pour la documentation
+ *
+ * Ces deux constantes permettent d'indiquer un nom d'utilisateur
+ * et un mot de passe pour accès à l'API.
+ *
+ * Cet utilisateur est distinct de ceux définis dans la page de gestion des
+ * identifiants d'accès à l'API, et aura accès à TOUT en écriture/administration.
+ *
+ * Défaut: null
+ */
+//const API_USER = 'coraline';
+//const API_PASSWORD = 'thisIsASecretPassword42';
+
+/**
+ * DISABLE_INSTALL_PING
+ *
+ * Lors de l'installation, ou d'une mise à jour, la version installée de Paheko,
+ * ainsi que celle de PHP et de SQLite, sont envoyées à Paheko.cloud.
+ *
+ * Cela permet de savoir quelles sont les versions utilisées, et également de compter
+ * le nombre d'installations effectuées.
+ *
+ * Aucune donnée personnelle n'est envoyée. Un identifiant anonyme est envoyé,
+ * permettant d'identifier l'installation et éviter les doublons.
+ * (voir le code dans lib/.../Install.php)
+ *
+ * Le code de stockage des statistiques est visible à :
+ * https://paheko.cloud/ping/
+ *
+ * Pour désactiver cet envoi il suffit de placer cette constante à TRUE.
+ *
+ * Défaut : false
+ */
+//const DISABLE_INSTALL_PING = false;
+
+/**
+ * Informations légale sur l'hébergeur
+ *
+ * Ce texte (HTML) est affiché en bas de la page "mentions légales"
+ * (.../admin/legal.php)
+ *
+ * S'il est omis, l'association sera indiquée comme étant auto-hébergée.
+ *
+ * Défaut : null
+ *
+ * @var string|null
+ */
+//const LEGAL_HOSTING_DETAILS = 'OVH 5 rue de l'hébergement ROUBAIX';
+
+/**
+ * Message d'avertissement
+ *
+ * Sera affiché en haut de toutes les pages de l'administration.
+ *
+ * Code HTML autorisé.
+ * Utiliser NULL pour désactiver le message.
+ *
+ * Défaut : null
+ *
+ * @var null|string
+ */
+//const ALERT_MESSAGE = 'Ceci est un compte de test.';
diff --git a/src/config.local.php b/src/config.local.php
new file mode 100644
index 0000000..bb73fee
--- /dev/null
+++ b/src/config.local.php
@@ -0,0 +1,9 @@
+ 0), -- En jours
+ start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
+ end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
+);
+
+CREATE TABLE IF NOT EXISTS services_fees
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ amount INTEGER NULL,
+ formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)
+
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
+ id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting
+ id_analytical INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL
+);
+
+CREATE TABLE IF NOT EXISTS services_users
+-- Enregistrement des cotisations et activités
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service
+
+ paid INTEGER NOT NULL DEFAULT 0,
+ expected_amount INTEGER NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, date);
+
+CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
+CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
+CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
+CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date);
+
+CREATE TABLE IF NOT EXISTS services_reminders
+-- Rappels de devoir renouveller une cotisation
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+
+ delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel
+
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS services_reminders_sent
+-- Enregistrement des rappels envoyés à qui et quand
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,
+
+ sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
+ due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);
+
+CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
+CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);
+
+--
+-- COMPTA
+--
+
+CREATE TABLE IF NOT EXISTS acc_charts
+-- Plans comptables : il peut y en avoir plusieurs
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ country TEXT NOT NULL,
+ code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur
+ label TEXT NOT NULL,
+ archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable
+);
+
+CREATE TABLE IF NOT EXISTS acc_accounts
+-- Comptes des plans comptables
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE,
+
+ code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc.
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ position INTEGER NOT NULL, -- position actif/passif/charge/produit
+ type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
+ user INTEGER NOT NULL DEFAULT 1 -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
+CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
+CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);
+
+-- Balance des comptes par exercice
+CREATE VIEW IF NOT EXISTS acc_accounts_balances
+AS
+ SELECT id_year, id, label, code, type, debit, credit,
+ CASE -- 3 = dynamic asset or liability depending on balance
+ WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
+ WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
+ ELSE position
+ END AS position,
+ CASE
+ WHEN position IN (1, 4) -- 1 = asset, 4 = expense
+ OR (position = 3 AND (debit - credit) > 0)
+ THEN
+ debit - credit
+ ELSE
+ credit - debit
+ END AS balance,
+ CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt
+ FROM (
+ SELECT t.id_year, a.id, a.label, a.code, a.position, a.type,
+ SUM(l.credit) AS credit,
+ SUM(l.debit) AS debit
+ FROM acc_accounts a
+ INNER JOIN acc_transactions_lines l ON l.id_account = a.id
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ GROUP BY t.id_year, a.id
+ );
+
+CREATE TABLE IF NOT EXISTS acc_years
+-- Exercices
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ label TEXT NOT NULL,
+
+ start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
+ end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),
+
+ closed INTEGER NOT NULL DEFAULT 0,
+
+ id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
+);
+
+CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);
+
+-- Make sure id_account is reset when a year is deleted
+CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
+ UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
+END;
+
+CREATE TABLE IF NOT EXISTS acc_transactions
+-- Opérations comptables
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
+ status INTEGER NOT NULL DEFAULT 0, -- Statut (bitmask)
+
+ label TEXT NOT NULL,
+ notes TEXT NULL,
+ reference TEXT NULL, -- N° de pièce comptable
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable
+
+ hash TEXT NULL,
+ prev_hash TEXT NULL,
+
+ id_year INTEGER NOT NULL REFERENCES acc_years(id),
+ id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL,
+ id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
+CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related);
+CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_lines
+-- Lignes d'écritures d'une opération
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_account INTEGER NOT NULL REFERENCES acc_accounts (id), -- N° du compte dans le plan comptable
+
+ credit INTEGER NOT NULL,
+ debit INTEGER NOT NULL,
+
+ reference TEXT NULL, -- Référence de paiement, eg. numéro de chèque
+ label TEXT NULL,
+
+ reconciled INTEGER NOT NULL DEFAULT 0,
+
+ id_analytical INTEGER NULL REFERENCES acc_accounts(id) ON DELETE SET NULL,
+
+ CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
+ CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_analytical ON acc_transactions_lines (id_analytical);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_users
+-- Liaison des écritures et des membres
+(
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_user, id_transaction)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ officiel INTEGER NOT NULL DEFAULT 0,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+ auteur TEXT NULL,
+ url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INTEGER NOT NULL DEFAULT 0,
+ menu_condition TEXT NULL,
+ config TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS plugins_signaux
+-- Association entre plugins et signaux (hooks)
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (id),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS api_credentials
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ label TEXT NOT NULL,
+ key TEXT NOT NULL,
+ secret TEXT NOT NULL,
+ created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_use TEXT NULL,
+ access_level INT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key);
+
+---------- FILES ----------------
+
+CREATE TABLE IF NOT EXISTS files
+-- Files metadata
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ path TEXT NOT NULL,
+ parent TEXT NOT NULL,
+ name TEXT NOT NULL, -- File name
+ type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory
+ mime TEXT NULL,
+ size INT NULL,
+ modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) = modified),
+ image INT NOT NULL DEFAULT 0,
+
+ CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL))
+);
+
+-- Unique index as this is used to make up a file path
+CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path);
+CREATE INDEX IF NOT EXISTS files_parent ON files (parent);
+CREATE INDEX IF NOT EXISTS files_name ON files (name);
+CREATE INDEX IF NOT EXISTS files_modified ON files (modified);
+
+CREATE TABLE IF NOT EXISTS files_contents
+-- Files contents (empty if using another storage backend)
+(
+ id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
+ compressed INT NOT NULL DEFAULT 0,
+ content BLOB NOT NULL
+);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4
+-- Search inside files content
+(
+ tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012)
+ path TEXT NOT NULL,
+ title TEXT NULL,
+ content TEXT NOT NULL, -- Text content
+ notindexed=path
+);
+
+CREATE TABLE IF NOT EXISTS web_pages
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ parent TEXT NOT NULL, -- Parent path, empty = web root
+ path TEXT NOT NULL, -- Full page directory name
+ uri TEXT NOT NULL, -- Page identifier
+ file_path TEXT NOT NULL, -- Full file path for contents
+ type INTEGER NOT NULL, -- 1 = Category, 2 = Page
+ status TEXT NOT NULL,
+ format TEXT NOT NULL,
+ published TEXT NOT NULL CHECK (datetime(published) = published),
+ modified TEXT NOT NULL CHECK (datetime(modified) = modified),
+ title TEXT NOT NULL,
+ content TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path);
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
+CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (parent);
+CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
+CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);
+
+-- FIXME: rename to english
+CREATE TABLE IF NOT EXISTS recherches
+-- Recherches enregistrées
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
+ intitule TEXT NOT NULL,
+ creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
+ cible TEXT NOT NULL, -- "membres" ou "compta"
+ type TEXT NOT NULL, -- "json" ou "sql"
+ contenu TEXT NOT NULL
+);
+
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache
+-- Cache des hash de mots de passe compromis
+(
+ hash TEXT NOT NULL PRIMARY KEY
+);
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
+-- Cache des préfixes de mots de passe compromis
+(
+ prefix TEXT NOT NULL PRIMARY KEY,
+ date INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS emails (
+-- List of emails addresses
+-- We are not storing actual email addresses here for privacy reasons
+-- So that we can keep the record (for opt-out reasons) even when the
+-- email address has been removed from the users table
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL,
+ verified INTEGER NOT NULL DEFAULT 0,
+ optout INTEGER NOT NULL DEFAULT 0,
+ invalid INTEGER NOT NULL DEFAULT 0,
+ fail_count INTEGER NOT NULL DEFAULT 0,
+ sent_count INTEGER NOT NULL DEFAULT 0,
+ fail_log TEXT NULL,
+ last_sent TEXT NULL,
+ added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash);
+
+CREATE TABLE IF NOT EXISTS emails_queue (
+-- List of emails waiting to be sent
+ id INTEGER NOT NULL PRIMARY KEY,
+ sender TEXT NULL,
+ recipient TEXT NOT NULL,
+ recipient_hash TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ content TEXT NOT NULL,
+ content_html TEXT NULL,
+ sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
+ sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started
+ context INTEGER NOT NULL
+);
diff --git a/src/include/data/1.1.21_migration.sql b/src/include/data/1.1.21_migration.sql
new file mode 100644
index 0000000..a824067
--- /dev/null
+++ b/src/include/data/1.1.21_migration.sql
@@ -0,0 +1,4 @@
+ALTER TABLE services_fees ADD COLUMN id_analytical INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL;
+
+UPDATE acc_charts SET code = 'PCA_2018' WHERE code = 'PCA2018';
+UPDATE acc_charts SET code = 'PCA_1999' WHERE code = 'PCA1999';
diff --git a/src/include/data/1.1.25_migration.sql b/src/include/data/1.1.25_migration.sql
new file mode 100644
index 0000000..4ed9469
--- /dev/null
+++ b/src/include/data/1.1.25_migration.sql
@@ -0,0 +1,3 @@
+UPDATE plugins_signaux SET signal = 'home.banner' WHERE signal = 'accueil.banniere';
+UPDATE plugins_signaux SET signal = 'reminder.send.after' WHERE signal = 'rappels.auto';
+UPDATE plugins_signaux SET signal = 'email.send.before' WHERE signal = 'email.envoi';
diff --git a/src/include/data/1.1.29_migration.sql b/src/include/data/1.1.29_migration.sql
new file mode 100644
index 0000000..2d31ce0
--- /dev/null
+++ b/src/include/data/1.1.29_migration.sql
@@ -0,0 +1,23 @@
+CREATE TEMP TABLE tmp_new_accounts (id_chart, code, label, position);
+
+-- Add missing accounts
+INSERT INTO tmp_new_accounts (code, label, position) VALUES
+ ('438', 'Organismes sociaux - Charges à payer et produits à recevoir', 2),
+ ('4382', 'Charges sociales sur congés à payer', 2),
+ ('4386', 'Autres charges à payer', 2),
+ ('4387', 'Produits à recevoir', 2);
+
+UPDATE tmp_new_accounts SET id_chart = (SELECT id FROM acc_charts WHERE code = 'PCA_2018');
+
+INSERT OR IGNORE INTO acc_accounts (id_chart, code, label, position, user) SELECT *, 0 FROM tmp_new_accounts WHERE id_chart IS NOT NULL;
+
+DROP TABLE tmp_new_accounts;
+
+CREATE TEMP TABLE IF NOT EXISTS su_fix_fee (id);
+
+INSERT INTO su_fix_fee
+ SELECT su.id FROM services_users su LEFT JOIN services_fees sf ON sf.id = su.id_fee AND sf.id_service = sf.id_service
+ WHERE sf.id IS NULL AND su.id_fee IS NOT NULL;
+
+-- Remove id_fee from subscriptions where it belongs to another service
+UPDATE services_users SET id_fee = NULL WHERE id IN (SELECT id FROM su_fix_fee);
\ No newline at end of file
diff --git a/src/include/data/charts/be_pcmn_2019.csv b/src/include/data/charts/be_pcmn_2019.csv
new file mode 100644
index 0000000..52fe4b1
--- /dev/null
+++ b/src/include/data/charts/be_pcmn_2019.csv
@@ -0,0 +1,445 @@
+code,label,description,position,bookmark
+1,"FONDS SOCIAL, PROVISIONS POUR RISQUES ET CHARGE ET DETTES À PLUS D’UN AN",,Passif,
+10,Fonds de l’association ou de la fondation,,Passif,
+12,Plus-values de réévaluation,,Passif,
+120,Plus-values de réévaluation sur immobilisations incorporelles,,Passif,
+121,Plus-values de réévaluation sur immobilisations corporelles,,Passif,
+122,Plus-values de réévaluation sur immobilisations financières,,Passif,
+124,Reprises de réductions de valeurs sur placements de trésorerie,,Passif,
+13,Fonds affectés et autres réserves,,Passif,
+130,Fonds affectés pour investissements,,Passif,
+131,Fonds affectés pour passif social,,Passif,
+132,Réserves immunisés,,Passif,
+139,Autres fonds affectés et autres réserves,,Passif,
+14,Résultats reportés (+)(-),Bénéfice ou déficit,Actif ou passif,
+15,Subsides en capital,,Passif,
+16,Provisions et impôts différés,,Passif,
+160,Provisions pour pensions et obligations similaires,,Passif,
+161,Provisions pour charge fiscales,,Passif,
+162,Provisions pour grosses réparations et gros entretiens,,Passif,
+163,Provisions pour obligations environnementales,,Passif,
+164,Provisions pour autres risques et charge,,Passif,
+167,"Provisions pour remboursement de subsides, legs et dons avec droit de reprise",,Passif,
+168,Impôts différés,,Passif,
+17,Dettes à plus d'un an,,Passif,
+170,Emprunts subordonnés,,Passif,
+171,Emprunts obligataires non subordonnés,,Passif,
+172,Dettes de location-financement et dettes assimilées,,Passif,
+173,Établissements de crédit,,Passif,
+1730,Dettes en comptes,,Passif,
+1731,Promesses,,Passif,
+1732,Crédits d'acceptation,,Passif,
+174,Autres emprunts,,Passif,
+175,Dettes commerciales,,Passif,
+1750,Fournisseurs,,Passif,
+1751,Effets à payer,,Passif,
+176,Acomptes sur commandes,,Passif,
+178,Cautionnements en numéraire,,Passif,
+179,Autres dettes,,Passif,
+1790,Productives d’intérêts,,Passif,
+1791,Non productives d’intérêts ou assorties d’un intérêt anormalement faible,,Passif,
+2,"FRAIS D’ÉTABLISSEMENT, ACTIFS IMMOBILISÉS ET CRÉANCES À PLUS D’UN AN",,Actif,
+20,Frais d'établissement,,Actif,
+200,Frais de constitution,,Actif,
+201,Frais d'émission d'emprunt,,Actif,
+202,Autres frais d'établissement,,Actif,
+204,Frais de restructuration,,Actif,
+21,Immobilisations incorporelles,Actifs immobilisés,Actif,
+210,Frais de recherche et de développement,,Actif,
+211,"Concessions, brevets, licences, savoir-faire, marques et droits similaires",,Actif,
+212,Goodwill,,Actif,
+213,Acomptes versés,,Actif,
+22,Terrains et constructions,,Actif,
+220,Terrains,,Actif,
+221,Constructions,,Actif,
+222,Terrains bâtis,,Actif,
+223,Autres droits réels sur des immeubles,,Actif,
+23,"Installations, machines et outillage ",,Actif,
+24,Mobilier et matériel roulant,,Actif,
+25,Immobilisations détenues en location-financement et droits similaires,,Actif,
+250,Terrains et construction,,Actif,
+251,"Installations, machines et outillage",,Actif,
+252,Mobilier et matériel roulant,,Actif,
+26,Autres immobilisations corporelles,,Actif,
+27,Immobilisations corporelles en cours et acomptes versés,,Actif,
+28,Immobilisations financières,,Actif,
+280,Participations dans des sociétés liées,,Actif,
+2800,Valeur d'acquisition,,Actif,
+2801,Montants non appelés,,Actif,
+2808,Plus-values actées,,Actif,
+2809,Réductions de valeur actées,,Actif,
+281,Créances sur des entités liées,,Actif,
+2810,Créances en compte,,Actif,
+2811,Effets à recevoir,,Actif,
+2812,Titres à revenu fixe,,Actif,
+2817,Créances douteuses,,Actif,
+2819,Réductions de valeurs actées,,Actif,
+282,Participations dans des sociétés avec lesquelles il existe un lien de participation,,Actif,
+2820,Valeur d'acquisition,,Actif,
+2821,Montants non appelés,,Actif,
+2828,Plus-values actées,,Actif,
+2829,Réductions de valeurs actées,,Actif,
+283,Créances sur des sociétés avec lesquelles il existe un lien de participation,,Actif,
+2830,Créances en compte,,Actif,
+2831,Effets à recevoir,,Actif,
+2832,Titres à revenu fixe,,Actif,
+2837,Créances douteuses,,Actif,
+2839,Réductions de valeurs actées,,Actif,
+284,Autres actions et parts,,Actif,
+2840,Valeur d'acquisition,,Actif,
+2841,Montants non appelés,,Actif,
+2848,Plus-values actées,,Actif,
+2849,Réductions de valeurs actées,,Actif,
+285,Autres créances,,Actif,
+2850,Créances en compte,,Actif,
+2851,Effets à recevoir,,Actif,
+2852,Titres à revenu fixe,,Actif,
+2857,Créances douteuses,,Actif,
+2859,Réductions de valeurs actées,,Actif,
+288,Cautionnements versés en numéraire,,Actif,
+29,Créances à plus d'un an,,Actif,
+290,Créances commerciales,,Actif,
+2900,Clients,,Actif,
+2901,Effets à recevoir,,Actif,
+2906,Acomptes versés,,Actif,
+2907,Créances douteuses,,Actif,
+2909,Réductions de valeurs actées,,Actif,
+291,Autres créances,,Actif,
+2910,Créances en compte,,Actif,
+2911,Effets à recevoir,,Actif,
+2912,Subsides à recevoir,,Actif,
+2915,Créances non productives d’intérêts ou assorties d’un intérêt anormalement faible,,Actif,
+2916,Créances douteuses,,Actif,
+2919,Réductions de valeurs actées,,Actif,
+3,STOCKS ET COMMANDES EN COURS D'EXÉCUTION,,Actif,
+30,Matières premières,,Actif,
+300,Valeur d'acquisition,,Actif,
+309,Réductions de valeur actées,,Actif,
+31,Fournitures,,Actif,
+310,Valeur d'acquisition,,Actif,
+319,Réductions de valeur actées,,Actif,
+32,En-cours de fabrication,,Actif,
+320,Valeur d'acquisition,,Actif,
+329,Réductions de valeur actées,,Actif,
+33,Produit finis,,Actif,
+330,Valeur d'acquisition,,Actif,
+339,Réductions de valeur actées,,Actif,
+34,Marchandises,,Actif,
+340,Valeur d'acquisition,,Actif,
+349,Réductions de valeur actées,,Actif,
+35,Immeubles destinés a la vente,,Actif,
+350,Valeur d'acquisition,,Actif,
+359,Réductions de valeur actées,,Actif,
+36,Acomptes versés sur achats pour stocks,,Actif,
+360,Acomptes versés,,Actif,
+369,Réductions de valeur actées,,Actif,
+37,Commandes en cours d'exécution,,Actif,
+370,Valeur d'acquisition,,Actif,
+371,Bénéfice pris en compte,,Actif,
+379,Réductions de valeur actées,,Actif,
+4,CRÉANCES ET DETTES À UN AN AU PLUS,,Passif,
+40,Créances commerciales,,Passif,
+400,Clients,,Passif,
+401,Effets à recevoir,,Passif,
+404,Produit à recevoir,,Passif,
+406,Acomptes versés,,Passif,
+407,Créances douteuses,,Passif,
+409,Réductions de valeurs actées,,Passif,
+41,Autres créances,,Passif,
+410,Autres tiers,,Passif,
+411,Tva à récupérer,,Passif,
+412,Impôts et précomptes à récupérer,,Passif,
+4120,4120 à 4124,,Passif,
+4125,4125 à 4127 Autres impôts et taxes belges,,Passif,
+4128,Impôts et taxes étrangers,,Passif,
+413,Subsides à recevoir,,Passif,
+414,Produit à recevoir,,Passif,
+415,Créances non productives d’intérêts ou assorties d’un intérêt anormalement faible,,Passif,
+416,Créances diverses,,Passif,
+417,Créances douteuses,,Passif,
+418,Cautionnements versés en numéraire,,Passif,
+419,Réductions de valeurs actées,,Passif,
+42,Dettes à plus d'un an échéant dans l'année (même subdivision que le compte 17),,Passif,
+43,Dettes financières,,Passif,
+430,Établissements de crédit – Emprunts en compte à terme fixe,,Passif,
+431,Établissements de crédit – Promesses,,Passif,
+432,Établissements de crédit – Crédits d'acceptation,,Passif,
+433,Établissements de crédit – Dettes en compte courant,,Passif,
+439,Autres emprunts,,Passif,
+44,Dettes commerciales,,Passif,
+440,Fournisseurs,,Passif,
+441,Effets à payer,,Passif,
+444,Factures à recevoir,,Passif,
+45,"Dettes fiscales, salariales et sociales",,Passif,
+450,Dettes fiscales estimées,,Passif,
+4505, à 4507 Autres impôts et taxes belges,,Passif,
+4508,Impôts et taxes étrangers,,Passif,
+451,Tva à payer,,Passif,
+452,Impôts et taxes à payer,,Passif,
+4525,Autres impôts et taxes belges,,Passif,
+4528,Impôts et taxes étrangers,,Passif,
+453,Précomptes retenus,,Passif,
+454,Office national de la Sécurité sociale,,Passif,
+455,Rémunérations,,Passif,
+456,Pécules de vacances,,Passif,
+459,Autres dettes sociales,,Passif,
+46,Acomptes sur commandes,,Passif,
+48,Dettes diverses,,Passif,
+480,Obligations et coupons échus,,Passif,
+483,Subsides à rembourser,,Passif,
+488,Cautionnements reçus en numéraire,,Passif,
+489,Autres dettes diverses,,Passif,
+4890,Productives d’intérêts,,Passif,
+4891,Non productives d’intérêts ou assorties d’un intérêt anormalement faible,,Passif,
+49,Comptes de régularisation et d'attente,,Actif,
+490,Charge à reporter,,Actif,
+491,Produit acquis,,Actif,
+492,Charge à imputer,,Passif,
+493,Produit à reporter,,Passif,
+4931,Produit à reporter (créditeur),,Passif,
+4932,Produit à reporter (débiteur),,Actif,
+499,Comptes d'attente,,Passif,
+5,PLACEMENTS DE TRÉSORERIE ET VALEURS DISPONIBLES,,Actif,
+50,"Placements de trésorerie autres que actions et parts, titres à revenu fixe et dépôts à terme",,Actif,
+500,Valeur d'acquisition,,Actif,
+509,Réductions de valeurs actées,,Actif,
+51,Actions et parts,,Actif,
+510,Valeur d'acquisition,,Actif,
+511,Montants non appelés,,Actif,
+519,Réductions de valeur actées,,Actif,
+52,Titres à revenu fixe,,Actif,
+520,Valeur d'acquisition,,Actif,
+529,Réductions de valeurs actées,,Actif,
+53,Dépôts à terme,,Actif,
+530,De plus d'un an,,Actif,
+531,De plus d'un mois et à un an au plus,,Actif,
+532,D'un mois au plus,,Actif,
+539,Réductions de valeur actées,,Actif,
+54,Valeurs échues à l'encaissement,,Actif,
+55,Établissements de crédit,,Actif,
+56,Comptes bancaires,,Actif,Favori
+57,Caisses,,Actif,
+570,Caisses-espèces,,Actif,Favori
+578,Caisses-timbres,,Actif,
+58,Virements internes,,Actif,
+6,CHARGES,,Charge,
+60,Approvisionnements et marchandises,,Charge,
+600,Achats de matières premières,,Charge,Favori
+601,Achats de fournitures,,Charge,Favori
+602,"Achats de services, travaux et études",,Charge,Favori
+603,Sous-traitances générales,,Charge,Favori
+604,Achats de marchandises,,Charge,Favori
+605,Achats d'immeubles destinés à la vente,,Charge,
+608,"Remises, ristournes et rabais obtenus",,Charge,
+609,Variation des stocks,,Charge,
+6090,de matières premières,,Charge,
+6091,de fournitures,,Charge,
+6094,de marchandises,,Charge,
+6095,d'immeubles destinés à la vente,,Charge,
+61,Services et biens divers,,Charge,Favori
+617,Personnel intérimaire et personnes mises à la disposition de l’association ou de la fondation,,Charge,
+618,"Rémunérations, primes pour assurances extralégales, pensions de retraite et de survie des administrateurs qui ne sont pas attribuées en vertu d'un contrat de travail",,Charge,
+62,"Rémunérations, charges sociales et pensions",,Charge,
+620,Rémunérations et avantages sociaux directs,,Charge,Favori
+6200,Administrateurs ou gérants,,Charge,
+6201,Personnel de direction,,Charge,
+6202,Employés,,Charge,
+6203,Ouvriers,,Charge,
+6204,Autres membres du personnel,,Charge,
+621,Cotisations patronales pour assurances sociales,,Charge,
+622,Primes patronales pour assurances extra-légales,,Charge,
+623,Autres frais du personnel,,Charge,
+624,Pensions de retraite et de survie,,Charge,
+6240,Administrateurs ou gérants (27),,Charge,
+6241,Personnel,,Charge,
+63,"Amortissements, réductions de valeur et provisions pour risques et charges",,Charge,
+630,Dotations aux amortissements et aux réductions de valeur sur immobilisations,,Charge,
+6300,Dotations aux amortissements sur frais d'établissement,,Charge,
+6301,Dotation aux amortissements sur immobilisations incorporelles,,Charge,
+6302,Dotation aux amortissements sur immobilisations corporelles,,Charge,
+6308,Dotation aux réductions de valeurs sur immobilisations incorporelles,,Charge,
+6309,Dotation aux réductions de valeurs sur immobilisations corporelles,,Charge,
+631,Réductions de valeur sur stocks,,Charge,
+6310,Dotations,,Charge,
+6311,Reprises,,Charge,
+632,Réductions de valeur sur commandes en cours d'exécution,,Charge,
+6320,Dotations,,Charge,
+6321,Reprises,,Charge,
+633,Réductions de valeur sur créances commerciales à plus d'un an,,Charge,
+6330,Dotations,,Charge,
+6331,Reprises,,Charge,
+634,Réductions de valeur sur créances à un an au plus,,Charge,
+6340,Dotations,,Charge,
+6341,Reprises,,Charge,
+635,Provisions pour pensions et obligations similaires,,Charge,
+6350,Dotations,,Charge,
+6351,Utilisations et reprises,,Charge,
+636,Provisions pour grosses réparations et gros entretiens,,Charge,
+6360,Dotations,,Charge,
+6361,Utilisations et reprises,,Charge,
+637,Provisions pour obligations environnementales,,Charge,
+6370,Dotations,,Charge,
+6371,Utilisations et reprises,,Charge,
+638,Provisions pour subsides et legs à rembourser et pour dons avec droit de reprise,,Charge,
+6380,Dotations,,Charge,
+6381,Utilisations et reprises,,Charge,
+639,Provisions pour autres risques et charges,,Charge,
+6390,Dotations,,Charge,
+6391,Utilisations et reprises,,Charge,
+64,Autres charges d'exploitation,,Charge,
+640,Charges fiscales,,Charge,
+641,Moins-values sur réalisations courantes d'immobilisations corporelles,,Charge,
+642,Moins-values sur réalisations de créances commerciales,,Charge,
+643,Dons,,Charge,
+644,644-648 Charges d'exploitations diverses,À subdiviser,Charge,
+649,Charges d'exploitation portées à l'actif au titre de frais de restructuration (–),,Charge,
+65,Charges financières,,Charge,
+650,Charges des dettes,,Charge,
+6500,"Intérêts, commissions et frais afférents aux dettes",,Charge,
+6501,Amortissements frais d'émission d'emprunts et des primes de remboursement,,Charge,
+6502,Intérêts intercalaires portés à l'actif,,Charge,
+651,Réductions de valeur sur actifs circulants,,Charge,
+6510,Dotations,,Charge,
+6511,Reprises,,Charge,
+652,Moins-values sur réalisation d'actifs circulants,,Charge,
+653,Charges d'escompte de créances,,Charge,
+654,Différences de change,,Charge,
+655,Écarts de conversion des devises,,Charge,
+656,Provisions à caractère financier,,Charge,
+6560,Dotations,,Charge,
+6561,Utilisations et reprises,,Charge,
+657,Charges financières diverses,À subdiviser,Charge,
+659,Charges financières portées à l'actif au titre de frais de restructuration,,Charge,
+66,Charges d’exploitation ou financières non récurrentes,,Charge,
+660,Amortissements et réductions de valeur non récurrents (dotations),,Charge,
+6600,sur frais d'établissement,,Charge,
+6601,sur immobilisations incorporelles,,Charge,
+6602,sur immobilisations corporelles,,Charge,
+661,Réduction de valeur sur immobilisations financières (dotation),,Charge,
+662,Provisions pour risques et charges non récurrents,,Charge,
+6620,Provisions pour risques et charges d’exploitation non récurrents,,Charge,
+66200,Dotations,,Charge,
+66201,Utilisations,,Charge,
+6621,Provisions pour risques et charges financiers non récurrents,,Charge,
+66210,Dotations,,Charge,
+66211,Utilisation,,Charge,
+663,Moins-values sur réalisation d'actifs immobilisés,,Charge,
+6630,Moins-values sur réalisation d'immobilisations incorporelles et corporelles,,Charge,
+6631,Moins-values sur réalisations d'actifs immobilisés,,Charge,
+664,664 à 667 Autres charges d'exploitation non récurrentes,À subdiviser,Charge,
+668,Autres charges financières non récurrentes,,Charge,
+6690,Charges d’exploitation portées à l’actif au titre de frais de restructuration,,Charge,
+6691,Charges financières non récurrentes portées à l'actif au titre de frais de restructuration,,Charge,
+67,Impôts,,Charge,
+670, Impôts belges sur le résultat de l’exercice,,Charge,
+6701,Excédent de versements d'impôts et de précomptes porté à l’actif (–),,Charge,
+6702, Charges fiscales estimées,,Charge,
+6710,Suppléments d'impôts dus ou versés,,Charge,
+6711, Suppléments d'impôts estimés,,Charge,
+6712,Provisions fiscales constituées," Impôts étrangers sur le résultat de l’exercice, impôts étrangers sur le résultat d'exercices antérieurs",Charge,
+68, Transferts aux impôts différés et aux réserves immunisées,,Charge,
+680, Transferts aux impôts différés,,Charge,
+689,Transferts aux réserves immunisées,,Charge,
+69,Affectations et prélèvements,,Charge,
+690,Résultat négatif de l'exercice antérieur reporté,,Passif,
+691,Transfert aux fonds affectés et autres réserves,,Actif,
+692,Résultat positif à reporter,,Actif,
+7,PRODUITS,,Produit,
+70,Chiffre d'affaires,,Produit,
+700,Ventes et prestations de services,,Produit,Favori
+708,"Remises, ristournes et rabais accordés",,Produit,
+71,Variation des stocks et des commandes en cours d’exécution,,Produit,
+712,Des en-cours de fabrication,,Produit,Favori
+713,Des produits finis,,Produit,Favori
+715,Des immeubles construits destinés à la vente,,Produit,
+717,Des commandes en cours d'exécution,,Produit,
+7170,Valeur d'acquisition,,Produit,
+7171,Bénéfice pris en compte,,Produit,
+72,Production immobilisée,,Produit,
+73,"Cotisations, dons, legs et subsides",,Produit,Favori
+730,Cotisations,,Produit,Favori
+731,Dons,,Produit,Favori
+732,Legs,,Produit,Favori
+733,Subsides,,Produit,Favori
+74,Autres produits d’exploitation,,Produit,Favori
+741,Plus-values sur réalisations courantes d'immobilisations corporelles,,Produit,
+742,Plus-values sur réalisation de créances commerciales,,Produit,
+743,743-749 Produits d'exploitation divers,À subdiviser,Produit,
+75,Produits financiers,,Produit,Favori
+750,Produits des immobilisations financières,,Produit,
+751,Produits des actifs circulants,,Produit,
+752,Plus-values sur la réalisation d'actifs circulants,,Produit,
+753,(libellé vide dans la version officielle),,Produit,
+754,Différences de change,,Produit,
+755,Écarts de conversion des devises,,Produit,
+756,756-759 Produits financiers divers,À subdiviser,Produit,
+76,Produits d'exploitation ou financiers non récurrents,,Produit,
+760,Reprise d'amortissements et réductions de valeur,,Produit,
+7600,Reprise sur immobilisations incorporelles,,Produit,
+7601,Reprise sur immobilisations corporelles,,Produit,
+761,Reprises de réductions de valeur sur immobilisations financières,,Produit,
+762,Reprises de provisions pour risques et charges non récurrents,,Produit,
+7620,Reprises de provisions pour risques et charges d’exploitation non récurrents,,Produit,
+7621,Reprises de provisions pour risques et charges financiers non récurrents,,Produit,
+763,Plus-values sur réalisation d'actifs immobilisés,,Produit,
+7630,Plus-values sur réalisation d'immobilisations incorporelles et corporelles,,Produit,
+7631,Plus-values sur réalisations d'actifs immobilisés,,Produit,
+764,764-768 Autres produits d’exploitation non récurrents,À subdiviser,Produit,
+769,Autres produits financiers non récurrents,,Produit,
+77,Régularisation d'impôts,,Produit,
+78,Prélèvement sur les réserves immunisées et les impôts différés,,Produit,
+780,Prélèvement sur les impôts différés,,Produit,
+789,Prélèvement sur les réserves immunisées,,Produit,
+79,Affectations et prélèvements,,Produit,
+790,Résultat positif de l'exercice antérieur reporté,Résultat excédentaire,Produit,
+791,Autres réserves,,Produit,
+792,Résultat négatif à reporter,Résultat déficitaire,Charge,
+890,Ouverture,,,
+891,Clôture,,,
+0,DROITS ET ENGAGEMENTS HORS BILAN,Sont portés dans les comptes de la classe 0 les droits et engagements autres que ceux qui doivent être portés dans les comptes des classes 1 à 5.,,
+00,Garanties constituées par des tiers pour compte de l’association ou de la fondation,,,
+000,"Créanciers de l'association ou de la fondation, bénéficiaires de garanties de tiers",,,
+001,Tiers constituants de garanties pour compte de l’association ou de la fondation,,,
+01,Garanties personnelles pour compte de tiers,,,
+010,Débiteurs pour engagements sur effets en circulation,,,
+011,Créanciers d'engagements sur effets en circulation,,,
+0110,Effets cédés par l’association ou la fondation sous son endos,,,
+0111,Autres engagements sur effets en circulation,,,
+012,Débiteurs pour autres garanties personnelles,,,
+013,Créanciers d'autres garanties personnelles,,,
+02,Garanties réelles constituées sur avoirs propres,,,
+020,"Créanciers de l'association ou de la fondation, bénéficiaires de garanties réelles",,,
+021,Garanties réelles constituées pour compte propre,,,
+022,"Créanciers de tiers, bénéficiaires de garanties réelles",,,
+023,Garanties réelles constituées pour compte de tiers,,,
+03,Garanties reçues,,,
+032,Garanties reçues,,,
+033,Constituants de garanties,,,
+04,Biens et valeurs détenus par des tiers en leur nom mais aux risques et profits de l’association ou de la fondation,,,
+040,"Tiers, détenteurs en leur nom mais aux risques et profits de l'association ou de la fondation de biens et de valeurs",,,
+041,Biens et valeurs détenus par des tiers en leur nom mais aux risques et profits de l’association ou de la fondation,,,
+05,Engagements d'acquisition et de cession d’immobilisations,,,
+050,Engagements d'acquisition,,,
+051,Créanciers d'engagements d'acquisition,,,
+052,Débiteurs pour engagements de cession,,,
+053,Engagements de cession,,,
+06,Marchés à terme,,,
+060,Marchandises achetées à terme - à recevoir,,,
+061,Créanciers pour marchandises achetées à terme,,,
+062,Débiteurs pour marchandises vendues à terme,,,
+063,Marchandises vendues à terme - à livrer,,,
+064,Devises achetées à terme - à recevoir,,,
+065,Créanciers pour devises achetées à terme,,,
+066,Débiteurs pour devises vendues à terme,,,
+067,Devises vendues à terme - à livrer,,,
+07,Biens et valeurs de tiers détenus par l'association ou la fondation,,,
+070,Droits d'usage à long terme,,,
+0700,Sur terrains et constructions,,,
+0701,"Sur installations, machines et outillage",,,
+0702,Sur mobilier et matériel roulant,,,
+071,Créanciers de loyers et redevances,,,
+072,"Biens et valeurs de tiers reçus en dépôt, en consignation ou à façon",,,
+073,Commettants et déposants de biens et de valeurs,,,
+074,Biens et valeurs détenus pour compte ou aux risques et profits de tiers,,,
+075,Créanciers de biens et valeurs détenus pour compte de tiers ou à leurs risques et profits,,,
+09,Droits et engagements divers,,,
diff --git a/src/include/data/charts/ch_asso.csv b/src/include/data/charts/ch_asso.csv
new file mode 100644
index 0000000..da04c40
--- /dev/null
+++ b/src/include/data/charts/ch_asso.csv
@@ -0,0 +1,167 @@
+Numéro,Libellé,Description,Position,Favori
+1,Actifs,,Actif,
+10,Liquidités,,Actif,
+100,Caisses,,Actif,
+1000,Caisse,,Actif,Favori
+1005,Caisse Euro,,Actif,Favori
+1010,CCP,,Actif,
+102,Banques,,Actif,
+1020,Compte bancaire,,Actif,Favori
+1030,Dépôts à court terme (< 3 mois),,Actif,
+109,Comptes d’attente,,Actif,
+1090,Dépôts en attente,,Actif,Favori
+11,Titres cotés en bourse et détenus à court terme,,Actif,
+1100,Titres,,Actif,
+12,Débiteurs,,Actif,
+120,Débiteurs résultant de la vente de biens et de prestations de services,,Actif,
+1200,Débiteurs résultant de la vente de biens et de prestations de services,,Actif,
+125,Autres débiteurs,,Actif,
+1250,Autres débiteurs,,Actif,
+13,Stocks,,Actif,
+1300,Stocks divers,,Actif,
+14,Actifs transitoires (comptes de régularisation d’actifs),,Actif,
+1400,Impôts anticipés,,Actif,
+1410,Produits à recevoir,,Actif,
+1420,Charges payées d'avance,,Actif,
+15,Immobilisations financières,,Actif,
+1500,Cautions loyers,,Actif,
+16,Participations,,Actif,
+1600,Participations dans d’autres entités,,Actif,
+17,Immobilisations corporelles,,Actif,
+1700,Mobilier,,Actif,
+1710,Informatique,,Actif,
+18,Immobilisations incorporelles,,Actif,
+1800,Licences informatiques,,Actif,
+2,Passifs,,Passif,
+20,Créanciers,,Passif,
+2000,Fournisseurs,,Passif,
+2050,Autres dettes à court terme,,Passif,
+21,Dettes à court terme liées aux charges de personnel,,Passif,
+2100,Créanciers caisse AVS,,Passif,
+2110,Créanciers LAA,,Passif,
+2120,Créanciers LPP,,Passif,
+2130,Créanciers impôt source,,Passif,
+2140,Créanciers assurance indemnités journalières maladie,,Passif,
+2180,Salaires à payer,,Passif,
+22,Dettes financières à court terme,,Passif,
+2200,Emprunts bancaires à rembourser dans l'année,,Passif,
+23,Passifs transitoires (comptes de régularisation de passifs),,Passif,
+2310,Produits constatés d'avance,,Passif,
+2320,Charges à payer,,Passif,
+24,Dettes à long terme,,Passif,
+2400,Emprunts bancaires à rembourser à plus d'une année,,Passif,
+2410,Autres dettes à long terme,,Passif,
+26,Provisions,,Passif,
+28,Fonds affectés (Swiss GAAP RPC),À subdiviser par projet,Passif,
+29,Fonds propres,,Passif,
+2900,Capital initial versé (ou capital de fondation),,Passif,
+2910,Réserve associative,,Passif,
+2990,Résultats reportés (fonds libres),,Passif,
+2999,Résultat de l'exercice,,Passif,
+29991,Résultat positif,,Passif,
+29999,Résultat négatif,,Passif,
+3,Charges,,Charge,
+30,Charges directes de projets,À subdiviser par projet,Charge,
+31,Charges de personnel,,Charge,
+310,Salaires,,Charge,
+3100,Salaires,,Charge,Favori
+311,Charges sociales,,Charge,
+3110,AVS-AI-AC-AMAT,,Charge,
+3111,LAA professionnelle,,Charge,
+3112,LAA complémentaire,,Charge,
+3113,Assurance indemnités journalières maladie,,Charge,
+3114,LPP,,Charge,
+312,Autres charges de personnel,,Charge,
+3120,Frais de formation,,Charge,Favori
+3128,Autres frais de personnel,,Charge,
+315,Compensations charges de personnel (revenus présentés en déduction des charges de personnel),,Charge,
+3150,Indemnités d'assurance pour charges du personnel,,Charge,
+3151,Mise à disposition de personnes,,Charge,
+3152,Commission perception IS,,Charge,
+3159,Autres compensations charges salariales,,Charge,
+32,Charges de locaux,,Charge,
+3200,Loyers,,Charge,Favori
+3210,"Charges – Eau, gaz, électricité ",,Charge,Favori
+3220,Frais d'entretiens locaux,,Charge,Favori
+3230,Assurance RC et choses,,Charge,
+3280,Frais de location de salles,,Charge,Favori
+33,Administration et informatique,,Charge,
+330,Administration,,Charge,
+3300,Fournitures de bureau,,Charge,Favori
+3301,Télécommunications,,Charge,Favori
+3302,Frais de port,,Charge,Favori
+3303,Documents et abonnements,,Charge,
+3304,Frais de cotisations,,Charge,
+3306,Frais de réunion,,Charge,
+335,Informatique,,Charge,
+3350,Frais de licence,Par exemple pour décompter la contribution à un logiciel comptable ;-),Charge,Favori
+3351,Frais de maintenance,,Charge,
+3352,Petit matériel informatique,,Charge,Favori
+34,Frais de promotion et de représentation,,Charge,
+340,Matériel de promotion,,Charge,
+3400,Impression de matériel de promotion,,Charge,
+3401,Conception de matériel de promotion,,Charge,
+3409,Autre matériel de promotion,,Charge,
+341,Site internet et communication en ligne,,Charge,
+3410,"Frais de maintenance de site internet, plateforme web, outils de communication en ligne",,Charge,
+3411,"Conception de site internet, plateforme web, outils de communication en ligne",,Charge,
+3419,Autres frais liés à la communication en ligne,,Charge,
+346,Frais de représentation,,Charge,
+3460,Frais de voyage,,Charge,
+3461,Frais de repas,,Charge,Favori
+3469,Autres frais de représentation,,Charge,
+35,Mises à disposition gratuites (contrepartie : subventions non monétaires en 45),,Charge,
+36,Autres charges d’exploitation,,Charge,
+360,Amortissements et dépréciations d’actifs,,Charge,
+3600,Amortissements,,Charge,
+3601,Dépréciations d’actifs,,Charge,
+361,Charges sur débiteurs douteux,,Charge,
+3610,Variation provision sur débiteurs douteux,,Charge,
+3620,Pertes sur débiteurs douteux,,Charge,
+369,Autres charges d’exploitation,,Charge,
+3690,Autres charges d’exploitation,,Charge,
+37,"Charges hors exploitation, exceptionnelles, uniques ou hors périodes",,Charge,
+370,Charges hors exploitation,,Charge,
+3700,Charges hors exploitation,,Charge,
+371,Charges exceptionnelles ou uniques,,Charge,
+3710,Charges exceptionnelles ou uniques,,Charge,
+372,Charges hors périodes,,Charge,
+3720,Charges liées aux exercices précédents,,Charge,
+38,Charges financières,,Charge,
+3800,Charges d'intérêts,,Charge,
+3810,Frais bancaires,,Charge,Favori
+3820,Pertes de change,,Charge,
+39,Variation des fonds affectés (Swiss GAAP RPC),,Charge,
+3900,Attribution aux fonds affectés,,Charge,
+3910,Produits internes de fonds affectés,,Produit,
+4,Revenus,,Produit,
+40,Revenus de ventes et de prestations,,Produit,
+4000,Revenu de prestations,,Produit,Favori
+4010,Revenu de ventes,,Produit,Favori
+41,Revenus des fonds affectés,,Produit,
+410,Subventions,,Produit,Favori
+4130,Donateurs privés – fonds affectés,,Produit,
+42,Dons non affectés,,Produit,
+4220,Donateurs privés – non affectés,,Produit,
+43,Cotisations de membres,,Produit,
+4400,Cotisations de membres,,Produit,Favori
+45,Subventions non monétaires (contrepartie : mises à disposition gratuites en 35),,Produit,
+46,Autres produits d’exploitation,,Produit,
+4600,Dissolutions de provisions,,Produit,
+47,"Produits hors exploitation, exceptionnels, uniques ou hors périodes ",,Produit,
+470,Produits hors exploitation,,Produit,
+4700,Produits hors exploitation,,Produit,
+471,Produits exceptionnels ou uniques,,Produit,
+4710,Produits exceptionnels ou uniques,,Produit,
+472,Produits hors période,,Produit,
+4720,Produits liés aux exercices précédents,,Produit,
+48,Revenus financiers,,Produit,
+4800,Revenus d'intérêts,,Produit,
+4820,Gains de change,,Produit,
+49,Variation des fonds affectés (Swiss GAAP RPC),,Produit,
+4900,Utilisation des fonds affectés,,Produit,
+4910,Charges internes de fonds affectés,,Charge,
+5,Comptes de tiers,,Actif ou passif,
+9,Bilan,,,
+9100,Bilan d'ouverture,,,
+9101,Bilan de clôture,,,
diff --git a/src/include/data/charts/fr_cse_2015.csv b/src/include/data/charts/fr_cse_2015.csv
new file mode 100644
index 0000000..e5db607
--- /dev/null
+++ b/src/include/data/charts/fr_cse_2015.csv
@@ -0,0 +1,587 @@
+code,label,description,position,bookmark
+1,"Classe 1 — Comptes de capitaux (Fonds propres, emprunts et dettes assimilés)",,Passif,
+10,FONDS ASSOCIATIFS ET RÉSERVES,,Passif,
+102,Fonds associatifs sans droit de reprise,,Passif,
+1021,Première situation nette établie,,Passif,
+1022,Fonds statutaires,,Passif,
+1023,Dotations non consomptibles,,Passif,
+10231,Dotations non consomptibles initiales,,Passif,
+10232,Dotations non consomptibles complémentaires,,Passif,
+1024,Autres fonds propres sans droit de reprise,,Passif,
+103,Fonds associatif avec droit de reprise,,Passif,
+1032,Fonds statutaires,,Passif,
+1034,Autres fonds propres avec droit de reprise,,Passif,
+105,Écarts de réévaluation,,Passif,
+1051,Écarts réévaluation sur biens sans droit reprise,,Passif,
+1052,Écarts réévaluation sur biens avec droit reprise,,Passif,
+106,Réserves,,Passif,
+1061,Réserves « Attributions économiques et professionnelles »,,Actif ou passif,
+1062,Réserves « Activités sociales et culturelles »,,Passif,
+1063,Réserves Indisponibles,,Passif,
+1064,Réserves statutaires,,Passif,
+1068,Réserves réglementées,,Passif,
+1069,Réserves pour projet de l’entité,,Passif,
+108,Dotations consomptibles,,Passif,
+1081,Dotations consomptibles,,Passif,
+1089,Dotation consomptibles inscrites au compte de résultat,,Passif,
+11,REPORT à NOUVEAU,,Passif,
+110,Report à nouveau (Solde créditeur),,Passif,
+1101,Report à nouveau « Attributions économiques et professionnelles » (solde créditeur),,Passif,
+1102,Report à nouveau « Activités sociales et culturelles » (solde créditeur),,Passif,
+119,Report à nouveau (Solde débiteur),,Passif,
+1191,Report à nouveau « Attributions économiques et professionnelles » (solde débiteur),,Passif,
+1192,Report à nouveau « Activités sociales et culturelles » (solde débiteur),,Passif,
+12,RÉSULTAT NET DE L'EXERCICE,,Passif,
+120,Résultat de l'exercice (excédent),,Passif,
+1201,Résultat de l’exercice « Attributions économiques et professionnelles » (excédent),,Passif,
+1202,Résultat de l’exercice « Activités sociales et culturelles » (excédent),,Passif,
+129,Résultat de l'exercice (déficit),,Passif,
+1291,Résultat de l’exercice « Attributions économiques et professionnelles » (déficit),,Passif,
+1292,Résultat de l’exercice « Activités sociales et culturelles » (déficit),,Passif,
+13,SUBVENTIONS D'INVESTISSEMENTS,,Passif,
+131,Subventions d'équipement,,Passif,
+1311,État,,Passif,
+1312,Régions,,Passif,
+1313,Départements,,Passif,
+1314,Communes,,Passif,
+1315,Collectivités publiques,,Passif,
+1316,Entreprises publiques,,Passif,
+1317,Entreprises et organismes privés,,Passif,
+139,Subventions inscrites au compte de résultat,,Passif,
+14,PROVISIONS RÉGLEMENTÉES,,Passif,
+148,Autres provisions réglementées,,Passif,
+15,PROVISIONS POUR RISQUES ET CHARGES,,Passif,
+151,Provisions pour risques,,Passif,
+152,Provisions pour charges sur legs ou donations,,Passif,
+153,Provisions pour pensions et obligations simil,,Passif,
+155,Provisions pour impôts,,Passif,
+157,Provisions pour charges à répartir,,Passif,
+158,Autres provisions pour charges,,Passif,
+16,EMPRUNTS ET DETTES ASSIMILÉES,,Passif,
+163,Autres emprunts obligataires,,Passif,
+1631,Titres associatifs et assimilés,,Passif,
+164,Emprunts auprès des établissements de crédit,,Passif,
+165,Dépôts et cautionnements reçus,,Passif,
+1651,Dépôts,,Passif,
+1655,Cautionnements,,Passif,
+167,Emprunts et dettes sous conditions particulières,,Passif,
+168,Autres emprunts et dettes assimilées,,Passif,
+18,COMPTES DE LIAISONS,,Passif,
+181,Apports permanents siège-établissements,,Passif,
+185,Biens & prestations de services échangés siège-établissements,,Passif,
+186,Biens & prestations de services entre établissements - Charges,,Passif,
+187,Biens & prestations de services entre établissements - Produits,,Passif,
+19,FONDS DÉDIÉS OU REPORTÉS,,Passif,
+191,Fonds reportés liés aux legs ou donations,,Passif,
+1911,Legs ou donations,,Passif,
+1912,Donations temporaires d’usufruit,,Passif,
+194,Fonds dédiés sur subventions fonctionnement,,Passif,
+195,Fonds dédiés sur contributions financières autres organismes,,Passif,
+196,Fonds dédiés sur ressources liées à la générosité,,Passif,
+2,Classe 2 — Comptes d'immobilisations,,Actif,
+20,IMMOBILISATIONS INCORPORELLES,,Actif,
+201,Frais d'établissement,,Actif,
+203,Frais de recherche et de développement,,Actif,
+204,Donations temporaires d’usufruit,,Actif,
+205,"Brevets, licences, marques...",,Actif,
+206,Droit au bail,,Actif,
+208,Autres immobilisations incorporelles,,Actif,
+21,IMMOBILISATIONS CORPORELLES,,Actif,
+211,Terrains,,Actif,
+212,Agencements / aménagements de terrains,,Actif,
+2131,Bâtiments,,Actif,
+2135,"Installations, agencements...de constructions",,Actif,
+214,Constructions sur sol d'autrui,,Actif,
+215,"Installations techniques, matériel, outillage",,Actif,
+2154,Matériel industriel,,Actif,
+2155,Outillage industriel,,Actif,
+218,Autres immobilisations corporelles,,Actif,
+2181,"Installations, agencements, aménagements divers",,Actif,
+2182,Matériel de transport,,Actif,
+2183,Matériel de bureau et matériel informatique,D’une valeur supérieure à 500€,Actif,
+2184,Mobilier,D’une valeur supérieure à 500€,Actif,
+23,IMMOBILISATIONS EN COURS,,Actif,
+231,Immobilisations corporelles en cours,,Actif,
+238,"Avances, acomptes sur immobilisations corporelles",,Actif,
+24,BIEN DESTINÉS À ÊTRE CÉDÉS,,Actif,
+240,Biens reçus par legs ou donations à céder,,Actif,
+26,PARTICIPATIONS ET CRÉANCES RATTACHÉES,,Actif,
+261,Titres de participation,,Actif,
+266,Autres formes de participation,,Actif,
+267,Créances rattachées à des participations,,Actif,
+269,Versements restants sur participations,,Actif,
+27,IMMOBILISATIONS FINANCIÈRES,,Actif,
+271,Titres immobilisés (droit de propriété),,Actif,
+272,Titres immobilisés (droit de créance),,Actif,
+274,Prêts,,Actif,
+2742,Prêts aux partenaires,,Actif,
+275,Dépôts et cautionnements versés,,Actif,
+276,Autres créances immobilisées,,Actif,
+28,AMORTISSEMENTS DES IMMOBILISATIONS,,Actif,
+280,Amortissements des immobilisations incorporelles,,Actif,
+2801,Amortissements frais d'établissement,,Actif,
+2804,Amortissements donations temporaires d’usufruit,,Actif,
+2805,"Amortissements brevets, licences, marques...",,Actif,
+2806,Amortissements droit au bail,,Actif,
+2808,Amortissements autres immo.incorporelles,,Actif,
+2812,"Amortissements agencements, aménagements de terrains",,Actif,
+28131,Amortissements bâtiments,,Actif,
+28135,"Amortissements installations, agencements...",,Actif,
+2814,Amt.constructions sur sol d'autrui,,Actif,
+2815,"Amortissements installations techniques, matériel, outillage",,Actif,
+28181,"Amortissements installations, agencements, aménagements",,Actif,
+28182,Amortissements matériel de transport,,Actif,
+28183,"Amortissement matériel de bureau, informatique",D’une valeur supérieure à 500€,Actif,
+28184,Amortissements du mobilier,D’une valeur supérieure à 500€,Actif,
+29,DÉPRÉCIATIONS DES IMMOBILISATIONS,,Actif,
+2904,Donations temporaires d'usufruit,,Actif,
+2905,"Brevets, licences, marques...",,Actif,
+2906,Droit au bail,,Actif,
+2908,Autres immobilisations incorporelles,,Actif,
+2911,Terrains,,Actif,
+2931,Immobilisations corporelles en cours,,Actif,
+294,Biens reçus par legs ou donations à céder,,Actif,
+2961,Titres de participations,,Actif,
+2966,Autres formes de participations,,Actif,
+2967,Créances rattachées à des participations,,Actif,
+2971,Titres immobilisés (droit de propriété),,Actif,
+2972,Titres immobilisés (droit de créance),,Actif,
+2974,Prêts,,Actif,
+2975,Dépôts et cautionnements versés,,Actif,
+2976,Autres créances immobilisées,,Actif,
+3,Classe 3 — Comptes de stocks,,Actif,
+31,MATIÈRES PREMIÈRES ET FOURNITURES,,Actif,
+318,Matières premières et fournitures,,Actif,
+32,AUTRES APPROVISIONNEMENTS,,Actif,
+321,Matières consommables,,Actif,
+322,Fournitures consommables,,Actif,
+326,Emballages,,Actif,
+33,EN-COURS DE PRODUCTION DE BIENS,,Actif,
+331,Produits en cours,,Actif,
+335,Travaux en cours,,Actif,
+34,En-cours de production de services,,Actif,
+341,Études en cours,,Actif,
+345,Prestations de services en cours,,Actif,
+35,STOCKS DE PRODUITS,,Actif,
+351,Produits intermédiaires,,Actif,
+355,Produits finis,,Actif,
+358,Produits résiduels,,Actif,
+37,STOCKS DE MARCHANDISES,,Actif,
+370,Stocks de marchandises,,Actif,
+39,PROVISIONS POUR DÉPRÉCIATIONS STOCKS & EN-COURS,,Actif,
+391,Matières premières et fournitures,,Actif,
+392,Autres approvisionnements,,Actif,
+393,En-cours de production de biens,,Actif,
+394,En-cours de production de services,,Actif,
+395,Stocks de produits,,Actif,
+397,Stocks de marchandises,,Actif,
+4,Classe 4 — Comptes de tiers,,Actif ou passif,
+40,FOURNISSEURS ET COMPTES RATTACHÉS,,Actif ou passif,
+401,Fournisseurs,,Actif ou passif,
+4010,Autres fournisseurs,,Actif ou passif,Favori
+403,Fournisseurs - Effets à payer,,Passif,
+404,Fournisseurs d'immobilisations,,Actif ou passif,
+405,Fournisseurs d'immobilisations - Effets à payer,,Passif,
+408,Fournisseurs - Factures non parvenues,,Passif,
+4091,Fournisseurs - Avances & acomptes,,Actif,
+41,BÉNÉFICIAIRES ET COMPTES RATTACHÉS,,Actif ou passif,
+410,Bénéficiaires et comptes rattachés,,Actif ou passif,
+411,Bénéficiaires,,Actif ou passif,
+4110,Autres bénéficiaires,Pour les dettes ou créances des membres,Actif ou passif,Favori
+413,Bénéficiaires - Effets à recevoir,,Actif,
+416,Bénéficiaires douteux ou litigieux,,Actif,
+418,Bénéficiaires non encore facturés,,Actif,
+419,Bénéficiaires créditeurs,,Passif,
+4191,Bénéficiaires créditeurs : avances et acomptes,,Passif,
+4198,"Rabais, remises, ristournes à accorder et autres avoirs à établir ",,Passif,
+42,PERSONNEL ET COMPTES RATTACHÉS,,Actif ou passif,
+421,Personnel : Rémunérations dues,,Passif,
+4210,Autres membres du personnel,Dettes dues aux salarié⋅e⋅s,Actif ou passif,Favori
+422,"Comités d'entreprise, d'établissement",,Actif ou passif,
+425,Personnel : Avances & acomptes,,Actif,
+427,Personnel - Oppositions,,Passif,
+4286,Personnel- Charges à payer,,Passif,
+4287,Personnel- Produits à recevoir,,Actif,
+43,SÉCURITÉ SOCIALE & AUTRES ORGANISMES SOCIAUX,,Passif,
+431,Sécurité sociale,,Passif,
+4372,Mutuelles,,Passif,
+4373,Caisses de retraites et de prévoyance,,Passif,
+4378,Autres organismes sociaux,,Passif,
+44,État ET AUTRES COLLECTIVITÉS PUBLIQUES,,Actif,
+441,État - Subventions à recevoir,,Actif,
+4421,Prélèvements à la source- Impôt sur le revenu,,Actif ou passif,
+444,État - Impôts sur les bénéfices,,Actif ou passif,
+4452,TVA due intracommunautaire,,Actif ou passif,
+4455,Taxes sur CA à décaisser,,Actif,
+44562,TVA déductible sur immobilisations,,Actif,
+44566,TVA déductible sur autres biens et services,,Actif,
+44571,TVA normale collectée,,Actif,
+445711,TVA réduite collectée,,Actif,
+445712,TVA super-réduite collectée,,Actif,
+445713,TVA intermédiaire collectée,,Actif,
+4458,Taxe sur CA à régulariser ou en attente,,Actif,
+447,"Autres impôts, taxes et versements assimilés",,Actif,
+4486,État - Charges à payer,,Passif,
+4487,État - Produits à recevoir,,Actif,
+45,"CONFÉDÉRATION, FÉDÉRATION, UNIONS, etc. AFFILIÉES",,Actif ou passif,
+451,"Confédération, fédération et associations affiliées",,Actif ou passif,
+455,Partenaires - comptes courants,,Actif ou passif,
+46,DÉBITEURS DIVERS ET CREDIT.DVS,,Actif ou passif,
+461,Créances reçues par legs ou donations,,Actif,
+466,Dettes des legs ou donations,,Passif,
+4671,Débiteurs divers,,Actif ou passif,
+4672,Créditeurs divers,,Actif ou passif,
+4681,Frais des bénévoles,,Actif ou passif,
+4686,Divers - Charges à payer,,Passif,
+4687,Divers - Produits à recevoir,,Actif,
+47,COMPTES D'ATTENTE,,Actif ou passif,
+4715,Compte de transit,,Actif ou passif,
+4718,Compte d'attente,,Actif ou passif,
+48,COMPTES DE RÉGULARISATION,,Actif ou passif,
+481,Charges à répartir,,Passif,
+486,Charges constatées d'avance,,Actif,
+487,Produits constatés d'avance,,Passif,
+49,DÉPRÉCIATIONS DES COMPTES DE TIERS,,Actif ou passif,
+491,Provisions pour dépréciation des comptes d'usagers,,Passif,
+496,Provisions pour dépréciations des comptes débiteurs divers,,Passif,
+5,Classe 5 — Comptes financiers,,Actif,
+50,VALEURS MOBILIÈRES DE PLACEMENT,,Actif,
+503,Actions,,Actif,
+506,Obligations,,Actif,
+508,Autres valeurs mobilières de placement et créances assimilées,,Actif,
+51,"BANQUES, ÉTABLISSEMENTS FINANCIERS",,Actif,
+5112,Chèques à encaisser,,Actif ou passif,Favori
+5115,Paiements par carte à encaisser,,Actif ou passif,
+512,Banques,,Actif ou passif,
+512A,Compte courant Fonctionnement,,Actif ou passif,Favori
+512B,Compte courant Œuvres Sociales,,Actif ou passif,Favori
+5186,Intérêts courus à payer,,Passif,
+5187,Intérêts courus à recevoir,,Actif,
+53,Caisses,,Actif ou passif,
+530,Caisse,,Actif ou passif,Favori
+58,VIREMENTS INTERNES,,Actif ou passif,
+580,Virements internes,,Actif ou passif,
+59,DÉPRÉCIATIONS DES COMPTES FINANCIERS,,Actif ou passif,
+5903,Actions,,Actif ou passif,
+5906,Obligations,,Actif ou passif,
+5908,Autres valeurs mobilières de placement et créances assimilées,,Actif ou passif,
+6,Classe 6 — Comptes de charges,,Charge,
+60,ACHATS (SAUF 603),,Charge,
+601,Achats stockés - Matières premières et fournitures,,Charge,
+6010,Achats stockés de matières et fournitures,,Charge,
+6011,Achats stockés - Matières premières,,Charge,
+6017,Achats stockés - Fournitures,,Charge,
+602,Achats stockés - Autres approvisionnements,,Charge,
+6021,Achats stockés - Matières consommables,,Charge,
+60221,Achats stockés - Combustibles,,Charge,
+60222,Achats stockés - Produits d'entretien,,Charge,
+60223,Achats stockés - Fournitures d'atelier,,Charge,
+60224,Achats stockés - Fournitures de magasin,,Charge,
+60225,Fournitures de bureau,,Charge,
+6026,Achats stockés - Emballages,,Charge,
+603,Variations de stocks,,Charge,
+6031,Variations de stocks matières & fournitures,,Charge,
+6032,Variations stocks autres approvisionnements,,Charge,
+6037,Variations de stocks de marchandises,,Charge,
+604,Achats d'études et prestations de services,,Charge,
+605,"Achats matériel, équipements & travaux",,Charge,
+606,Achats non stockés de matières et fournitures,,Charge,Favori
+6061,"Fournitures non stockables (eau, énergie...)","Facture d'eau, d’électricité, etc.",Charge,Favori
+60611,Eau,,Charge,Favori
+60612,Électricité,,Charge,Favori
+60613,Chauffage,,Charge,Favori
+6063,Fournitures d'entretien et petit équipement,"Vis, et matériel de bricolage (sauf outils) par exemple",Charge,Favori
+6064,Fournitures administratives,"Cartouches d'encre, papier, matériel bureautique, etc.",Charge,Favori
+6065,Petits logiciels,Par exemple contribution à un logiciel de gestion associative génial :-),Charge,Favori
+6068,Autres fournitures & matières,,Charge,Favori
+607,Achats de marchandises,Marchandises destinées à être revendues en l'état.,Charge,Favori
+608,Frais accessoires d'achats,,Charge,
+60811,Frais accessoires d'achats sur matières,,Charge,
+60817,Frais accessoires d'achats sur fournitures,,Charge,
+609,"Rabais, remises et ristournes sur achats",,Charge,
+6091,"Rabais, remises, ristournes sur achats matières et fournitures",,Charge,
+6092,"Rabais, remises, ristournes sur achats et autres approvisionnements",,Charge,
+6097,"Rabais, remises, ristournes sur achats de marchandises",,Charge,
+61,SERVICES EXTÉRIEURS,,Charge,
+611,Sous-traitance générale,,Charge,
+6122,Redevance crédit-bail mobilier,,Charge,
+6125,Redevances crédit-bail immobilier,,Charge,
+6132,Locations immobilières,Locations versées pour un local ou du matériel.,Charge,Favori
+6135,Locations mobilières,,Charge,
+6136,Malis sur emballages,,Charge,
+614,Charges locatives et de copropriété,,Charge,
+6152,Entretien sur biens immobiliers,,Charge,
+6155,Entretien sur biens mobiliers,,Charge,
+6156,Maintenance,,Charge,
+616,Primes d'assurance,"Frais d’assurance local, activité, etc.",Charge,Favori
+6161,Primes d'assurances multirisques,,Charge,
+6164,Primes d'assurances / risques d'exploitation,,Charge,
+6165,Primes d'assurances / insolvabilité usagers,,Charge,
+6168,Autres assurances,,Charge,
+617,Etudes et recherches,,Charge,
+6181,Documentation générale,,Charge,
+6183,Documentation technique,,Charge,
+6185,"Frais de colloques, séminaires, conférences",,Charge,
+6187,Prestations administratives,,Charge,
+62,AUTRES SERVICES EXTÉRIEURS,,Charge,
+621,Personnel extérieur à l'association,,Charge,
+6211,Personnel intérimaire,,Charge,
+6214,Personnel détaché ou prêté à l'association,,Charge,
+62141,Mises à disposition de personnel salarié,Frais de mise à disposition via un groupement d’employeurs,Charge,Favori
+622,Rémunérations d'intermédiaires et honoraires,,Charge,
+6221,Commissions ... sur achats,,Charge,
+6222,Commissions... sur ventes,,Charge,
+6226,Honoraires,,Charge,
+62264,Honoraires sur legs ou donations à céder,,Charge,
+6227,Frais d'actes et de contentieux,,Charge,
+6228,Rémunérations divers intermédiaires & honoraires,,Charge,
+623,"Publicité, publications, relations publiques","Bulletins, affiches, communication, etc.",Charge,Favori
+6231,Annonces et insertions,,Charge,
+6232,Fêtes et cérémonies,,Charge,
+6233,Foires et expositions,,Charge,
+6234,Cadeaux,,Charge,
+6236,Catalogues et imprimés,,Charge,
+6237,Publications,,Charge,
+6238,"Divers : pourboires, dons courants",,Charge,
+624,Transports de biens...,,Charge,
+6241,Transports sur achats,,Charge,
+6242,Transports sur ventes,,Charge,
+6243,Transports entre établissements,,Charge,
+6244,Transports administratifs,,Charge,
+6247,Transports collectifs du personnel,,Charge,
+6248,Transports divers,,Charge,
+625,"Déplacements, missions et réceptions","Billet de train, remboursement de frais kilométrique, etc.",Charge,Favori
+6251,Voyages et déplacements,,Charge,
+6255,Frais de déménagement,,Charge,
+6256,Frais de missions,,Charge,
+6257,"Frais de réceptions, représentations",,Charge,
+626,Frais postaux et de télécommunications,"Facture d'accès à Internet, timbres, etc.",Charge,Favori
+6261,Liaisons spécialisées,,Charge,
+6263,"Affranchissements, frais postaux",,Charge,
+6265,Téléphone,,Charge,
+627,Services bancaires et assimilés,Frais bancaires,Charge,Favori
+628,Divers,,Charge,Favori
+6281,Cotisations (liées à l'activité économique),,Charge,
+6284,Frais de recrutement du personnel,,Charge,
+63,"IMPÔTS, TAXES ET VERSEMENTS ASSIMILÉS",,Charge,
+631,Sur rémunérations - administration des impôts,,Charge,
+6311,Taxe sur les salaires,,Charge,
+633,Sur rémunérations - autres organismes,,Charge,
+6331,Versement de transport,,Charge,
+6332,Allocation logement,,Charge,
+6333,Formation professionnelle continue,,Charge,
+6334,Participations employeurs à l'effort de construction,,Charge,
+635,Autres - Administration des impôts,,Charge,
+63512,Taxes foncières,,Charge,
+63513,Autres impôts locaux,,Charge,
+6354,Droits d'enregistrement et de timbre,,Charge,
+637,Autres - Autres organismes,,Charge,
+64,CHARGES DE PERSONNEL,,Charge,
+641,Rémunérations du personnel,,Charge,
+6411,"Salaires, appointements",,Charge,
+6412,Congés payés,,Charge,
+6413,Primes et gratifications,,Charge,
+6414,Indemnités et avantages divers,,Charge,
+6415,Supplément familial,,Charge,
+645,Charges de sécurité sociale et de prévoyance,,Charge,
+6451,Cotisations à l'URSSAF,,Charge,
+6452,Cotisations aux mutuelles,,Charge,
+6453,Cotisations caisses de retraites et de prévoyance,,Charge,
+6458,Cotisations aux autres organismes sociaux,,Charge,
+647,Autres charges sociales,,Charge,
+6472,Versements aux comités d'entreprise et d'établissement,,Charge,
+6473,Versement aux comités d'hygiène et de sécurité,,Charge,
+6474,Versements aux autres œuvres sociales,,Charge,
+6475,"Médecine du travail, pharmacie",,Charge,
+648,Autres charges de personnel,,Charge,
+6481,Indemnités du personnel de culte,,Charge,
+6485,Charges sociales sur indemnités de culte,,Charge,
+6488,Autres charges de personnel,,Charge,
+65,AUTRES CHARGES DE GESTION COURANTE,,Charge,
+6511,"Redevances pour concessions, brevets, licences",,Charge,
+6516,Droits d'auteur et de reproduction,,Charge,
+6518,Autres droits et valeurs similaires,,Charge,
+652,Licences fédérales,Licences payées pour les adhérents (par exemple fédération sportive etc.),Charge,Favori
+653,Charges de la générosité du public,,Charge,
+6531,Autres charges sur legs ou donations,,Charge,
+654,Pertes sur créances irrécouvrables,,Charge,
+655,Quotes-parts sur opérations faites en commun,,Charge,
+657,Aides financières,,Charge,
+6571,Aides financières octroyées,,Charge,
+6572,Quotes-parts de générosité reversée,,Charge,
+658,Charges diverses de gestion courante,,Charge,Favori
+6586,Cotisations (vie statutaire),,Charge,
+6588,Charges diverses de gestion courante,,Charge,
+66,CHARGES FINANCIÈRES,,Charge,
+661,Charges d'intérêts,,Charge,
+665,Escomptes accordés,,Charge,
+666,Pertes de changes,,Charge,
+667,Charges nettes sur cessions de valeurs mobilières de placement,,Charge,
+668,Autres charges financières,,Charge,
+67,Charges exceptionnelles,,Charge,
+670,Charges exceptionnelles,Autres dépenses exceptionnelles,Charge,Favori
+6712,"Pénalités, amendes fiscales et pénales",,Charge,
+6713,"Dons, libéralités",,Charge,
+6714,Créances devenues irrécouvrables,,Charge,
+6718,Autres charges exceptionnelles de gestion,,Charge,
+673,Apports ou affectations en numéraire,,Charge,
+675,Valeurs comptables des éléments d'actifs cédés,,Charge,
+6750,Valeurs comptables des actifs cédés,,Charge,
+6754,Immobilisations reçues par legs ou donations,,Charge,
+678,Autres charges exceptionnelles sur opération en capital,,Charge,
+68,"Dotation AUX AMORTISSEMENTS, DÉPRÉCIATIONS ET ENGAGEMENTS",,Charge,
+6811,Dotation aux amortissements des immobilisations,,Charge,
+6812,Dotation aux amortissements charges à répartir,,Charge,
+6815,Dotation aux provisions d'exploitation,,Charge,
+6816,Dotation provisions pour dépréciations des immobillisations,,Charge,
+68164,Dotation pr dépréc. d’actifs reçus par legs ou donations,,Charge,
+6817,Dotation aux dépréciations des actifs circulants,,Charge,
+68173,Dotations dépréciations stocks et en-cours,,Charge,
+68174,Dotations dépréciations créances,,Charge,
+686,Dotation aux amortissements & Provisions - Charges financières,,Charge,
+68662,Dotation aux amortissements & provisions immobilisations financières,,Charge,
+68665,Dotation aux amortissements & provisions valeurs mobilières de placement,,Charge,
+687,Dotation aux amortissements & Provisions - Charges exceptionnelles,,Charge,
+689,Reports en fonds dédiés,,Charge,
+6891,Reports en fonds reportés,,Charge,
+6894,Reports en fonds dédiés / subventions d’exploitation,,Charge,
+6895,Reports en fonds dédiés / contributions financières d'autres organismes,,Charge,
+6896,Reports en fonds dédiés / ressources générosité,,Charge,
+69,IMPÔTS SUR LES BÉNÉFICES,,Charge,
+695,Impôts sur les bénéfices,,Charge,
+7,Classe 7 — Comptes de produits,,Produit,
+70,"VENTES PROD.FINIS, MARCHANDISES, PRESTATIONS",,Produit,
+701,Ventes de produits finis,Vente de produits fabriqués par l'association.,Produit,Favori
+702,Ventes de produits intermédiaires,,Produit,
+703,Ventes de produits résiduels,,Produit,
+704,Travaux,,Produit,
+705,Études,,Produit,Favori
+706,Prestations de services,,Produit,Favori
+7063,Parrainages,,Produit,
+707,Ventes de marchandises,Ventes de produits achetés et revendus en l’état,Produit,Favori
+7073,Ventes de dons en nature,,Produit,
+708,Produits des activités annexes,,Produit,
+7081,Produits des services exploités dans l’intérêt du personnel,,Produit,
+7083,Locations diverses,,Produit,
+7085,Ports et frais accessoires facturés,,Produit,
+7088,Autres produits d'activités annexes,,Produit,
+709,"Rabais, remises, ristournes accordés",,Produit,
+7091,"Rabais, remises, ristournes sur ventes de produits finis",,Produit,
+7092,"Rabais, remises, ristournes sur ventes de produits intermédiaires",,Produit,
+7094,"Rabais, remises, ristournes sur travaux",,Produit,
+7095,"Rabais, remises, ristournes sur études",,Produit,
+7096,"Rabais, remises, ristournes sur prest.de services",,Produit,
+7097,"Rabais, remises, ristournes sur ventes marchandises",,Produit,
+71,PRODUCTION STOCKÉE,,Produit,
+713,"Variation de stocks (en-cours, productions)",,Produit,
+7133,Variation des en-cours de production de biens,,Produit,
+7134,Variation des en-cours de production services,,Produit,
+7135,Variations de stocks de produits,,Produit,
+72,PRODUCTION IMMOBILISÉE,,Produit,
+721,Production immobilisée incorporelle,,Produit,
+722,Production immobilisée corporelle,,Produit,
+73,CONCOURS PUBLICS,,Produit,
+730,Concours publics,,Produit,
+74,SUBVENTION D'EXPLOITATION,,Produit,
+740,Subventions reçues,,Produit,Favori
+7403,Autres subventions,,Produit,Favori
+748,Subventions d'exploitation diverses,,Produit,
+75,AUTRES PRODUITS DE GESTION COURANTE,,Produit,
+751,"Redevances pour concessions, licences...",,Produit,
+753,Versements des fondateurs ou consommation dot,,Produit,
+7531,Versements des fondateurs,,Produit,
+7532,Quotes-parts de dotation consomptible virée a,,Produit,
+754,Ressources liées à la générosité du public,Dons reçus,Produit,Favori
+7541,Dons manuels,,Produit,
+75411,Dons manuels,,Produit,
+75412,Abandons de frais par les bénévoles,,Produit,
+7542,Mécénats,,Produit,
+7543,"Legs, donations et assurances-vie",,Produit,
+75431,Assurances-vie,,Produit,
+75432,Legs ou donations,,Produit,
+75433,Autres produits sur legs ou donations,,Produit,
+755,Contributions financières,,Produit,
+7551,Contributions financières d’autres organismes,,Produit,
+7552,Quotes-parts de générosité reçues,,Produit,
+756,Cotisations,Cotisations des adhérent⋅e⋅s,Produit,Favori
+7561,Cotisations sans contrepartie,,Produit,
+7562,Cotisations avec contrepartie,,Produit,
+756201,Subvention de fonctionnement reçue l'employeur,Produits affectés à la section « Attributions économiques et professionnelles »,Produit,Favori
+756202,Contribution reçue de l'employeur,Produits affectés à la section « Activités sociales et culturelles »,Produit,Favori
+757,Gains de change / créances et dettes d’exploitation,,Produit,
+758,Produits divers de gestion courante,,Produit,
+7588,Autres produits divers de gestion courante,,Produit,
+76,PRODUITS FINANCIERS,,Produit,
+761,Produits des participations,,Produit,
+762,Produits des autres immobilisations financières,,Produit,
+763,Revenus des autres créances,,Produit,
+764,Revenus des valeurs mobilières de placement,,Produit,
+765,Escomptes obtenus,,Produit,
+766,Gains de change,,Produit,
+767,Produits nets sur cession valeurs mobilières de placement,,Produit,
+768,Autres produits financiers,,Produit,
+77,PRODUITS EXCEPTIONNELS,,Produit,
+771,Produits exceptionnels sur opération de gestion,,Produit,
+7713,Libéralités perçues,,Produit,
+7718,Autres produits exceptionnels sur opération de gestion,,Produit,
+775,Produits des cessions d'actif,,Produit,
+7754,Immobilisations reçues en legs ou donations à céder,,Produit,
+777,Quote-part subvention d'investissement virée au résultat,,Produit,
+778,Autres produits exceptionnels,,Produit,
+7780,Manifestations diverses,"Revenus provenant de manifestations au profit de l'association : droit d'entrée, location d'emplacement en vide grenier, ventes, etc.",Produit,Favori
+78,"REPRISES SUR AMORTISSEMENTS, DÉPRÉCIATIONS, ENGAGEMENTS",,Produit,
+781,Reprises / Amortissements & Provisions d'exploitation,,Produit,
+7811,Amortissements immobilisations corporelles & incorporelles,,Produit,
+7815,Reprises sur provisions d'exploitation,,Produit,
+7816,Dépréciations immobilisations corporelles & incorporelles,,Produit,
+78164,Reprises dépréciations d’actifs reçus par legs ou donations destinés à être cédés,,Produit,
+7817,Dépréciations actifs circulant,,Produit,
+786,Reprises sur provisions pour risques et dépréciations ,À inscrire dans les produits exceptionnels,Produit,
+7865,Risques & charges financiers,,Produit,
+7866,Déprec.des éléments financiers,,Produit,
+787, Reprises sur provisions pour risques et dépréciations,À inscrire dans les produits exceptionnels,Produit,
+7872,Provisions réglementées - Immobilisations,,Produit,
+7873,Provisions réglementées - stocks,,Produit,
+7874,Autres provisions réglementées,,Produit,
+7875,Risques et charges,,Produit,
+7876,Dépréciations exceptionnelles,,Produit,
+789,Utilisations fonds reportés et de fonds dédiés,,Produit,
+7891,Utilisations de fonds reportés,,Produit,
+7894,Utilisations des fonds dédiés / subventions,,Produit,
+7895,Utilisations des fonds dédiés / contributions,,Produit,
+7896,Utilisations des fonds dédiés / générosité,,Produit,
+79,TRANSFERT DE CHARGES,,Produit,
+791,Transferts de charges d'exploitation,,Produit,
+796,Transferts de charges financières,,Produit,
+797,Transferts de charges exceptionnelles,,Produit,
+8,Classe 8 — Comptes spéciaux,,,
+80,ENGAGEMENTS,,,
+801,Engagements donnés par l’entité,,,
+8011,"Avals, cautions, garanties",,,
+8014,Effets circulant sous l’endos de l’entité,,,
+8016,Redevances crédit-bail restant à courir,,,
+80161,Crédit-bail mobilier,,,
+80165,Crédit-bail immobilier,,,
+8018,Autres engagements donnés,,,
+802,Engagements reçus par l’entité,,,
+8021,"Avals, cautions, garanties",,,
+8024,Créances escomptées non échues,,,
+8026,Engagements reçus pour utilisation en crédit-bail,,,
+80261,Crédit-bail mobilier,,,
+80265,Crédit-bail immobilier,,,
+8028,Autres engagements reçus,,,
+809,Contrepartie des engagements,,,
+8091,Contrepartie 801,,,
+8092,Contrepartie 802,,,
+86,EMPLOI DES CONTRIBUTIONS VOLONTAIRES EN NATURE,,,
+860,"Secours en nature (alimentaires, vestimentaires…)",,Charge,
+861,"Mise à disposition gratuite de biens (locaux, matériels…)",,Charge,
+862,Prestations,,Charge,
+864,Personnel bénévole,,Charge,
+87,CONTRIBUTIONS VOLONTAIRES EN NATURE,,,
+870,Bénévolat,,Produit,
+871,Prestations en nature,,Produit,
+875,Dons en nature,,Produit,
+89,COMPTES DE BILAN,,,
+890,Bilan d'ouverture,,Actif ou passif,
+891,Bilan de clôture,,Actif ou passif,
diff --git a/src/include/data/charts/fr_pca_1999.csv b/src/include/data/charts/fr_pca_1999.csv
new file mode 100644
index 0000000..6b16f03
--- /dev/null
+++ b/src/include/data/charts/fr_pca_1999.csv
@@ -0,0 +1,295 @@
+code,label,description,position,bookmark
+1,"Classe 1 — Comptes de capitaux (Fonds propres, emprunts et dettes assimilés)",,Passif,
+10,FONDS ASSOCIATIFS ET RÉSERVES,,Passif,
+102,Fonds associatif sans droit de reprise,,Passif,
+1021,Valeur du patrimoine intégré,,Passif,
+1022,Fonds statutaire,,Passif,
+1024,Apports sans droit de reprise,,Passif,
+103,Fonds associatif avec droit de reprise,,Passif,
+1034,Apports avec droit de reprise,,Passif,
+105,Écarts de réévaluation,,Passif,
+106,Réserves,,Passif,
+1063,Réserves statutaires ou contractuelles,,Passif,
+1064,Réserves réglementées,,Passif,
+1068,Autres réserves (dont réserves pour projet associatif),,Passif,
+11,REPORT À NOUVEAU,,Passif,
+110,Report à nouveau (Solde créditeur),,Passif,
+119,Report à nouveau (Solde débiteur),,Passif,
+12,RÉSULTAT NET DE L'EXERCICE,,Passif,
+120,Résultat de l'exercice (excédent),,Passif,
+129,Résultat de l'exercice (déficit),,Passif,
+13,SUBVENTIONS D'INVESTISSEMENT AFFECTÉES A DES BIENS NON RENOUVELABLES,,Passif,
+131,Subventions d'investissement (renouvelables),,Passif,
+139,Subventions d'investissement inscrites au compte de résultat,,Passif,
+14,PROVISIONS REGLEMENTÉES,,Passif,
+15,PROVISIONS,,Passif,
+151,Provisions pour risques,,Passif,
+157,Provisions pour charges à répartir sur plusieurs exercices,,Passif,
+158,Autres provisions pour charges,,Passif,
+16,EMPRUNTS ET DETTES ASSIMILÉES,,Passif,
+164,Emprunts auprès des établissements de crédits,,Passif,
+165,Dépôts et cautionnements reçus,,Passif,
+167,Emprunts et dettes assorties de conditions particulières,,Passif,
+168,Autres emprunts et dettes assimilés,,Passif,
+17,DETTES RATTACHÉES À DES PARTICIPATIONS,,Passif,
+18,COMPTES DE LIAISON DES ÉTABLISSEMENTS,,Passif,
+181,Apports permanents entre siège social et établissements,,Passif,
+185,Biens et prestations de services échangés entre établissements et siège social,,Passif,
+186,Biens et prestations de services échangés entre établissements (charges),,Passif,
+187,Biens et prestations de services échangés entre établissements (produits),,Passif,
+19,FONDS DÉDIÉS,,Passif,
+194,Fonds dédiés sur subventions de fonctionnement,,Passif,
+195,Fonds dédiés sur dons manuels affectés,,Passif,
+197,Fonds dédiés sur legs et donations affectés,,Passif,
+198,Excédent disponible après affectation au projet associatif,,Passif,
+199,Reprise des fonds affectés au projet associatif,,Passif,
+2,Classe 2 — Comptes d'immobilisations,,Actif,
+20,IMMOBILISATIONS INCORPORELLES,,Actif,
+200,Immobilisations incorporelles,,Actif,
+21,IMMOBILISATIONS CORPORELLES,,Actif,
+210,Investissements,,Actif,
+22,IMMOBILISATIONS GREVÉES DE DROITS,,Actif,
+228,Immobilisations grevées de droits,,Actif,
+229,Droits des propriétaires,,Actif,
+23,IMMOBILISATIONS EN COURS,,Actif,
+231,Immobilisations corporelles en cours,,Actif,
+238,Avances et acomptes versés sur commande d'immobilisations corporelles,,Actif,
+26,PARTICIPATIONS ET CRÉANCES RATTACHÉES A DES PARTICIPATIONS,,Actif,
+261,Titres de participation,,Actif,
+27,AUTRES IMMOBILISATIONS FINANCIÈRES,,Actif,
+270,Participations financières,,Actif,
+275,Dépôts et cautionnements versés,,Actif,
+28,AMORTISSEMENTS DES IMMOBILISATIONS,,Actif,
+280,Amortissements des immobilisations incorporelles,,Actif,
+281,Amortissements des immobilisations corporelles,,Actif,
+29,DÉPRÉCIATION DES IMMOBILISATIONS,,Actif,
+290,Dépréciation des immobilisations incorporelles,,Actif,
+291,Dépréciation des immobilisations corporelles,,Actif,
+3,Classe 3 — Comptes de stocks,,Actif,
+31,MATIERES PREMIERES ET FOURNITURES,,Actif,
+311,Matières,,Actif,
+317,Fournitures,,Actif,
+32,AUTRES APPROVISIONNEMENTS,,Actif,
+321,Matières consommables,,Actif,
+322,Fournitures consommables,,Actif,
+33,EN-COURS DE PRODUCTION DE BIENS,,Actif,
+331,Produits en cours,,Actif,
+335,Travaux en cours,,Actif,
+34,EN-COURS DE PRODUCTION DE SERVICES,,Actif,
+35,STOCKS DE PRODUITS,,Actif,
+351,Produits intermédiaires,,Actif,
+355,Produits finis,,Actif,
+358,Produits résiduels,,Actif,
+3581,Déchets,,Actif,
+3585,Rebuts,,Actif,
+3586,Matière de récupération,,Actif,
+37,STOCKS DE MARCHANDISES,,Actif,
+370,Autres stocks de marchandises,,Actif,
+39,PROVISIONS POUR DEPRECIATION DES STOCKS ET EN-COURS,,Actif,
+391,Provisions pour dépréciation des matières premières et fournitures,,Actif,
+4,Classe 4 — Comptes de tiers,,Actif ou passif,
+40,FOURNISSEURS ET COMPTES RATTACHÉS,,Passif,
+401,Fournisseurs,,Passif,
+4010,Autres fournisseurs,,Actif ou passif,Favori
+408,Fournisseurs - Factures non parvenues,,Passif,
+409,Avances aux fournisseurs,,Actif,
+41,USAGERS ET COMPTES RATTACHÉS,,Actif,
+411,Usagers,,Actif,
+4110,Autres usagers,,Actif ou passif,Favori
+419,Avances aux usagers,,Passif,
+42,PERSONNEL ET COMPTES RATTACHÉS,,Passif,
+421,Personnel - Rémunérations dues,,Passif,
+4210,Autres membres du personnel,,Actif ou passif,
+425,Personnel - Avances et acomptes,,Actif,
+428,Personnel - Charges à payer et produits à recevoir,,Actif ou passif,
+43,SÉCURITÉ SOCIALE ET AUTRES ORGANISMES SOCIAUX,,Passif,
+430,Dettes et crédits envers les organismes sociaux,,Passif,
+431,Sécurité sociale,,Passif,
+437,Autres organismes sociaux,,Passif,
+4372,Mutuelles,,Passif,
+4373,Caisse de retraite et de prévoyance,,Passif,
+4374,Caisse d'allocations de chômage - Pôle emploi,,Passif,
+4375,AGESSA,,Passif,
+4378,Autres organismes sociaux - Divers,,Passif,
+438,Organismes sociaux - Charges à payer et produits à recevoir,,Actif ou passif,
+4382,Charges sociales sur congés à payer,,Passif,
+4386,Autres charges à payer,,Passif,
+4387,Produits à recevoir,,Actif,
+439,Avances auprès des organismes sociaux,,Passif,
+44,ÉTAT ET AUTRES COLLECTIVITÉS PUBLIQUES,,Actif,
+441,État - Subventions à recevoir,,Actif,
+4411,Subventions d'investissement,,Actif,
+4417,Subventions d'exploitation,,Actif,
+4418,Subventions d'équilibre,,Actif,
+4419,Avances sur subventions,,Actif,
+442,État - Impôts et taxes recouvrables sur des tiers,,Passif,
+444,État - Impôts sur les bénéfices,,Actif ou passif,
+445,État - Taxes sur le chiffre d'affaires,,Actif ou passif,
+4455,Taxes sur le chiffre d'affaires à décaisser,,Actif,
+44551,TVA à décaisser,,Actif,
+44558,Taxes assimilées à la TVA,,Actif,
+4456,Taxes sur le chiffre d'affaires déductibles,,Actif,
+44562,TVA sur immobilisations,,Actif,
+44566,TVA sur autres biens et services,,Actif,
+4457,Taxes sur le chiffre d'affaires collectées par l'association,,Actif,
+4458,Taxes sur le chiffre d'affaires à régulariser ou en attente,,Actif,
+44581,Acomptes - Régime simplifié d'imposition,,Actif,
+44582,Acomptes - Régime du forfait,,Actif,
+44583,Remboursement de taxes sur le chiffre d'affaires demandé,,Actif,
+44584,TVA récupérée d'avance,,Actif,
+44586,Taxes sur le chiffre d'affaires sur factures non parvenues,,Actif,
+44587,Taxes sur le chiffre d'affaires sur factures à établir,,Actif,
+447,"Autres impôts, taxes et versements assimilés",,Passif,
+4471,"Autres impôts, taxes et versements assimilés sur rémunérations (Administration des impôts)",,Passif,
+44711,Taxe sur les salaires,,Passif,
+44713,Participation des employeurs à la formation professionnelle continue,,Passif,
+44714,Cotisation par défaut d'investissement obligatoire dans la construction,,Passif,
+44718,"Autres impôts, taxes et versements assimilés",,Passif,
+4473,"Autres impôts, taxes et versements assimilés sur rémunérations (Autres organismes)",,Passif,
+44733,Participation des employeurs à la formation professionnelle continue,,Passif,
+44734,Participation des employeurs à l'effort de construction (versements à fonds perdus),,Passif,
+4475,"Autres impôts, taxes et versements assimilés (Administration des impôts)",,Passif,
+4477,"Autres impôts, taxes et versements assimilés (Autres organismes)",,Passif,
+448,État - Charges à payer et produits à recevoir,,Passif,
+4482,Charges fiscales sur congés à payer,,Passif,
+4486,Autres charges à payer,,Passif,
+4487,Produits à recevoir,,Actif,
+449,Avances auprès de l'état et des collectivités publiques,,Passif,
+45,"CONFÉDÉRATION, FÉDÉRATION, UNIONS ET ASSOCIATIONS AFFILIÉES",,Actif ou passif,
+451,"Confédération, fédération et associations affiliées - Compte courant",,Actif ou passif,
+455,Sociétaires - Comptes courants,,Actif ou passif,
+46,DÉBITEURS DIVERS ET CRÉDITEURS DIVERS,,Actif ou passif,
+467,Autres comptes débiteurs et créditeurs,,Actif ou passif,
+468,Divers - Charges à payer et produits à recevoir,,Actif ou passif,
+4686,Charges à payer,,Passif,
+4687,Produits à recevoir,,Actif,
+47,COMPTES TRANSITOIRES OU D'ATTENTE,,Actif ou passif,
+471,Recettes à classer,,Passif,
+472,Dépenses à classer et à régulariser,,Actif,
+48,COMPTES DE RÉGULARISATION,,Actif ou passif,
+481,Charges à répartir sur plusieurs exercices,,Actif,
+486,Charges constatées d'avance,,Actif,
+487,Produits constatés d'avance,,Passif,
+49,DEPRECIATION DES COMPTES DE TIERS,,Actif,
+491,Dépréciation des comptes clients,,Actif,
+496,Dépréciation des comptes débiteurs divers,,Actif,
+5,Classe 5 — Comptes financiers,,Actif,
+50,VALEURS MOBILIÈRES DE PLACEMENT,,Actif,
+51,"BANQUES, ÉTABLISSEMENTS FINANCIERS ET ASSIMILÉS",,Actif,
+511,Valeurs à l'encaissement,,Actif,
+5112,Chèques à encaisser,,Actif ou passif,Favori
+5115,Paiements par carte à encaisser,,Actif ou passif,
+512,Banques,,Actif ou passif,
+53,CAISSE,,Actif,
+530,Caisse,,Actif ou passif,Favori
+54,RÉGIES D'AVANCES ET ACCRÉDITIFS,,Actif,
+58,VIREMENTS INTERNES,,Actif,
+59,PROVISIONS POUR DÉPRÉCIATION DES COMPTES FINANCIERS,,Actif,
+6,Classe 6 — Comptes de charges,,Charge,
+60,ACHATS,,Charge,
+601,Achats stockés - Matières premières et fournitures,,Charge,
+602,Achats stockés - Autres approvisionnements,,Charge,
+604,Achat d'études et prestations de services,,Charge,Favori
+606,Achats non stockés de matières et fournitures,,Charge,
+6061,"Fournitures non stockables (eau, énergie...)","Facture d'eau, d'opérateur électrique, etc.",Charge,Favori
+6063,Fournitures d'entretien et de petit équipement,,Charge,Favori
+6064,Fournitures administratives,"Cartouches d'encre, papier, matériel bureautique, etc.",Charge,Favori
+6068,Autres matières et fournitures,,Charge,Favori
+607,Achats de marchandises,Marchandises destinées à être revendues en l'état.,Charge,Favori
+61,SERVICES EXTÉRIEURS,,Charge,
+611,Sous-traitance générale,,Charge,
+612,Redevances de crédit-bail,,Charge,
+613,Locations,Locations versées pour un local ou du matériel.,Charge,Favori
+614,Charges locatives et de co-propriété,,Charge,
+615,Entretiens et réparations,,Charge,
+616,Primes d'assurance,,Charge,Favori
+618,Divers,,Charge,
+62,AUTRES SERVICES EXTÉRIEURS,,Charge,
+621,Personnel extérieur à l'association,,Charge,
+62141,Mises à disposition de personnel salarié,,Charge,Favori
+622,Rémunérations d'intermédiaires et honoraires,,Charge,
+6226,Honoraires,,Charge,
+6227,Frais d'actes et de contentieux,"Insertion au Journal Officiel, frais de justice, etc.",Charge,Favori
+6228,Divers,,Charge,
+623,"Publicité, publications, relations publiques","Bulletins, affiches, communication, etc.",Charge,Favori
+624,Transports de biens et transports collectifs du personnel,,Charge,
+625,"Déplacements, missions et réceptions","Billet SNCF, remboursement de frais kilométrique, etc.",Charge,Favori
+626,Frais postaux et de télécommunications,"Facture d'accès à Internet, timbres, etc.",Charge,Favori
+627,Services bancaires et assimilés,Frais bancaires,Charge,Favori
+628,Divers,,Charge,Favori
+63,"IMPÔTS, TAXES ET VERSEMENTS ASSIMILÉS",,Charge,
+631,"Impôts, taxes et versements assimilés sur rémunérations (Administration des impôts)",,Charge,
+6311,Taxes sur les salaires,,Charge,
+6313,Participations des employeurs à la formation professionnelle continue,,Charge,
+635,"Autres impôts, taxes et versements assimilés (Administration des impôts)",,Charge,
+6351,Impôts directs (sauf impôts sur les bénéfices),,Charge,
+6353,Impôts indirects,,Charge,
+637,"Autres impôts, taxes et versements assimilés (Autres organismes)",,Charge,
+64,CHARGES DE PERSONNEL,,Charge,
+641,Rémunérations du personnel,,Charge,
+643,Rémunérations du personnel artistique et assimilés,,Charge,
+645,Charges de sécurité sociale et de prévoyance,,Charge,
+647,Autres charges sociales,,Charge,
+648,Autres charges de personnel,,Charge,
+65,AUTRES CHARGES DE GESTION COURANTE,,Charge,
+652,Licences fédérales,Licences payées pour les adhérents (par exemple fédération sportive etc.),Charge,Favori
+658,Charges diverses de gestion courante,,Charge,Favori
+66,CHARGES FINANCIÈRES,,Charge,
+661,Charges d'intérêts,,Charge,
+67,CHARGES EXCEPTIONNELLES,,Charge,
+670,Charges exceptionnelles,Autres dépenses exceptionnelles,Charge,Favori
+671,Charges exceptionnelles sur opérations de gestion,,Charge,
+6713,"Dons, libéralités",,Charge,
+678,Autres charges exceptionnelles,,Charge,
+6788,Charges exceptionnelles diverses,,Charge,
+68,"DOTATIONS AUX AMORTISSEMENTS, DÉPRÉCIATIONS, PROVISIONS ET ENGAGEMENTS",,Charge,
+681,"Dotations aux amortissements, dépréciations et provisions - Charges d'exploitation",,Charge,
+6811,Dotations aux amortissements des immobilisations incorporelles et corporelles,,Charge,
+68111,Immobilisations incorporelles,,Charge,
+68112,Immobilisations corporelles,,Charge,
+686,"Dotations aux amortissements, dépréciations et provisions - Charges financières",,Charge,
+69,PARTICIPATION DES SALARIÉS - IMPÔTS SUR LES BÉNÉFICES ET ASSIMILÉS,,Charge,
+695,Impôts sur les sociétés (y compris impôts sur les sociétés des personnes morales non lucratives),,Charge,
+7,Classe 7 — Comptes de produits,,Produit,
+70,"VENTES DE PRODUITS FINIS, PRESTATIONS DE SERVICES, MARCHANDISES",,Produit,
+701,Ventes de produits finis,Vente de produits fabriqués par l'association.,Produit,Favori
+706,Prestations de services,,Produit,Favori
+707,Ventes de marchandises,,Produit,Favori
+708,Produits des activités annexes,,Produit,
+71,PRODUCTION STOCKÉE (OU DÉSTOCKAGE),,Produit,
+72,PRODUCTION IMMOBILISÉE,,Produit,
+74,SUBVENTIONS D'EXPLOITATION,,Produit,
+740,Subventions reçues,,Produit,Favori
+75,AUTRES PRODUITS DE GESTION COURANTE,,Produit,
+754,Collectes,,Produit,Favori
+756,Cotisations,,Produit,Favori
+758,Produits divers de gestion courante,,Produit,
+7587,Ventes de dons en nature,,Produit,
+7588,Autres produits de la générosité du public,,Produit,
+76,PRODUITS FINANCIERS,,Produit,
+760,Produits financiers,,Produit,
+77,PRODUITS EXCEPTIONNELS,,Produit,
+771,Produits exceptionnels sur opérations de gestion,,Produit,
+7713,Libéralités reçues,,Produit,
+7715,Subventions d'équilibre,,Produit,
+775,Produits des cessions d'éléments d'actifs,,Produit,
+778,Autres produits exceptionnels,,Produit,
+7780,Manifestations diverses,"Revenus provenant de manifestations au profit de l'association : droit d'entrée, location d'emplacement en vide grenier, ventes, etc.",Produit,Favori
+7788,Produits exceptionnels divers,,Produit,
+78,REPRISES SUR AMORTISSEMENTS ET PROVISIONS,,Produit,
+79,TRANSFERT DE CHARGES,,Produit,
+791,Transferts de charges d'exploitation,,Produit,
+796,Transferts de charges financières,,Produit,
+797,Transferts de charges exceptionnels,,Produit,
+8,Classe 8 — Comptes spéciaux,,,
+86,RÉPARTITION PAR NATURE DE CHARGES,,Charge,
+861,Mise à dispositions gratuites de biens,,Charge,
+862,Prestations,,Charge,Favori
+864,Personnel bénévole,,Charge,Favori
+87,RÉPARTITION PAR NATURE DE RESSOURCES,,Produit,
+870,Bénévolat,,Produit,Favori
+871,Prestations en nature,,Produit,Favori
+875,Dons en nature,,Produit,
+89,BILAN,,,
+890,Bilan d'ouverture,,Actif ou passif,
+891,Bilan de clôture,,Actif ou passif,
diff --git a/src/include/data/charts/fr_pca_2018.csv b/src/include/data/charts/fr_pca_2018.csv
new file mode 100644
index 0000000..49d151f
--- /dev/null
+++ b/src/include/data/charts/fr_pca_2018.csv
@@ -0,0 +1,553 @@
+code,label,description,position,bookmark
+1,"Classe 1 — Comptes de capitaux (Fonds propres, emprunts et dettes assimilés)",,Passif,
+10,Fonds propres et réserves,,Passif,
+102,Fonds propres sans droit de reprise,,Passif,
+1021,Première situation nette établie,,Passif,
+1022,Fonds statutaires,,Passif,
+1023,Dotations non consomptibles,,Passif,
+10231,Dotations non consomptibles initiales,,Passif,
+10232,Dotations non consomptibles complémentaires,,Passif,
+1024,Autres fonds propres sans droit de reprise,,Passif,
+103,Fonds propres avec droit de reprise,,Passif,
+1032,Fonds statutaires,,Passif,
+1034,Autres fonds propres avec droit de reprise,,Passif,
+105,Ecarts de réévaluation,,Passif,
+1051,Ecarts réévaluation sur biens sans dt reprise,,Passif,
+1052,Ecarts réévaluation sur biens avec dt reprise,,Passif,
+106,Réserves,,Passif,
+1062,Réserves indisponibles,,Passif,
+1063,Réserves statutaires,,Passif,
+1064,Réserves réglementées,,Passif,
+1068,Réserves pour projet de l’entité,,Passif,
+108,Dotations consomptibles,,Passif,
+1081,Dotations consomptibles,,Passif,
+1089,Dot. consomptibles inscrites au cpte de résul,,Passif,
+11,Report à nouveau,,Passif,
+110,Report à nouveau (Solde créditeur),,Passif,
+119,Report à nouveau (Solde débiteur),,Passif,
+12,Résultat net de l’exercice,,Passif,
+120,Résultat de l'exercice (excédent),,Passif,
+129,Résultat de l'exercice (déficit),,Passif,
+13,Subventions d’investissement,,Passif,
+131,Subventions d'équipement,,Passif,
+139,Subventions inscrites au compte de résultat,,Passif,
+14,Provisions réglementées,,Passif,
+148,Autres provisions réglementées,,Passif,
+15,Provisions pour risques et charges,,Passif,
+151,Provisions pour risques,,Passif,
+152,Provisions pour charges sur legs ou donations,,Passif,
+153,Provisions pour pensions et obligations simil,,Passif,
+155,Provisions pour impôts,,Passif,
+157,Provisions pour charges à répartir,,Passif,
+158,Autres provisions pour charges,,Passif,
+16,Emprunts et dettes assimilées,,Passif,
+163,Autres emprunts obligataires,,Passif,
+1631,Titres associatifs et assimilés,,Passif,
+164,Emprunts auprès des établissements de crédit,,Passif,
+165,Dépôts et cautionnements reçus,,Passif,
+1651,Dépôts,,Passif,
+1655,Cautionnements,,Passif,
+167,Emprunts et dettes sous conditions particulières,,Passif,
+168,Autres emprunts et dettes assimilées,,Passif,
+18,Comptes de liaisons,,Passif,
+181,Apports permanents siège-établissements,,Passif,
+185,Biens & PS échangés siège-établissements,,Passif,
+186,Biens & PS entre établissements - Charges,,Passif,
+187,Biens & PS entre établissements - Produits,,Passif,
+19,Fonds dédiés ou reportés,,Passif,
+191,Fonds reportés liés aux legs ou donations,,Passif,
+1911,Legs ou donations,,Passif,
+1912,Donations temporaires d’usufruit,,Passif,
+194,Fonds dédiés sur subventions fonctionnement,,Passif,
+195,F.d. / contributions financières autres org.,,Passif,
+196,F.d. / ressources liées à la générosité,,Passif,
+2,Classe 2 — Comptes d'immobilisations,,Actif,
+20,Immobilisations incorporelles,,Actif,
+201,Frais d'établissement,,Actif,
+203,Frais de recherche et de développement,,Actif,
+204,Donations temporaires d’usufruit,,Actif,
+205,"Brevets, licences, marques...",,Actif,
+206,Droit au bail,,Actif,
+208,Autres immobilisations incorporelles,,Actif,
+21,Immobilisations corporelles,,Actif,
+211,Terrains,,Actif,
+212,Agencements / aménagements de terrains,,Actif,
+2131,Bâtiments,,Actif,
+2135,"Installations, agencements...de constructions",,Actif,
+214,Constructions sur sol d'autrui,,Actif,
+215,"Installations techniques, matériel, outillage",,Actif,
+2154,Matériel industriel,,Actif,
+2155,Outillage industriel,,Actif,
+218,Autres immobilisations corporelles,,Actif,
+2181,"Installations, agencements, aménagem. divers",,Actif,
+2182,Matériel de transport,,Actif,
+2183,Matériel bureau et informatique,,Actif,
+2184,Mobilier,,Actif,
+23,Immobilisations en cours,,Actif,
+231,Immobilisations corporelles en cours,,Actif,
+238,"Avances, acomptes sur immobilis. corporelles",,Actif,
+24,Biens destinés à être cédés,,Actif,
+240,Biens reçus par legs ou donations à céder,,Actif,
+26,Participations et créances rattachées,,Actif,
+261,Titres de participation,,Actif,
+266,Autres formes de participation,,Actif,
+267,Créances rattachées à des participations,,Actif,
+269,Versements restants sur participations,,Actif,
+27,Immobilisations financières,,Actif,
+271,Titres immobilisés (droit de propriété),,Actif,
+272,Titres immobilisés (droit de créance),,Actif,
+274,Prêts,,Actif,
+2742,Prêts aux partenaires,,Actif,
+275,Dépôts et cautionnements versés,,Actif,
+276,Autres créances immobilisées,,Actif,
+28,Amortissements des immobilisations,,Actif,
+2801,Amt. frais d'établissement,,Actif,
+2804,Amt. donations temporaires d’usufruit,,Actif,
+2805,"Amt brevets, licences, marques...",,Actif,
+2806,Amt. droit au bail,,Actif,
+2808,Amt. autres immo.incorporelles,,Actif,
+2812,"Amt.agencements, aménagements de terrains",,Actif,
+28131,Amortissements bâtiments,,Actif,
+28135,"Amt. installations, agencements...",,Actif,
+2814,Amt.constructions sur sol d'autrui,,Actif,
+2815,"Amt instal. techniques, matériel, outillage",,Actif,
+28181,"Amt. intallations, agencements, aménagements",,Actif,
+28182,Amt. matériel de transport,,Actif,
+28183,"Amortiss.matériel de bureau, informatique",,Actif,
+28184,Amortissements du mobilier,,Actif,
+29,Dépréciations des immobilisations,,Actif,
+2904,Donations temporaires d'usufruit,,Actif,
+2905,"Brevets, licences, marques...",,Actif,
+2906,Droit au bail,,Actif,
+2908,Autres immobilisations incorporelles,,Actif,
+2911,Terrains,,Actif,
+2931,Immobilisations corporelles en cours,,Actif,
+294,Biens reçus par legs ou donations à céder,,Actif,
+2961,Titres de participations,,Actif,
+2966,Autres formes de participations,,Actif,
+2967,Créances rattachées à des participations,,Actif,
+2971,Titres immobilisés (droit de propriété),,Actif,
+2972,Titres immobilisés (droit de créance),,Actif,
+2974,Prêts,,Actif,
+2975,Dépôts et cautionnements versés,,Actif,
+2976,Autres créances immobilisées,,Actif,
+3,Classe 3 — Comptes de stocks,,Actif,
+31,Matières premières et fournitures,,Actif,
+318,Matières premières et fournitures,,Actif,
+32,Autres approvisionnements,,Actif,
+321,Matières consommables,,Actif,
+322,Fournitures consommables,,Actif,
+326,Emballages,,Actif,
+33,En-cours de production de biens,,Actif,
+331,Produits en cours,,Actif,
+335,Travaux en cours,,Actif,
+34,En-cours de production de services,,Actif,
+341,Etudes en cours,,Actif,
+345,Prestations de services en cours,,Actif,
+35,Stocks de produits,,Actif,
+351,Produits intermédiaires,,Actif,
+355,Produits finis,,Actif,
+358,Produits résiduels,,Actif,
+37,Stocks de marchandises,,Actif,
+370,Stocks de marchandises,,Actif,
+39,Provisions pour dépréciations stocks et en-cours,,Actif,
+391,Matières premières et fournitures,,Actif,
+392,Autres approvisionnements,,Actif,
+393,En-cours de production de biens,,Actif,
+394,En-cours de production de services,,Actif,
+395,Stocks de produits,,Actif,
+397,Stocks de marchandises,,Actif,
+4,Classe 4 — Comptes de tiers,,Actif ou passif,
+40,Fournisseurs et comptes rattachés,,Actif ou passif,
+401,Fournisseurs,,Actif ou passif,
+4010,Autres fournisseurs,,Actif ou passif,Favori
+403,Fournisseurs - Effets à payer,,Passif,
+404,Fournisseurs d'immobilisations,,Actif ou passif,
+405,Fournisseurs d'immos - Effets à payer,,Passif,
+408,Fournisseurs - Factures non parvenues,,Passif,
+4091,Fournisseurs - Avances & acomptes,,Actif,
+41,Usagers et comptes rattachés,,Actif ou passif,
+411,Usagers,,Actif ou passif,
+4110,Autres usagers,Pour les dettes ou créances des membres,Actif ou passif,Favori
+413,Usagers - Effets à recevoir,,Actif,
+416,Usagers douteux ou litigieux,,Actif,
+418,Usagers non encore facturés,,Actif,
+4191,Usagers créditeurs : Avances et acomptes,,Passif,
+42,Personnel et comptes rattachés,,Actif ou passif,
+421,Personnel : Rémunérations dues,,Passif,
+4210,Autres membres du personnel,Dettes dûes aux salarié⋅e⋅s,Actif ou passif,Favori
+422,"Comités d'entreprise, d'établissement",,Actif ou passif,
+425,Personnel : Avances & acomptes,,Actif,
+427,Personnel - Oppositions,,Passif,
+4286,Personnel- Charges à payer,,Passif,
+4287,Personnel- Produits à recevoir,,Actif,
+43,Sécurité sociale et autres organismes sociaux,,Passif,
+431,Sécurité sociale,,Passif,
+4372,Mutuelles,,Passif,
+4373,Caisses de retraites et de prévoyance,,Passif,
+4378,Autres organismes sociaux,,Passif,
+438,Organismes sociaux - Charges à payer et produits à recevoir,,Passif,
+4382,Charges sociales sur congés à payer,,Passif,
+4386,Autres charges à payer,,Passif,
+4387,Produits à recevoir,,Passif,
+44,État et autres collectivités publiques,,Actif,
+441,État - Subventions à recevoir,,Actif,
+4421,Prélèvements à la source - Impôt sur le revenu,,Actif ou passif,
+444,État - Impôts sur les bénéfices,,Actif ou passif,
+4452,TVA due intracommunautaire,,Actif ou passif,
+4455,Taxes sur CA à décaisser,,Actif,
+44562,TVA déductible sur immobilisations,,Actif,
+44566,TVA déductible sur autres biens et services,,Actif,
+44571,TVA normale collectée,,Actif,
+445711,TVA réduite collectée,,Actif,
+445712,TVA super-réduite collectée,,Actif,
+445713,TVA intermédiaire collectée,,Actif,
+4458,Taxe sur CA à régulariser ou en attente,,Actif,
+447,"Autres impôts, taxes et versements assimilés",,Actif,
+4486,État - Charges à payer,,Passif,
+4487,État - Produits à recevoir,,Actif,
+45,"Confédération, fédération, unions… affiliées",,Actif ou passif,
+451,"Confédération, fédération et associations affiliées",,Actif ou passif,
+455,Partenaires - comptes courants,,Actif ou passif,
+46,Débiteurs divers et créditeurs divers,,Actif ou passif,
+461,Créances reçues par legs ou donations,,Actif,
+466,Dettes des legs ou donations,,Passif,
+4671,Débiteurs divers,,Actif ou passif,
+4672,Créditeurs divers,,Actif ou passif,
+4681,Frais des bénévoles,,Actif ou passif,
+4686,Divers - Charges à payer,,Passif,
+4687,Divers - Produits à recevoir,,Actif,
+47,Comptes d’attente,,Actif ou passif,
+4715,Compte de transit,,Actif ou passif,
+4718,Compte d'attente,,Actif ou passif,
+48,Comptes de régularisation,,Actif ou passif,
+481,Charges à répartir,,Passif,
+486,Charges constatées d'avance,,Actif,
+487,Produits constatés d'avance,,Passif,
+49,Dépréciations des comptes de tiers,,Actif ou passif,
+491,Provision pour dépreciation des comptes d'usagers,,Passif,
+496,Provision pour dépreciation des comptes débiteurs divers,,Passif,
+5,Classe 5 — Comptes financiers,,Actif,
+50,Valeurs mobilières de placement,,Actif,
+503,Actions,,Actif,
+506,Obligations,,Actif,
+508,Autres VMP et créances assimilées,,Actif,
+51,"Banques, établissements financiers",,Actif,
+5112,Chèques à encaisser,,Actif ou passif,Favori
+5115,Paiements par carte à encaisser,,Actif ou passif,
+512,Banques,,Actif ou passif,
+5186,Intérêts courus à payer,,Passif,
+5187,Intérêts courus à recevoir,,Actif,
+53,Caisses,,Actif ou passif,
+530,Caisse,,Actif ou passif,Favori
+58,Virements internes,,Actif ou passif,
+580,Virements internes,,Actif ou passif,
+59,Dépréciations des comptes financiers,,Actif ou passif,
+5903,Actions,,Actif ou passif,
+5906,Obligations,,Actif ou passif,
+5908,Autres VMP et créances assimilées,,Actif ou passif,
+6,Classe 6 — Comptes de charges,,Charge,
+60,Achats (sauf 603),,Charge,
+601,Achats stockés - Matières premières et fournitures,,Charge,
+6010,Achats stockés de matières et fournitures,,Charge,
+6011,Achats stockés - Matières premières,,Charge,
+6017,Achats stockés - Fournitures,,Charge,
+602,Achats stockés - Autres approvisionnements,,Charge,
+6021,Achats stockés - Matières consommables,,Charge,
+60221,Achats stockés - Combustibles,,Charge,
+60222,Achats stockés - Produits d'entretien,,Charge,
+60223,Achats stockés - Fournitures d'atelier,,Charge,
+60224,Achats stockés - Fournitures de magasin,,Charge,
+60225,Fournitures de bureau,,Charge,
+6026,Achats stockés - Emballages,,Charge,
+603,Variations de stocks,,Charge,
+6031,Variations de stocks matières & fournitures,,Charge,
+6032,Variations stocks autres approvisionnements,,Charge,
+6037,Variations de stocks de marchandises,,Charge,
+604,Achats d'études et prestations de services,,Charge,
+605,"Achats matériel, équipements & travaux",,Charge,
+606,Achats non stockés de matières et fournitures,,Charge,
+6061,"Fournitures non stockables (eau, énergie...)","Facture d'eau, d’électricité, etc.",Charge,Favori
+60611,Eau,,Charge,
+60612,Electricité,,Charge,
+60613,Chauffage,,Charge,
+6063,Fournitures d'entretien et petit équipement,"Vis, et matériel de bricolage (sauf outils) par exemple",Charge,Favori
+6064,Fournitures administratives,"Cartouches d'encre, papier, matériel bureautique, etc.",Charge,Favori
+6065,Petits logiciels,Par exemple contribution à un logiciel de gestion associative génial :-),Charge,Favori
+6068,Autres fournitures & matières,,Charge,Favori
+607,Achats de marchandises,Marchandises destinées à être revendues en l'état.,Charge,Favori
+608,Frais accessoires d'achats,,Charge,
+60811,Frais accessoires d'achats sur matières,,Charge,
+60817,Frais accessoires d'achats sur fournitures,,Charge,
+609,"Rabais, remises et ristournes sur achats",,Charge,
+6091,RRR sur achats matières et fournitures,,Charge,
+6092,RRR sur achats et autres approvisionnements,,Charge,
+6097,RRR sur achats de marchandises,,Charge,
+61,Services extérieurs,,Charge,
+611,Sous-traitance générale,,Charge,
+6122,Redevance crédit-bail mobilier,,Charge,
+6125,Redevances crédit-bail immobilier,,Charge,
+6132,Locations immobilières,Locations versées pour un local ou du matériel.,Charge,Favori
+6135,Locations mobilières,,Charge,
+6136,Malis sur emballages,,Charge,
+614,Charges locatives et de copropriété,,Charge,
+6152,Entretien sur biens immobiliers,,Charge,
+6155,Entretien sur biens mobiliers,,Charge,
+6156,Maintenance,,Charge,
+616,Primes d'assurance,"Frais d’assurance local, activité, etc.",Charge,Favori
+6161,Primes d'assurances mutirisques,,Charge,
+6164,Primes d'assurances / risques d'exploitation,,Charge,
+6165,Primes d'assurances / insolvabilité usagers,,Charge,
+6168,Autres assurances,,Charge,
+617,Études et recherches,,Charge,
+6181,Documentation générale,,Charge,
+6183,Documentation technique,,Charge,
+6185,"Frais de colloques, séminaires, conférences",,Charge,
+6187,Prestations administratives,,Charge,
+62,Autres services extérieurs,,Charge,
+621,Personnel extérieur à l'association,,Charge,
+6211,Personnel intérimaire,,Charge,
+6214,Personnel détaché ou prêté à l'association,,Charge,
+62141,Mises à disposition de personnel salarié,Frais de mise à disposition via un groupement d’employeurs,Charge,Favori
+622,Rémunérations d'intermédiaires et honoraires,,Charge,
+6221,Commissions… sur achats,,Charge,
+6222,Commissions… sur ventes,,Charge,
+6226,Honoraires,,Charge,
+62264,Honoraires sur legs ou donations à céder,,Charge,
+6227,Frais d'actes et de contentieux,,Charge,
+6228,Rémunérations divers intermédiaires & honoraires,,Charge,
+623,"Publicité, publications, relations publiques","Bulletins, affiches, communication, etc.",Charge,Favori
+6231,Annonces et insertions,,Charge,
+6232,Fêtes et cérémonies,,Charge,
+6233,Foires et expositions,,Charge,
+6234,Cadeaux,,Charge,
+6236,Catalogues et imprimés,,Charge,
+6237,Publications,,Charge,
+6238,"Divers : pourboires, dons courants",,Charge,
+624,Transports de biens,,Charge,
+6241,Transports sur achats,,Charge,
+6242,Transports sur ventes,,Charge,
+6243,Transports entre établissements,,Charge,
+6244,Transports administratifs,,Charge,
+6247,Transports collectifs du personnel,,Charge,
+6248,Transports divers,,Charge,
+625,"Déplacements, missions et réceptions","Billet de train, remboursement de frais kilométrique, etc.",Charge,Favori
+6251,Voyages et déplacements,,Charge,
+6255,Frais de déménagement,,Charge,
+6256,Frais de missions,,Charge,
+6257,"Frais de réceptions, représentations",,Charge,
+626,Frais postaux et de télécommunications,"Facture d'accès à Internet, timbres, etc.",Charge,Favori
+6261,Liaisons spécialisées,,Charge,
+6263,"Affranchissements, frais postaux",,Charge,
+6265,Téléphone,,Charge,
+627,Services bancaires et assimilés,Frais bancaires,Charge,Favori
+628,Divers,,Charge,Favori
+6281,Cotisations (liées à l'activité économique),,Charge,
+6284,Frais de recrutement du personnel,,Charge,
+63,"Impôts, taxes et versements assimilés",,Charge,
+631,Sur rémunérations - administration des impôts,,Charge,
+6311,Taxe sur les salaires,,Charge,
+633,Sur rémunérations - autres organismes,,Charge,
+6331,Versement de transport,,Charge,
+6332,Allocation logement,,Charge,
+6333,Formation professionnelle continue,,Charge,
+6334,Participations employeurs à l'effort de construction,,Charge,
+635,Autres - Administration des impôts,,Charge,
+63512,Taxes foncières,,Charge,
+63513,Autres impôts locaux,,Charge,
+6354,Droits d'enregistrement et de timbre,,Charge,
+637,Autres - Autres organismes,,Charge,
+64,Charges de personnel,,Charge,
+641,Rémunérations du personnel,,Charge,
+6411,"Salaires, appointements",,Charge,
+6412,Congés payés,,Charge,
+6413,Primes et gratifications,,Charge,
+6414,Indemnités et avantages divers,,Charge,
+6415,Supplément familial,,Charge,
+645,Charges de sécurité sociale et de prévoyance,,Charge,
+6451,Cotisations à l'URSSAF,,Charge,
+6452,Cotisations aux mutuelles,,Charge,
+6453,Cotisations caisses de retraites et de prévoyance,,Charge,
+6458,Cotisations aux autres organismes sociaux,,Charge,
+647,Autres charges sociales,,Charge,
+6472,Versements aux comités d'entreprise et d'établissement,,Charge,
+6473,Versement aux comités d'hygiène et de sécurité,,Charge,
+6474,Versements aux autres œuvres sociales,,Charge,
+6475,"Médecine du travail, pharmacie",,Charge,
+648,Autres charges de personnel,,Charge,
+6481,Indemnités du personnel de culte,,Charge,
+6485,Charges sociales sur indemnités de culte,,Charge,
+6488,Autres charges de personnel,,Charge,
+65,Autres charges de gestion courante,,Charge,
+6511,"Redevances pour concessions, brevets, licences",,Charge,
+6516,Droits d'auteur et de reproduction,,Charge,
+6518,Autres droits et valeurs similaires,,Charge,
+652,Licences fédérales,Licences payées pour les adhérents (par exemple fédération sportive etc.),Charge,Favori
+653,Charges de la générosité du public,,Charge,
+6531,Autres charges sur legs ou donations,,Charge,
+654,Pertes sur créances irrécouvrables,,Charge,
+655,Quotes-parts sur opérations faites en commun,,Charge,
+657,Aides financières,,Charge,
+6571,Aides financières octroyées,,Charge,
+6572,Quotes-parts de générosité reversée,,Charge,
+658,Charges diverses de gestion courante,,Charge,Favori
+6586,Cotisations (vie statutaire),,Charge,
+6588,Charges diverses de gestion courante,,Charge,
+66,Charges financières,,Charge,
+661,Charges d'intérêts,,Charge,
+665,Escomptes accordés,,Charge,
+666,Pertes de changes,,Charge,
+667,Charges nettes sur cessions de VMP,,Charge,
+668,Autres charges financières,,Charge,
+67,Charges exceptionnelles,,Charge,
+670,Charges exceptionnelles,Autres dépenses exceptionnelles,Charge,Favori
+6712,"Pénalités, amendes fiscales et pénales",,Charge,
+6713,"Dons, libéralités",,Charge,
+6714,Créances devenues irrécouvrables,,Charge,
+6718,Autres charges exceptionnelles de gestion,,Charge,
+672,Charges sur exercices antérieurs,,Charge,
+673,Apports ou affectations en numéraire,,Charge,
+675,Valeurs comptables des éléments d'actifs cédés,,Charge,
+6750,Valeurs comptables des actifs cédés,,Charge,
+6754,Immobilisations reçues par legs ou donations,,Charge,
+678,Autres charges exceptionnelles sur opération en capital,,Charge,
+68,"Dotations aux amortissements, dépréciations et engagements",,Charge,
+6811,Dot. aux amortissements des immobilisations,,Charge,
+6812,Dot. aux amortissements charges à répartir,,Charge,
+6815,Dot. aux provisions d'exploitation,,Charge,
+6816,Dot. provisions pour dépréciations des immobilisations,,Charge,
+68164,Dot. pour dépréciation d’actifs reçus par legs ou donations,,Charge,
+6817,Dot. aux dépréciations des actifs circulants,,Charge,
+68173,Dotations dépréciations stocks et en-cours,,Charge,
+68174,Dotations dépréciations créances,,Charge,
+686,Dot. aux amortissements & provisions - Charges financières,,Charge,
+68662,Dot. aux amortissements & provisions des immobilisations financières,,Charge,
+68665,Dot. aux amortissements & provisions des valeurs mobilières de placement,,Charge,
+687,Dot. aux amortissements & provisions - Charges exceptionnelles,,Charge,
+689,Reports en fonds dédiés,,Charge,
+6891,Reports en fonds reportés,,Charge,
+6894,Reports en fonds dédiés / subventions d’exploitation,,Charge,
+6895,Reports en fds dédiés / contributions financières d'autres organismes,,Charge,
+6896,Reports en fds dédiés / ressources générosité,,Charge,
+69,Impôts sur les bénéfices,,Charge,
+695,IS sur personnes non lucratives,,Charge,
+7,Classe 7 — Comptes de produits,,Produit,
+70,"Ventes de produits finis, marchandises, prestations",,Produit,
+701,Ventes de produits finis,Vente de produits fabriqués par l'association.,Produit,Favori
+702,Ventes de produits intermédiaires,,Produit,
+703,Ventes de produits résiduels,,Produit,
+704,Travaux,,Produit,
+705,Études,,Produit,
+706,Prestations de services,,Produit,Favori
+7063,Parrainages,,Produit,
+707,Ventes de marchandises,Ventes de produits achetés et revendus en l’état,Produit,Favori
+7073,Ventes de dons en nature,,Produit,
+708,Produits des activités annexes,,Produit,
+7081,Produits des prestations fournies au personne,,Produit,
+7083,Locations diverses,,Produit,
+7085,Ports et frais accessoires facturés,,Produit,
+7088,Autres produits d'activités annexes,,Produit,
+709,RRR accordés,,Produit,
+7091,RRR sur ventes de produits finis,,Produit,
+7092,RRR sur ventes de produits intermédiaires,,Produit,
+7094,RRR sur travaux,,Produit,
+7095,RRR sur études,,Produit,
+7096,RRR sur prest.de services,,Produit,
+7097,RRR sur ventes marchandises,,Produit,
+71,Production stockée,,Produit,
+713,"Variation de stocks (en-cours, productions)",,Produit,
+7133,Variation des en-cours de production de biens,,Produit,
+7134,Variation des en-cours de production services,,Produit,
+7135,Variations de stocks de produits,,Produit,
+72,Production immobilisée corporelle,,Produit,
+721,Production immobilisée incorporelle,,Produit,
+722,Production immobilisée corporelle,,Produit,
+73,Concours publics,,Produit,
+730,Concours publics,,Produit,
+74,Subventions d’exploitation,,Produit,
+740,Subventions reçues,,Produit,Favori
+748,Subventions d'exploitation diverses,,Produit,
+75,Autres produits de gestion courante,,Produit,
+751,"Redevances pour concessions, licences…",,Produit,
+753,Versements des fondateurs ou consommation dot,,Produit,
+7531,Versements des fondateurs,,Produit,
+7532,Quotes-parts de dotation consomptible virée a,,Produit,
+754,Ressources liées à la générosité du public,Dons reçus,Produit,Favori
+7541,Dons manuels,,Produit,
+75411,Dons manuels,,Produit,
+75412,Abandons de frais par les bénévoles,,Produit,
+7542,Mécénats,,Produit,
+7543,"Legs, donations et assurances-vie",,Produit,
+75431,Assurances-vie,,Produit,
+75432,Legs ou donations,,Produit,
+75433,Autres produits sur legs ou donations,,Produit,
+755,Contributions financières,,Produit,
+7551,Contributions financières d’autres organismes,,Produit,
+7552,Quotes-parts de générosité reçues,,Produit,
+756,Cotisations,Cotisations des adhérent⋅e⋅s,Produit,Favori
+7561,Cotisations sans contrepartie,,Produit,
+7562,Cotisations avec contrepartie,,Produit,
+757,Gains de change / créances et dettes d’exploitation,,Produit,
+758,Produits divers de gestion courante,,Produit,
+7588,Autres produits divers de gestion courante,,Produit,
+76,Produits financiers,,Produit,
+761,Produits des participations,,Produit,
+762,Produits des autres immobilisations financières,,Produit,
+763,Revenus des autres créances,,Produit,
+764,Revenus des valeurs mobilières de placement,,Produit,
+765,Escomptes obtenus,,Produit,
+766,Gains de change,,Produit,
+767,Produits nets sur cession valeurs mobilières de placement,,Produit,
+768,Autres produits financiers,,Produit,
+77,Produits exceptionnels,,Produit,
+771,Produits exceptionnels sur opération de gestion,,Produit,
+7713,Libéralités perçues,,Produit,
+7718,Autres produits exceptionnel sur opération de gestion,,Produit,
+772,Produits sur exercices antérieurs,,Produit,
+775,Produits des cessions d'actif,,Produit,
+7754,Immobilisations reçues en legs ou donations à céder,,Produit,
+777,Quote-part de subvention d'investissement virée au résultat,,Produit,
+778,Autres produits exceptionnels,,Produit,
+7780,Manifestations diverses,"Revenus provenant de manifestations au profit de l'association : droit d'entrée, location d'emplacement en vide grenier, ventes, etc.",Produit,Favori
+78,"Reprises sur amortissement, dépréciations, engagements",,Produit,
+781,Reprises / amortissements & provisions d'exploitation,,Produit,
+7811,Amortissement immobilisations corporelles & incorporelles,,Produit,
+7815,Reprises sur provisions d'exploitation,,Produit,
+7816,Dépréciation immobilisations corporelles & incorporelles,,Produit,
+78164,Reprises de dépréciations d’actifs reçus par legs ou donations destinés à être cédés,,Produit,
+7817,Dépréciations actifs circulant,,Produit,
+786,Reprises / amortissements & provisions financiers,,Produit,
+7865,Risques et charges financiers,,Produit,
+7866,Dépréciations des éléments financiers,,Produit,
+787,Reprises sur amt & prov. exceptionnelles,,Produit,
+7872,Provisions réglementées - Immobilisations,,Produit,
+7873,Provisions réglementées - stocks,,Produit,
+7874,Autres provisions réglementées,,Produit,
+7875,Risques et charges,,Produit,
+7876,Dépréciations exceptionnelles,,Produit,
+789,Utilisations fonds reportés et de fonds dédiés,,Produit,
+7891,Utilisations de fonds reportés,,Produit,
+7894,Utilisations des fonds dédiés / subventions,,Produit,
+7895,Utilisations des fonds dédiés / contributions,,Produit,
+7896,Utilisations des fonds dédiés / générosité,,Produit,
+79,Transfert de charges,,Produit,
+791,Transferts de charges d'exploitation,,Produit,
+796,Transferts de charges financières,,Produit,
+797,Transferts de charges exceptionnelles,,Produit,
+8,Classe 8 — Comptes spéciaux,,,
+86,Emplois des contributions volontaires en nature,,,
+860,Secours en nature,,Charge,Favori
+8601,Alimentaires,,Charge,Favori
+8602,Vestimentaires,,Charge,Favori
+861,Mise à dispositions gratuites de biens,,Charge,Favori
+8611,Locaux,,Charge,Favori
+8612,Matériels,,Charge,Favori
+862,Prestations,,Charge,Favori
+864,Personnel bénévole,,Charge,Favori
+87,Contributions volontaires en nature,,,
+870,Dons en nature,,Produit,Favori
+871,Prestations en nature,,Produit,Favori
+875,Bénévolat,,Produit,Favori
+89,Comptes de bilan,,,
+890,Bilan d'ouverture,,Actif ou passif,
+891,Bilan de clôture,,Actif ou passif,
diff --git a/src/include/data/charts/fr_pcc_2020.csv b/src/include/data/charts/fr_pcc_2020.csv
new file mode 100644
index 0000000..79de4da
--- /dev/null
+++ b/src/include/data/charts/fr_pcc_2020.csv
@@ -0,0 +1,111 @@
+code,label,description,position,bookmark
+1,"Classe 1 — Provisions, avances, subventions et emprunts",,Passif,
+10,PROVISIONS ET AVANCES,,Passif,
+103,Avances,,Passif,
+106,Provisions pour travaux,Provisions pour travaux au titre de la délégation de pouvoirs accordée au conseil syndical,Passif,
+11,REPORT À NOUVEAU (SOLDE CRÉDITEUR OU DÉBITEUR),,Passif,
+110,Report à nouveau (solde créditeur),,Passif,
+119,Report à nouveau (solde débiteur),,Passif,
+12,Solde en attente sur travaux et opérations exceptionnelles,,Passif,
+121,Travaux décidés par l'assemblée générale,,Passif,
+122,Travaux délégués au conseil syndical ,,Passif,
+13,SUBVENTIONS,,Passif,
+131, Subventions accordées en instance de versement,,Passif,
+138,Autres subventions d’investissement ,,Passif,
+139,Subventions d’investissement inscrites au compte de résultat,,Passif,
+4,Classe 4 — COPROPRIÉTAIRES ET TIERS,,Actif ou passif,
+40,Fournisseurs,,Actif ou passif,
+42,Personnel,,Actif ou passif,
+43,SÉCURITÉ SOCIALE & AUTRES ORGANISMES SOCIAUX,,Passif,
+431,Sécurité sociale,,Passif,
+432,Autres organismes sociaux,,Passif,
+44,ÉTAT ET COLLECTIVITÉS TERRITORIALES ,,Actif,
+441,État et autres organismes - subventions à recevoir,,Actif,Favori
+442,État - impôts et versements assimilés,,Actif ou passif,Favori
+443,Collectivités territoriales – aides,,Actif ou passif,
+45,COLLECTIVITÉ DES COPROPRIÉTAIRES,,Actif ou passif,
+46,DÉBITEURS ET CRÉDITEUR DIVERS,,Actif ou passif,
+47,COMPTES D'ATTENTE,,Actif ou passif,
+471,Compte en attente d’imputation débiteur,,Actif ou passif,
+472,Compte en attente d’imputation créditeur,,Actif ou passif,
+48,COMPTES DE RÉGULARISATION,,Actif ou passif,
+486,Charges payées d'avance,,Actif,Favori
+487,Produits encaissés d’avance,,Passif,Favori
+49,DÉPRÉCIATIONS DES COMPTES DE TIERS,,Actif ou passif,
+491,Copropriétaires,,Passif,Favori
+496,Personnes autres que les copropriétaires,,Passif,Favori
+5,Classe 5 — Comptes financiers,,Actif,
+50,FONDS PLACÉS,,Actif,
+51,"Banques, ou fonds disponibles en banque pour le syndicat",,Actif,
+5112,Chèques à encaisser,,Actif ou passif,Favori
+5115,Paiements par carte à encaisser,,Actif ou passif,
+512,Banques,,Actif ou passif,
+53,Caisses,,Actif ou passif,
+530,Caisse,,Actif ou passif,Favori
+6,Classe 6 — Comptes de charges,,Charge,
+60,ACHATS DE MATIÈRES ET FOURNITURES,,Charge,
+601,Eau,,Charge,Favori
+602,Électricité,,Charge,Favori
+603,"Chauffage, énergie et combustibles",,Charge,Favori
+604,Achats produits d'entretien et petits équipements,,Charge,Favori
+605,Matériel,,Charge,Favori
+606,Fournitures,,Charge,Favori
+61,SERVICES EXTÉRIEURS,,Charge,
+611,Nettoyage des locaux,,Charge,Favori
+612,Locations immobilières,,Charge,Favori
+613,Locations mobilières,,Charge,
+614,Contrats de maintenance,,Charge,Favori
+615,Entretien et petites réparations,,Charge,Favori
+616,Primes d'assurance,,Charge,Favori
+62,FRAIS D’ADMINISTRATION ET HONORAIRES,,Charge,
+621,Rémunération du syndic sur gestion copropriété,,Charge,
+6211,Rémunération du syndic,,Charge,
+6212,Débours,,Charge,
+6213,Frais postaux,,Charge,Favori
+622,Autres honoraires du syndic,,Charge,
+6221,Honoraires travaux,,Charge,Favori
+6222,Prestations particulières,,Charge,
+6223,Autres honoraires,,Charge,
+623,Rémunérations de tiers intervenants,,Charge,
+624,Frais du conseil syndical,,Charge,
+63,"IMPÔTS, TAXES ET VERSEMENTS ASSIMILÉS",,Charge,
+632,Taxe de balayage,,Charge,Favori
+633,Taxe foncière,,Charge,Favori
+634,Autre impôt et taxe,,Charge,Favori
+64,FRAIS DE PERSONNEL,,Charge,
+641,Salaires,,Charge,Favori
+642,Charges sociales et organismes sociaux,,Charge,Favori
+644,"Autres (médecine du travail, mutuelle, etc.)",,Charge,Favori
+65,MONTANT SPÉCIFIQUE ALLOUÉ AU CONSEIL SYNDICAL,"Montant spécifique alloué au conseil syndical, au sein du budget prévisionnel, pour l'exercice de sa délégation de pouvoirs",Charge,
+66,"CHARGES FINANCIÈRES DES EMPRUNTS, AGIOS OU AUTRES",,Charge,
+661,Remboursements d'annuités d'emprunt,,Charge,Favori
+662,Autres charges financières et agios,,Charge,
+67,CHARGES POUR TRAVAUX ET OPÉRATIONS EXCEPTIONNELLES,,Charge,
+671,Travaux décidés par l’assemblée générale,,Charge,Favori
+672,Travaux urgents,,Charge,Favori
+673,"Études techniques, diagnostic, consultation",,Charge,
+677,Pertes sur créances irrécouvrables,,Charge,
+678,Charges exceptionnelles,,Charge,
+68,DOTATIONS AUX DÉPRÉCIATIONS SUR CRÉANCES DOUTEUSES,,Charge,
+7,Classe 7 — Comptes de produits,,Produit,
+70,APPELS DE FONDS,,Produit,
+701,Provisions sur opérations courantes,,Produit,Favori
+702,Provisions sur travaux de l’article 14-2 et opérations exceptionnelles,,Produit,
+703,Avances,,Produit,Favori
+704,Remboursements d’annuités d’emprunts,,Produit,
+705,Études,,Produit,
+706,Provisions au titre de la délégation de pouvoirs accordée au conseil syndical,,Produit,Favori
+7061,Provisions sur opérations courantes,,Produit,Favori
+7062,Provisions sur travaux et opérations exceptionnelles,,Produit,
+71,AUTRES PRODUITS,,Produit,
+711,Subventions,,Produit,Favori
+712,Emprunts,,Produit,Favori
+713,Indemnités d’assurance,,Produit,
+714,Produits divers (dont intérêts légaux dus par les copropriétaires),,Produit,
+716,Produits financiers,,Produit,Favori
+718,Produits exceptionnels,,Produit,
+78,REPRISES DE DÉPRÉCIATIONS SUR CRÉANCES DOUTEUSES,Reprises de dépréciations sur créances douteuses,Produit,
+8,Classe 8 — Comptes spéciaux,,,
+89,COMPTES DE BILAN,,,
+890,Bilan d'ouverture,,Actif ou passif,
+891,Bilan de clôture,,Actif ou passif,
diff --git a/src/include/data/charts/fr_pcg_2014.csv b/src/include/data/charts/fr_pcg_2014.csv
new file mode 100644
index 0000000..b0d5f78
--- /dev/null
+++ b/src/include/data/charts/fr_pcg_2014.csv
@@ -0,0 +1,367 @@
+code,label,description,position,bookmark
+1,COMPTES DE CAPITAUX,,Passif,
+10,CAPITAL ET RÉSERVES,,Passif,
+101,Capital,,Passif,
+1011,Capital souscrit -non appelé,,Passif,
+1012,"Capital souscrit -appelé, non versé",,Passif,
+1013,"Capital souscrit - appelé, versé",,Passif,
+102,Fonds fiduciaires,,Passif,
+104,Primes liées au capital social,,Passif,
+105,Écarts de réévaluation,,Passif,
+106,Réserves,,Passif,
+1068,Autres réserves,,Passif,
+107,Écart d’équivalence,,Passif,
+108,Compte de l’exploitant,,Passif,
+109,Actionnaires : capital souscrit – non appelé,,Passif,
+11,REPORT À NOUVEAU (SOLDE CRÉDITEUR OU DÉBITEUR),,Passif,
+110,Report à nouveau (solde créditeur),,Passif,
+119,Report à nouveau (solde débiteur),,Passif,
+12,RÉSULTAT DE L’EXERCICE (BÉNÉFICE OU PERTE),,Passif,
+120,Résultat de l’exercice (bénéfice),,Passif,
+129,Résultat de l’exercice (perte),,Passif,
+13,SUBVENTIONS D’INVESTISSEMENT,,Passif,
+131,Subventions d’équipement,,Passif,
+1311,État,,Passif,
+1312,Régions,,Passif,
+1313,Départements,,Passif,
+1314,Communes,,Passif,
+1315,Collectivités publiques,,Passif,
+1316,Entreprises publiques,,Passif,
+1317,Entreprises et organismes privés,,Passif,
+1318,Autres,,Passif,
+138,Autres subventions d’investissement,"Même ventilation que celle du compte 131, à rajouter si nécessaire",Passif,
+139,Subventions d’investissement inscrites au compte de résultat,,Passif,
+1391,"Subventions d'équipement","Même ventilation que celle du compte 131, à rajouter si nécessaire",Passif,
+1398,Autres subventions d’investissement ,"Même ventilation que celle du compte 131, à rajouter si nécessaire",Passif,
+14,PROVISIONS RÉGLEMENTÉES,,Passif,
+142,Provisions réglementées relatives aux immobilisations,,Passif,
+143,Provisions réglementées relatives aux stocks,,Passif,
+144,Provisions réglementées relatives aux autres éléments de l’actif,,Passif,
+145,Amortissements dérogatoires,,Passif,
+146,Provision spéciale de réévaluation,,Passif,
+147,Plus-values réinvesties,,Passif,
+148,Autres provisions réglementées,,Passif,
+15,PROVISIONS POUR RISQUES ET CHARGES,,Passif,
+151,Provisions pour risques,,Passif,
+153,Provisions pour pensions et obligations similaires,,Passif,
+154,Provisions pour restructurations,,Passif,
+155,Provisions pour impôts,,Passif,
+156,Provisions pour renouvellement des immobilisations (entreprises concessionnaires),,Passif,
+157,Provisions pour charges à répartir sur plusieurs exercices,,Passif,
+158,Autres provisions pour charges,,Passif,
+16,EMPRUNTS ET DETTES ASSIMILÉES,,Passif,
+161,Emprunts obligataires convertibles,,Passif,
+163,Autres emprunts obligataires,,Passif,
+164,Emprunts auprès des établissements de crédit,,Passif,
+165,Dépôts et cautionnements reçus,,Passif,
+166,Participation des salariés aux résultats,,Passif,
+167,Emprunts et dettes assortis de conditions particulières,,Passif,
+168,Autres emprunts et dettes assimilées,,Passif,
+169,Primes de remboursement des obligations,,Passif,
+17,DETTES RATTACHÉES À DES PARTICIPATIONS,,Passif,
+171,Dettes rattachées à des participations (groupe),,Passif,
+174,Dettes rattachées à des participations (hors groupe),,Passif,
+178,Dettes rattachées à des sociétés en participation,,Passif,
+18,COMPTES DE LIAISON DES ÉTABLISSEMENTS ET SOCIÉTÉS EN PARTICIPATION,,Passif,
+181,Comptes de liaison des établissements,,Passif,
+186,Biens et prestations de services échangés entre établissements (charges),,Passif,
+187,Biens et prestations de services échangés entre établissements (produits),,Passif,
+188,Comptes de liaison des sociétés en participation,,Passif,
+2,COMPTES D’IMMOBILISATIONS,,Actif,
+20,IMMOBILISATIONS INCORPORELLES,,Actif,
+201,Frais d’établissement,,Actif,
+203,Frais de recherche et de développement,,Actif,
+205,"Concessions et droits similaires, brevets, licences, marques, procédés, logiciels, droits et valeurs similaires",,Actif,
+206,Droit au bail,,Actif,
+207,Fonds commercial,,Actif,
+208,Autres immobilisations incorporelles,,Actif,
+21,IMMOBILISATIONS CORPORELLES,,Actif,
+211,Terrains,"Au besoin, créer des sous-comptes 2111 et suivants : Terrains nus, Terrains aménagés, Sous - sols et sursols, Terrains de gisement et Terrains bâtis.",Actif,
+212,Agencements et aménagements de terrains (même ventilation que celle du compte 211,(même ventilation que celle du compte 211) ,Actif,
+213,Constructions,,Actif,
+214,Constructions sur sol d’autrui (même ventilation que celle du compte 213,,Actif,
+215,"Installations techniques, matériels et outillage industriels",,Actif,
+218,Autres immobilisations corporelles,,Actif,
+22,IMMOBILISATIONS MISES EN CONCESSION,,Actif,
+23,IMMOBILISATIONS EN COURS,,Actif,
+231,Immobilisations corporelles en cours,,Actif,
+232,Immobilisations incorporelles en cours,,Actif,
+237,Avances et acomptes versés sur immobilisations incorporelles,,Actif,
+238,Avances et acomptes versés sur commandes d’immobilisations corporelles,,Actif,
+25,PARTS DANS DES ENTREPRISES LIÉES ET CRÉANCES SUR DES ENTREPRISES LIÉES,,Actif,
+26,PARTICIPATIONS ET CRÉANCES RATTACHÉES À DES PARTICIPATIONS,,Actif,
+261,Titres de participation,,Actif,
+266,Autres formes de participation,,Actif,
+267,Créances rattachées à des participations,,Actif,
+268,Créances rattachées à des sociétés en participation,,Actif,
+269,Versements restant à effectuer sur titres de participation non libérés,,Actif,
+27,AUTRES IMMOBILISATIONS FINANCIÈRES,,Actif,
+271,Titres immobilisés autres que les titres immobilisés de l’activité de portefeuille (droit de propriété),,Actif,
+272,Titres immobilisés (droit de créance),,Actif,
+273,Titres immobilisés de l’activité de portefeuille,,Actif,
+274,Prêts,,Actif,
+275,Dépôts et cautionnements versés,,Actif,
+276,Autres créances immobilisées,,Actif,
+277,(Actions propres ou parts propres),,Actif,
+278,Mali de fusion sur actifs financiers,,Actif,
+279,Versements restant à effectuer sur titres immobilisés non libérés,,Actif,
+28,AMORTISSEMENTS DES IMMOBILISATIONS,,Actif,
+280,Amortissements des immobilisations incorporelles,,Actif,
+281,Amortissements des immobilisations corporelles,,Actif,
+282,Amortissements des immobilisations mises en concession,,Actif,
+29,DÉPRÉCIATIONS DES IMMOBILISATIONS,,Actif,
+290,Dépréciations des immobilisations incorporelles,,Actif,
+291,Dépréciations des immobilisations corporelles ,Même ventilation que celle du compte 21,Actif,
+292,Dépréciations des immobilisations mises en concession,,Actif,
+293,Dépréciations des immobilisations en cours,,Actif,
+296,Provisions pour dépréciation des participations et créances rattachées à des participations,,Actif,
+297,Provisions pour dépréciation des autres immobilisations financières,,Actif,
+3,COMPTES DE STOCKS ET EN-COURS,,Actif,
+31,MATIÈRES PREMIÈRES (ET FOURNITURES),,Actif,
+311,Matières (ou groupe) A,,Actif,
+312,Matières (ou groupe) B,,Actif,
+317,"Fournitures A, B, C",,Actif,
+32,AUTRES APPROVISIONNEMENTS,,Actif,
+321,Matières consommables,,Actif,
+322,Fournitures consommables,,Actif,
+326,Emballages,,Actif,
+33,EN-COURS DE PRODUCTION DE BIENS,,Actif,
+331,Produits en cours,,Actif,
+335,Travaux en cours,,Actif,
+34,EN-COURS DE PRODUCTION DE SERVICES,,Actif,
+341,Études en cours,,Actif,
+345,Prestations de services en cours,,Actif,
+35,STOCKS DE PRODUITS,,Actif,
+351,Produits intermédiaires,,Actif,
+355,Produits finis,,Actif,
+358,Produits résiduels (ou matières de récupération),,Actif,
+37,STOCKS DE MARCHANDISES,,Actif,
+371,Marchandises (ou groupe) A,,Actif,
+372,Marchandises (ou groupe) B,,Actif,
+39,PROVISIONS POUR DÉPRÉCIATION DES STOCKS ET EN-COURS,,Actif,
+391,Provisions pour dépréciation des matières premières (et fournitures),,Actif,
+392,Provisions pour dépréciation des autres approvisionnements,,Actif,
+393,Provisions pour dépréciation des en-cours de production de biens,,Actif,
+394,Provisions pour dépréciation des en-cours de production de services,,Actif,
+395,Provisions pour dépréciation des stocks de produits,,Actif,
+397,Provisions pour dépréciation des stocks de marchandises,,Actif,
+4,COMPTES DE TIERS,,Actif ou passif,
+40,FOURNISSEURS ET COMPTES RATTACHÉS,,Actif ou passif,
+400,Fournisseurs et Comptes rattachés,,Actif ou passif,
+401,Fournisseurs,,Actif ou passif,
+403,Fournisseurs – Effets à payer,,Passif,
+404,Fournisseurs d’immobilisations,,Actif ou passif,
+405,Fournisseurs d’immobilisations – Effets à payer,,Passif,
+408,Fournisseurs – Factures non parvenues,,Passif,
+409,Fournisseurs débiteurs,,Actif,
+41,CLIENTS ET COMPTES RATTACHÉS,,Actif ou passif,
+410,Clients et comptes rattachés,,Actif ou passif,
+411,Clients,,Actif ou passif,
+413,Clients – Effets à recevoir,,Actif,
+416,Clients douteux ou litigieux,,Actif,
+418,Clients – Produits non encore facturés,,Actif,
+419,Clients créditeurs,,Passif,
+42,PERSONNEL ET COMPTES RATTACHÉS,,Actif ou passif,
+421,Personnel – Rémunérations dues,,Passif,
+422,"Comités d’entreprises, d’établissement,…",,Actif ou passif,
+424,Participation des salariés aux résultats,,Actif,
+425,Personnel – Avances et acomptes,,Actif,
+426,Personnel – Dépôts,,Passif,
+427,Personnel – Oppositions,,Passif,
+428,Personnel – Charges à payer et produits à recevoir,,Passif,
+43,SÉCURITÉ SOCIALE ET AUTRES ORGANISMES SOCIAUX,,Passif,
+431,Sécurité sociale,,Passif,
+437,Autres organismes sociaux,,Passif,
+438,Organismes sociaux – Charges à payer et produits à recevoir,,Passif,
+44,ÉTAT ET AUTRES COLLECTIVITÉS PUBLIQUES,,Actif,
+441,État – Subventions à recevoir,,Actif,
+442," Contributions, impôts et taxes recouvrés pour le compte de l’État ",,Passif,
+443,"Opérations particulières avec l’État, les collectivités publiques, les organismes internationaux",,Actif ou passif,
+444,État – Impôts sur les bénéfices,,Actif ou passif,
+445,État – Taxes sur le chiffre d’affaires,,Actif,
+446,Obligations cautionnées,,Actif,
+447,"Autres impôts, taxes et versements assimilés",,Actif,
+448,État – Charges à payer et produits à recevoir,,Actif ou passif,
+449,Quotas d’émission à acquérir,,Passif,
+45,GROUPE ET ASSOCIÉS,,Actif ou passif,
+451,Groupe,,Actif ou passif,
+455,Associés – Comptes courants,,Actif ou passif,
+456,Associés – Opérations sur le capital,,Actif,
+457,Associés – Dividendes à payer,,Passif,
+458,Associés – Opérations faites en commun et en G.I.E.,,Actif ou passif,
+46,DÉBITEURS DIVERS ET CRÉDITEURS DIVERS,,Actif ou passif,
+462,Créances sur cessions d’immobilisations,,Actif,
+464,Dettes sur acquisitions de valeurs mobilières de placement,,Passif,
+465,Créances sur cessions de valeurs mobilières de placement,,Actif,
+467,Autres comptes débiteurs ou créditeurs,,Actif ou passif,
+468,Divers – Charges à payer et produits à recevoir,,Actif ou passif,
+4686,Charges à payer,,Passif,
+4687,Produits à recevoir,,Actif,
+47,COMPTES TRANSITOIRES OU D’ATTENTE,,Actif ou passif,
+471,à 475 comptes d’attente,,Actif ou passif,
+476,Différence de conversion – Actif,,Actif,
+477,Différences de conversion – Passif,,Passif,
+478,Autres comptes transitoires,,Actif ou passif,
+48,COMPTES DE RÉGULARISATION,,Actif ou passif,
+481,Charges à répartir sur plusieurs exercices,,Passif,
+486,Charges constatées d’avance,,Actif,
+487,Produits constatés d’avance,,Passif,
+488,Comptes de répartition périodique des charges et des produits,,Actif ou passif,
+489,Quotas d’émission alloués par l’État,,Actif,
+49,PROVISIONS POUR DÉPRÉCIATION DES COMPTES DE TIERS,,Actif ou passif,
+491,Provisions pour dépréciation des comptes de clients,,Passif,
+495,Provisions pour dépréciation des comptes du groupe et des associés,,Passif,
+496,Provisions pour dépréciation des comptes de débiteurs divers,,Passif,
+5,COMPTES FINANCIERS,,Actif,
+50,VALEURS MOBILIÈRES DE PLACEMENT,,Actif,
+501,Parts dans des entreprises liées,,Actif,
+502,Actions propres,,Actif,
+503,Actions,,Actif,
+504,Autres titres conférant un droit de propriété,,Actif,
+505,Obligations et bons émis par la société et rachetés par elle,,Actif,
+506,Obligations,,Actif,
+507,Bons du Trésor et bons de caisse à court terme,,Actif,
+508,Autres valeurs mobilières de placement et autres créances assimilées,,Actif,
+509,Versements restant à effectuer sur valeurs mobilières de placement non libérées,,Actif,
+51,"BANQUES, ÉTABLISSEMENTS FINANCIERS ET ASSIMILÉS",,Actif,
+511,Valeurs à l’encaissement,,Actif ou passif,Favori
+512,Banques,,Actif ou passif,
+512A,Compte courant,,Actif ou passif,Favori
+514,Chèques postaux,,Actif ou passif,Favori
+515,« Caisses » du Trésor et des établissements publics,,Actif ou passif,
+516,Sociétés de bourse,,Actif,
+517,Autres organismes financiers,,Actif ou passif,
+518,Intérêts courus,,Actif ou passif,
+519,Concours bancaires courants,,Actif ou passif,
+52,INSTRUMENTS DE TRÉSORERIE,,Actif ou passif,
+53,CAISSE,,Actif ou passif,
+531,Caisse siège social,,Actif ou passif,Favori
+532,Caisse succursale (ou usine) A,,Actif ou passif,
+533,Caisse succursale (ou usine) B,,Actif ou passif,
+54,RÉGIES D’AVANCE ET ACCRÉDITIFS,,Actif ou passif,
+58,VIREMENTS INTERNES,,Actif ou passif,
+59,DÉPRÉCIATION DES COMPTES FINANCIERS,,Actif ou passif,
+590,Provisions pour dépréciation des valeurs mobilières de placement,,Passif,
+6,COMPTES DE CHARGES,,Charge,
+60,ACHATS (SAUF 603),,Charge,
+601,Achats stockés – Matières premières (et fournitures),,Charge,Favori
+602,Achats stockés – Autres approvisionnements,,Charge,Favori
+604,Achats d’études et prestations de services,,Charge,Favori
+605,"Achats de matériel, équipements et travaux",,Charge,Favori
+606,Achats non stockés de matière et fournitures,"On peut rajouter des sous-comptes : eau, électricité, etc.",Charge,Favori
+607,Achats de marchandises,,Charge,Favori
+608,"(Compte réservé, le cas échéant, à la récapitulation des frais accessoires incorporés aux achats)",,Charge,
+609,"Rabais, remises et ristournes obtenus sur achats",,Charge,
+603,Variations des stocks (approvisionnements et marchandises),,Charge,
+61,SERVICES EXTÉRIEURS,,Charge,
+611,Sous-traitance générale,,Charge,
+612,Redevances de crédit-bail,,Charge,
+613,Locations,,Charge,Favori
+614,Charges locatives et de copropriété,,Charge,Favori
+615,Entretien et réparations,,Charge,
+616,Primes d’assurances,,Charge,Favori
+617,Études et recherches,,Charge,
+618,Divers,,Charge,
+619,"Rabais, remises et ristournes obtenus sur services extérieurs",,Charge,
+62,AUTRES SERVICES EXTÉRIEURS,,Charge,
+621,Personnel extérieur à l’entreprise,,Charge,
+622,Rémunérations d’intermédiaires et honoraires,,Charge,
+623,"Publicité, publications, relations publiques",,Charge,
+624,Transports de biens et transports collectifs du personnel,Ne comprend pas les transports de l’exploitant,Charge,
+625,"Déplacements, missions et réceptions",,Charge,Favori
+626,Frais postaux et de télécommunications,,Charge,Favori
+627,Services bancaires et assimilés,,Charge,Favori
+628,Divers,,Charge,
+629,"Rabais, remises et ristournes obtenus sur autres services extérieurs",,Charge,
+63,"IMPÔTS, TAXES ET VERSEMENTS ASSIMILÉS",,Charge,
+631,"Impôts, taxes et versements assimilés sur rémunérations (administrations des impôts)",,Charge,
+633,"Impôts, taxes et versements assimilés sur rémunérations (autres organismes)",,Charge,
+635,"Autres impôts, taxes et versements assimilés (administrations des impôts)",,Charge,
+637,"Autres impôts, taxes et versements assimilés (autres organismes)",,Charge,
+64,CHARGES DE PERSONNEL,,Charge,
+641,Rémunérations du personnel,,Charge,
+644,Rémunération du travail de l’exploitant,,Charge,Favori
+645,Charges de sécurité sociale et de prévoyance,,Charge,Favori
+6451,Cotisations à l’Urssaf,,Charge,Favori
+646,Cotisations sociales personnelles de l’exploitant,,Charge,Favori
+647,Autres charges sociales,,Charge,
+648,Autres charges de personnel,,Charge,
+65,AUTRES CHARGES DE GESTION COURANTE,,Charge,
+651,"Redevances pour concessions, brevets, licences, marques, procédés, logiciels, droits et valeurs similaires",,Charge,
+653,Jetons de présence,,Charge,
+654,Pertes sur créances irrécouvrables,,Charge,
+655,Quote-part de résultat sur opérations faites en commun,,Charge,
+658,Charges diverses de gestion courante,,Charge,
+66,CHARGES FINANCIÈRES,,Charge,
+661,Charges d’intérêts,,Charge,
+664,Pertes sur créances liées à des participations,,Charge,
+665,Escomptes accordés,,Charge,
+666,Pertes de change,,Charge,
+667,Charges nettes sur cessions de valeurs mobilières de placement,,Charge,
+668,Autres charges financières,,Charge,
+67,CHARGES EXCEPTIONNELLES,,Charge,
+671,Charges exceptionnelles sur opérations de gestion,,Charge,
+672,"(Compte à la disposition des entités pour enregistrer, en cours d’exercice, les charges sur exercices antérieurs)",,Charge,
+675,Valeurs comptables des éléments d’actif cédés,,Charge,
+678,Autres charges exceptionnelles,,Charge,
+68,DOTATIONS AUX AMORTISSEMENTS ET AUX PROVISIONS,,Charge,
+681,Dotations aux amortissements et aux provisions – Charges d’exploitation,,Charge,
+686,Dotations aux amortissements et aux provisions – Charges financières,,Charge,
+687,Dotations aux amortissements et aux provisions – Charges exceptionnelles,,Charge,
+69,PARTICIPATION DES SALARIÉS – IMPÔTS SUR LES BÉNÉFICES ET ASSIMILÉS,,Charge,
+691,Participation des salariés aux résultats,,Charge,
+695,Impôts sur les bénéfices,,Charge,
+696,Suppléments d’impôt sur les sociétés liés aux distributions,,Charge,
+697,Imposition forfaitaire annuelle des sociétés,,Charge,
+698,Intégration fiscale,,Charge,
+699,Produits – Reports en arrière des déficits,,Charge,
+7,COMPTES DE PRODUITS,,Produit,
+70,"VENTES DE PRODUITS FABRIQUÉS, PRESTATIONS DE SERVICES, MARCHANDISES",,Produit,
+701,Ventes de produits finis,,Produit,Favori
+702,Ventes de produits intermédiaires,,Produit,Favori
+703,Ventes de produits résiduels,,Produit,Favori
+704,Travaux,,Produit,Favori
+705,Études,,Produit,Favori
+706,Prestations de services,,Produit,Favori
+707,Ventes de marchandises,,Produit,Favori
+708,Produits des activités annexes,,Produit,
+709,"Rabais, remises et ristournes accordés par l’entreprise",,Produit,
+71,PRODUCTION STOCKÉE (OU DÉSTOCKAGE),,Produit,
+713,"Variation des stocks (en-cours de production, produits)",,Produit,
+72,PRODUCTION IMMOBILISÉE,,Produit,
+721,Immobilisations incorporelles,,Produit,
+722,Immobilisations corporelles,,Produit,
+74,SUBVENTIONS D’EXPLOITATION,,Produit,
+75,AUTRES PRODUITS DE GESTION COURANTE,,Produit,
+751,"Redevances pour concessions, brevets, licences, marques, procédés, logiciels, droits et valeurs similaires",,Produit,
+752,Revenus des immeubles non affectés à des activités professionnelles,,Produit,
+753,"Jetons de présence et rémunérations d’administrateurs, gérants…",,Produit,
+754,Ristournes perçues des coopératives (provenant des excédents),,Produit,
+755,Quotes-parts de résultat sur opérations faites en commun,,Produit,
+758,Produits divers de gestion courante,,Produit,
+76,PRODUITS FINANCIERS,,Produit,
+761,PRODUITS DE PARTICIPATIONS,,Produit,
+762,Produits des autres immobilisations financières,,Produit,
+763,Revenus des autres créances,,Produit,
+764,Revenus des valeurs mobilières de placement,,Produit,
+765,Escomptes obtenus,,Produit,
+766,Gains de change,,Produit,
+767,Produits nets sur cessions de valeurs mobilières de placement,,Produit,
+768,Autres produits financiers,,Produit,
+77,Produits exceptionnels,,Produit,
+771,Produits exceptionnels sur opérations de gestion,,Produit,
+772,"(Compte à la disposition des entités pour enregistrer, en cours d’exercice, les produits sur exercices antérieurs)",,Produit,
+775,Produits des cessions d’éléments d’actif,,Produit,
+777,Quote-part des subventions d’investissement virée au résultat de l’exercice,,Produit,
+778,Autres produits exceptionnels,,Produit,
+78,REPRISES SUR AMORTISSEMENTS ET PROVISIONS,,Produit,
+781,Reprises sur amortissements et provisions (à inscrire dans les produits d’exploitation),,Produit,
+786,Reprises sur provisions pour risques (à inscrire dans les produits financiers),,Produit,
+787,Reprises sur provisions (à inscrire dans les produits exceptionnels),,Produit,
+79,TRANSFERTS DE CHARGES,,Produit,
+791,Transferts de charges d’exploitation,,Produit,
+796,Transferts de charges financières,,Produit,
+797,Transferts de charges exceptionnelles,,Produit,
+89,COMPTES DE BILAN,,,
+890,Bilan d'ouverture,,Actif ou passif,
+891,Bilan de clôture,,Actif ou passif,
\ No newline at end of file
diff --git a/src/include/data/charts/fr_pcs_2018.csv b/src/include/data/charts/fr_pcs_2018.csv
new file mode 100644
index 0000000..d82f7d5
--- /dev/null
+++ b/src/include/data/charts/fr_pcs_2018.csv
@@ -0,0 +1,1041 @@
+code,label,description,position,bookmark
+1,CLASSE 1 COMPTES DE CAPITAUX,,Passif,
+10,FONDS SYNDICAUX ET RESERVES,,Passif,
+102,Fonds propres sans droit de reprise,,Passif,
+1021,Valeur du patrimoine intégré,,Passif,
+1022,Fonds statutaires (à éclater en fonction des statuts),,Passif,
+1024,Apports sans droit de reprise,,Passif,
+1025,Legs et donations avec contrepartie d'actifs immobilisés,,Passif,
+1026,Subventions d'investissement affectées à des biens renouvelables,,Passif,
+1027,Autres fonds propres sans droit de reprise,,Passif,
+103,Fonds propres avec droit de reprise,,Passif,
+1034,Fonds propres avec droit de reprise,,Passif,
+1035,Legs et donations avec contrepartie d'actifs immobilisés assortis d'une obligation ou d'une condition,,Passif,
+1036,Subventions d'investissement affectées à des biens renouvelables,,Passif,
+1037,Autres fonds propres avec droit de reprise,,Passif,
+1039,Fonds propres avec droit de reprise inscrit au compte de résultat,,Passif,
+105,Ecarts de réévaluation,,Passif,
+1051,Ecarts de réévaluation sur des biens sans droit de reprise,,Passif,
+1052,Ecarts de réévaluation sur des biens avec droit de reprise,,Passif,
+1053,Réserve de réévaluation,,Passif,
+1055,Ecarts de réévaluation (autres opérations légales),,Passif,
+1057,Autres écarts de réévaluation en France,,Passif,
+1058,Autres écarts de réévaluation à l'étranger,,Passif,
+106,Réserves,,Passif,
+1062,Réserves indisponibles,,Passif,
+1063,Réserves statutaires ou contractuelles,,Passif,
+1064,Réserves réglementées,,Passif,
+10641,Plus-values nettes à long terme,,Passif,
+10643,Réserves consécutives à l'octroi de subventions d'investissement,,Passif,
+10648,Autres réserves réglementées,,Passif,
+1068,Autres réserves (dont réserves pour projet associatif),,Passif,
+10681,Réserve de propre assureur,,Passif,
+10688,Réserves diverses - Réserves pour projet associatif,,Passif,
+107,Écart d'équivalence,,Passif,
+11,ELEMENTS EN INSTANCE D'AFFECTATION,,Passif,
+110,Report à nouveau (solde créditeur),,Passif,
+115,Résultats sous contrôle de tiers financeurs,,Passif,
+119,Report à nouveau (solde débiteur),,Passif,
+12,RESULTAT NET DE L'EXERCICE (excédent ou déficit),,Passif,
+120,Résultat de l'exercice (excédent),,Passif,
+129,Résultat de l'exercice (déficit),,Passif,
+13,SUBVENTIONS D'INVESTISSEMENTS AFFECTEES A DES BIENS NON RENOUVELABLES,,Passif,
+131,Subventions d'équipement,,Passif,
+1311,Etat,,Passif,
+1312,Régions,,Passif,
+1313,Départements,,Passif,
+1314,Communes,,Passif,
+1315,Collectivités publiques,,Passif,
+1316,Entreprises publiques,,Passif,
+1317,Entreprises et organismes privés,,Passif,
+1318,Autres,,Passif,
+138,Autres subventions d'investissement,,Passif,
+139,Subventions d'Investissements Inscrites au compte de résultat,,Passif,
+1391,Subventions d'équipement,,Passif,
+1398,Autres subventions d'Investissement,,Passif,
+14,PROVISIONS REGLEMENTEES,,Passif,
+142,Provisions réglementées relatives aux immobilisations,,Passif,
+1423,Provisions pour reconstitution des gisements miniers et pétroliers,,Passif,
+1424,Provisions pour investissement (participation des salariés),,Passif,
+143,Provisions réglementées relatives aux stocks,,Passif,
+1431,Hausse des prix,,Passif,
+1432,Fluctuation des cours,,Passif,
+144,Provisions réglementées relatives aux autres éléments d'actif,,Passif,
+145,Amortissements dérogatoires,,Passif,
+146,Provision spéciale de réévaluation,,Passif,
+147,Plus-values réinvesties,,Passif,
+148,Autres provisions réglementées,,Passif,
+15,PROVISIONS,,Passif,
+151,Provisions pour risques,,Passif,
+1511,Provisions pour litiges,,Passif,
+1512,Provisions pour garanties données aux usagers ou clients,,Passif,
+1513,Provisions pour pertes sur marchés à terme,,Passif,
+1514,Provisions pour amendes et pénalités,,Passif,
+1515,Provisions pour pertes de change,,Passif,
+1516,Provisions pour pertes sur contrats - Provisions pour risques d'emploi,,Passif,
+1518,Autres provisions pour risques,,Passif,
+153,Provisions pour pensions et obligations similaires,,Passif,
+154,Provisions pour restructurations,,Passif,
+155,Provisions pour impôts,,Passif,
+156,Provisions pour renouvellement des immobilisations (entreprises concessionnaires),,Passif,
+157,Provisions pour charges à répartir sur plusieurs exercices,,Passif,
+1572,Provisions pour gros entretien ou grandes révisions,,Passif,
+158,Autres provisions pour charges,,Passif,
+1581,Provisions pour remises en état,,Passif,
+16,EMPRUNTS ET DETTES ASSIMILEES,,Passif,
+161,Emprunts obligataires convertibles,,Passif,
+163,Autres emprunts obligataires,,Passif,
+164,Emprunts auprès des établissements de crédit,,Passif,
+165,Dépôts et cautionnements reçus,,Passif,
+1651,Dépôts,,Passif,
+1655,Cautionnements,,Passif,
+166,Participation des salariés aux résultats,,Passif,
+1661,Comptes bloqués,,Passif,
+1662,Fonds de participation,,Passif,
+167,Emprunts et dettes assortis de conditions particulières,,Passif,
+1671,Emission de titres participatifs,,Passif,
+1672,Titres associatifs,,Passif,
+1674,Avances conditionnées de l'Etat,,Passif,
+1675,Emprunts participatifs,,Passif,
+168,Autres emprunts et dettes assimilées,,Passif,
+1681,Autres emprunts,,Passif,
+1685,Rentes viagères capitalisées,,Passif,
+1687,Autres dettes,,Passif,
+1688,Intérêts courus,,Passif,
+16881,sur emprunts obligataires convertibles,,Passif,
+16883,sur autres emprunts obligataires,,Passif,
+16884,sur emprunts auprès des établissements de crédit,,Passif,
+16885,sur dépôts et cautionnements reçus,,Passif,
+16886,sur participation des salariés aux résultats,,Passif,
+16887,sur emprunts et dettes assortis de conditions particulières,,Passif,
+16888,sur autres emprunts et dettes assimilés,,Passif,
+169,Primes de remboursement des obligations,,Actif,
+17,DETTES RATTACHEES A DES PARTICIPATIONS,,Passif,
+171,Dettes rattachées à des participations,,Passif,
+174,Dettes rattachées à des participations (hors groupe),,Passif,
+178,Dettes rattachées à des sociétés en participation,,Passif,
+1781,Principal,,Passif,
+1788,Intérêts courus,,Passif,
+18,COMPTES DE LIAISON DES ETABLISSEMENTS (avec le siège ou entre eux),,Passif,
+181,Apports permanents entre siège social et établissements,,Passif,
+185,Biens et prestations de services échangés entre établissements et siège social,,Passif,
+186,Biens et prestations de services échangés entre établissements (charges),,Passif,
+187,Biens et prestations de services échangés entre établissements (produits),,Passif,
+188,Comptes de liaison des sociétés en participation,,Passif,
+19,FONDS DEDIES,,Passif,
+193,Fonds dédiés sur apports,,Passif,
+194,Fonds dédiés sur subventions de fonctionnement,,Passif,
+195,Fonds dédiés sur dons manuels affectés,,Passif,
+196,Fonds dédiés aux contributions publiques de financement,,Passif,
+197,Fonds dédiés sur legs et donations affectés,,Passif,
+2,CLASSE 2 COMPTES D'IMMOBILISATIONS,,Actif,
+20,IMMOBILISATIONS INCORPORELLES,,Actif,
+201,Frais d'établissement,,Actif,
+2011,Frais de constitution,,Actif,
+2012,Frais de premier établissement,,Actif,
+20121,Frais de prospection,,Actif,
+20122,Frais de publicité,,Actif,
+2013,"Frais d'opérations diverses (fusions, scissions, transformations)",,Actif,
+203,Frais de recherche et de développement,,Actif,
+205,"Concessions et droits similaires, brevets, licences, marques, procédés, logiciels, droits et valeurs similaires",,Actif,
+206,Droit au bail,,Actif,
+207,Fonds commercial,,Actif,
+208,Autres immobilisations incorporelles,,Actif,
+21,IMMOBILISATIONS CORPORELLES,,Actif,
+211,Terrains,,Actif,
+2111,Terrains nus,,Actif,
+2112,Terrains aménagés,,Actif,
+2113,Sous-sols et sur-sols,,Actif,
+2114,Terrains et gisements,,Actif,
+21141,Carrières,,Actif,
+2115,Terrains bâtis,,Actif,
+21151,"Ensembles immobiliers industriels (A, B,...)",,Actif,
+21155,"Ensembles immobiliers administratifs et commerciaux (A, B,...)",,Actif,
+21158,Autres ensembles immobiliers,,Actif,
+211581,"affectés aux opérations professionnelles (A, B,...)",,Actif,
+211588,"affectés aux opérations non professionnelles (A, B,...)",,Actif,
+2116,Compte d'ordre sur immobilisations,,Actif,
+212,Agencements et aménagements de terrain (même analyse que le 211),,Actif,
+213,Constructions,,Actif,
+2131,Bâtiments,,Actif,
+21311,"Ensembles immobiliers industriels (A, B,...)",,Actif,
+21315,"Ensembles immobiliers administratifs et commerciaux (A, B,...)",,Actif,
+21318,Autres ensembles immobiliers,,Actif,
+213181,"affectés aux opérations professionnelles (A, B,...)",,Actif,
+213188,"affectés aux opérations non professionnelles (A, B,...)",,Actif,
+2135,Installations générales - agencements - aménagements des constructions (même analyse que le 2131),,Actif,
+2138,Ouvrages d'infrastructure,,Actif,
+21381,Voies de terre,,Actif,
+21382,Voies de fer,,Actif,
+21383,Voies d'eau,,Actif,
+21384,Barrages,,Actif,
+21385,Pistes d'aérodromes ??,,Actif,
+214,Construction sur sol d'autrui (même analyse que le 213),,Actif,
+215,"Installations techniques, matériel et outillage industriels",,Actif,
+2151,Installations complexes spécialisées,,Actif,
+21511,sur sol propre,,Actif,
+21514,sur sol d'autrui,,Actif,
+2153,Installations à caractère spécifique,,Actif,
+21531,sur sol propre,,Actif,
+21534,sur sol d'autrui,,Actif,
+2154,Matériel industriel,,Actif,
+2155,Outillage industriel,,Actif,
+2157,Agencements et aménagements du matériel et outillage industriels,,Actif,
+218,Autres immobilisations corporelles,,Actif,
+2181,"Installations générales, agencements, aménagements, divers",,Actif,
+2182,Matériel de transport,,Actif,
+2183,Matériel de bureau et matériel informatique,,Actif,
+2184,Mobilier,,Actif,
+2185,Cheptel,,Actif,
+2186,Emballages récupérables,,Actif,
+22,IMMOBILISATIONS MISES EN CONCESSION,,Actif,
+228,Immobilisations grevées de droits,,Actif,
+229,Droits des propriétaires,,Passif,
+23,IMMOBILISATIONS EN COURS,,Actif,
+231,Immobilisations corporelles en cours,,Actif,
+2312,Terrains,,Actif,
+2313,Constructions,,Actif,
+2315,Installations techniques - matériel et outillage industriels,,Actif,
+2318,Autres immobilisations corporelles,,Actif,
+232,Immobilisations incorporelles en cours,,Actif,
+236,Avances versées sur immobilisations financières,,Actif,
+237,Avances et acomptes versés sur immobilisations incorporelles,,Actif,
+238,Avances et acomptes versés sur commandes d'immobilisations corporelles,,Actif,
+2382,Terrains,,Actif,
+2383,Constructions,,Actif,
+2385,"Installations techniques, matériel et outillage industriels",,Actif,
+2388,Autres immobilisations corporelles,,Actif,
+25,(PARTS DANS DES ENTREPRISES LIEES ET CREANCES SUR DES ENTREPRISES LIEES),,Actif,
+26,PARTICIPATIONS ET CREANCES RATTACHEES A DES PARTICIPATIONS,,Actif,
+261,Titres de participation,,Actif,
+2611,Actions,,Actif,
+2618,Autres titres,,Actif,
+266,Autres formes de participation,,Actif,
+267,Créances rattachées à des participations,,Actif,
+2671,Créances rattachées à des participations (groupe),,Actif,
+2674,Créances rattachées à des participations (hors groupe),,Actif,
+2675,Versements représentatifs d'apports non capitalisés (appel de fonds),,Actif,
+2676,Avances consolidables,,Actif,
+2677,Autres créances rattachées à des participations,,Actif,
+2678,Intérêts courus,,Actif,
+268,Créances rattachées à des sociétés en participation,,Actif,
+2681,Principal,,Actif,
+2688,Intérêts courus,,Actif,
+269,Versements restant à effectuer sur titres de participation non libérés,,Passif,
+27,AUTRES IMMOBILISATIONS FINANCIERES,,Actif,
+271,Titres immobilisés autres que les titres immobilisés de l'activité de portefeuille (droit de propriété),,Actif,
+2711,Actions,,Actif,
+2718,Autres titres,,Actif,
+272,Titres immobilisés (droit de créance),,Actif,
+2721,Obligations,,Actif,
+2722,Bons,,Actif,
+273,Titres immobilisés de l'activité de portefeuille,,Actif,
+274,Prêts,,Actif,
+2741,Prêts participatifs,,Actif,
+2742,Prêts aux associés,,Actif,
+2743,Prêts au personnel,,Actif,
+2748,Autres prêts,,Actif,
+275,Dépôts et cautionnements versés,,Actif,
+2751,Dépôts,,Actif,
+2755,Cautionnements,,Actif,
+276,Autres créances immobilisées,,Actif,
+2761,Créances diverses,,Actif,
+2768,Intérêts courus,,Actif,
+27682,sur titres immobilisés (droits de créance),,Actif,
+27684,sur prêts,,Actif,
+27685,sur dépôts et cautionnements,,Actif,
+27688,sur créances diverses,,Actif,
+279,Versements restant à effectuer sur titres immobilisés non libérés,,Passif,
+28,AMORTISSEMENTS DES IMMOBILISATIONS,,Actif,
+280,Amortissements des immobilisations incorporelles,,Actif,
+2801,Frais d'établissement (même ventilation que le 201),,Actif,
+2803,Frais de recherche et de développement,,Actif,
+2805,"Concessions et droits similaires, brevets, licences, logiciels, droits et valeurs similaires",,Actif,
+2806,Amortissement du droit au bail,,Actif,
+2807,Fonds commercial,,Actif,
+2808,Autres immobilisations incorporelles,,Actif,
+281,Amortissements des immobilisations corporelles,,Actif,
+2811,Terrains de gisement,,Actif,
+2812,"Agencements, aménagements de terrains (même ventilation que le 212)",,Actif,
+2813,Constructions (même ventilation que le 213),,Actif,
+2814,Constructions sur sol d'autrui (même ventilation que le 214),,Actif,
+2815,Installations techniques matériel et outillage industriels (même ventilation que le 215),,Actif,
+2818,Autres immobilisations corporelles (même ventilation que le 218),,Actif,
+282,Amortissements des immobilisations mises en concession,,Actif,
+29,DEPRECIATION DES IMMOBILISATIONS,,Actif,
+290,Dépréciation des immobilisations incorporelles,,Actif,
+2905,"Marques, procédés, droits et valeurs similaires",,Actif,
+2906,Droit au bail,,Actif,
+2907,Fonds commercial,,Actif,
+2908,Autres immobilisations incorporelles,,Actif,
+291,Dépréciation des immobilisations corporelles (même analyse que le 21),,Actif,
+2911,Terrains (autres que terrains de gisement),,Actif,
+292,Dépréciation des immobilisations mises en concession,,Actif,
+293,Dépréciation des immobilisations en cours,,Actif,
+2931,Immobilisations corporelles en cours,,Actif,
+2932,Immobilisations incorporelles en cours,,Actif,
+296,Dépréciation des participations et créances rattachées à des participations,,Actif,
+2961,Titres de participation,,Actif,
+2966,Autres formes de participation,,Actif,
+2967,Créances rattachées à des participations (même ventilation que le 268),,Actif,
+2968,Créances rattachées à des sociétés en participation,,Actif,
+297,Dépréciation des autres immobilisations financières,,Actif,
+2971,Titres immobilisés autres que les titres immobilisés de l'activité de portefeuille - droit de propriété (même ventilation que le 271),,Actif,
+2972,Titres immobilisés droit de créance (même ventilation que le 272),,Actif,
+2973,Titres immobilisés de l'activité de portefeuille,,Actif,
+2974,Prêts (même ventilation que le 274),,Actif,
+2975,Dépôts et cautionnements versés (même ventilation que le 275),,Actif,
+2976,Autres créances immobilisées (même ventilation que le 276),,Actif,
+3,CLASSE 3 COMPTES DE STOCKS ET EN-COURS,,Actif,
+31,MATIERES PREMIERES (et fournitures),,Actif,
+311,Matière (ou groupe) A,,Actif,
+312,Matière (ou groupe) B,,Actif,
+317,"Fournitures, A, B, C....",,Actif,
+32,AUTRES APPROVISIONNEMENTS,,Actif,
+321,Matières consommables,,Actif,
+3211,Matière (ou groupe) C,,Actif,
+3212,Matière (ou groupe) D,,Actif,
+322,Fournitures consommables,,Actif,
+3221,Combustibles,,Actif,
+3222,Produits d'entretien,,Actif,
+3223,Fournitures d'atelier,,Actif,
+3224,Fournitures de magasin,,Actif,
+3225,Fournitures de bureau,,Actif,
+3226,Carburants (voir remarque A),,Actif,
+3227,Autres fournitures de matériel de transport (voir remarque A),,Actif,
+3228,Petit outillage (voir remarque A),,Actif,
+326,Emballages,,Actif,
+3261,Emballages perdus,,Actif,
+3265,Emballages récupérables non identifiables,,Actif,
+3267,Emballages à usage mixte,,Actif,
+33,EN COURS DE PRODUCTION DE BIENS,,Actif,
+331,Produits en cours,,Actif,
+3311,Produits en cours P 1,,Actif,
+3312,Produits en cours P 2,,Actif,
+335,Travaux en cours,,Actif,
+3351,Travaux en cours T 1,,Actif,
+3352,Travaux en cours T 2,,Actif,
+34,EN COURS DE PRODUCTION DE SERVICES,,Actif,
+341,Etudes en cours,,Actif,
+3411,Etudes en cours E 1,,Actif,
+3412,Etudes en cours E 2,,Actif,
+345,Prestations de services,,Actif,
+3451,Prestations de services S 1,,Actif,
+3452,Prestations de services S 2,,Actif,
+35,STOCKS DE PRODUITS,,Actif,
+351,Produits intermédiaires,,Actif,
+3511,Produits intermédiaires (ou groupe) A,,Actif,
+3512,Produits intermédiaires (ou groupe) B,,Actif,
+355,Produits finis,,Actif,
+3551,Produits finis (ou groupe) A,,Actif,
+3552,Produits finis (ou groupe) B,,Actif,
+358,Produits résiduels (ou matières de récupération),,Actif,
+3581,Déchets,,Actif,
+3585,Rebuts,,Actif,
+3586,Matières de récupération,,Actif,
+36,(STOCKS PROVENANT D'IMMOBILISATIONS),,Actif,
+37,STOCKS DE MARCHANDISES,,Actif,
+371,Marchandises (ou groupe) A,,Actif,
+372,Marchandises (ou groupe) B,,Actif,
+38,"(STOCKS EN VOIE D'ACHEMINEMENT, MISE EN DEPOT OU DONNES EN CONSIGNATION) (emploi exceptionnel - à détailler par nature)",,Actif,
+39,DEPRECIATION DES STOCKS ET EN COURS,,Actif,
+391,Dépréciation des matières premières (et fournitures),,Actif,
+3911,Matières (ou groupe) A,,Actif,
+3912,Matières (ou groupe) B,,Actif,
+3917,"Fournitures A, B, C...",,Actif,
+392,Dépréciation des autres approvisionnements,,Actif,
+3921,Matières consommables (même ventilation que le 321),,Actif,
+3922,Fournitures consommables (même ventilation que le 322),,Actif,
+3926,Emballages (même ventilation que le 326),,Actif,
+393,Dépréciation des en cours de production de biens,,Actif,
+3931,Produits en cours (même ventilation que le 331),,Actif,
+3935,Travaux en cours (même ventilation que le 335),,Actif,
+394,Dépréciation des en cours de production de services,,Actif,
+3941,Etudes en cours (même ventilation que le 341),,Actif,
+3945,Prestations de services en cours (même ventilation que le 345),,Actif,
+395,Dépréciation des stocks de produits,,Actif,
+3951,Produits intermédiaires (même ventilation que le 351),,Actif,
+3955,Produits finis (même ventilation que le 355),,Actif,
+397,Dépréciation des stocks de marchandises,,Actif,
+3971,Marchandises (ou groupe) A,,Actif,
+3972,Marchandises (ou groupe) B,,Actif,
+4,CLASSE 4 COMPTES DE TIERS,,Passif,
+40,FOURNISSEURS ET COMPTES RATTACHES,,Passif,
+400,Fournisseurs et comptes rattachés,,Passif,
+401,Fournisseurs,,Passif,
+4010,Autres fournisseurs,,Actif ou passif,Favori
+4011,Fournisseurs - Achats de biens ou de prestations de services,,Passif,
+4017,Fournisseurs - Retenues de garanties,,Passif,
+403,Fournisseurs - Effets à payer,,Passif,
+404,Fournisseurs d'immobilisations,,Passif,
+4041,Fournisseurs - Achats d'immobilisations,,Passif,
+4047,Fournisseurs d'immobilisations - Retenues de garantie,,Passif,
+405,Fournisseurs d'immobilisations - Effets à payer,,Passif,
+408,Fournisseurs - Factures non parvenues,,Passif,
+4081,Fournisseurs,,Passif,
+4084,Fournisseurs d'immobilisations,,Passif,
+4088,Fournisseurs - Intérêts en cours,,Passif,
+409,Fournisseurs débiteurs,,Actif,
+4091,Fournisseurs - Avances et acomptes versés sur commandes,,Actif,
+4096,Fournisseurs - Créances pour emballages et matériel à rendre,,Actif,
+4097,Fournisseurs - Autres avoirs,,Actif,
+40971,Fournisseurs d'exploitation,,Actif,
+40974,Fournisseurs d'immobilisations,,Actif,
+4098,"Rabais, remises, ristournes à obtenir et autres avoirs non encore reçus",,Actif,
+41,USAGERS ET COMPTES RATTACHES,,Actif,
+410,Usagers et comptes rattachés,,Actif,
+411,Usagers (clients),,Actif,
+4110,Autres usagers,Pour les dettes ou créances des membres,Actif ou passif,Favori
+4111,Usagers - Ventes de biens ou de prestations de services,,Actif,
+4117,Usagers - Retenues de garantie,,Actif,
+413,Usagers - Effets à recevoir,,Actif,
+416,Créances douteuses ou litigieuses,,Actif,
+418,Usagers - Produits non encore facturés,,Actif,
+4181,Usagers - Factures à établir,,Actif,
+4188,Usagers - Intérêts courus,,Actif,
+419,Usagers créditeurs,,Passif,
+4191,Usagers - Avances et acomptes reçus sur commande,,Passif,
+4196,Usagers - Dettes pour emballages et matériel consignés,,Passif,
+4197,Usagers - Autres avoirs,,Passif,
+4198,"Rabais, remises, ristournes à accorder et autres avoirs à établir",,Passif,
+42,PERSONNEL ET COMPTES RATTACHES,,Passif,
+421,Personnel - Rémunération dues,,Passif,
+4210,Autres membres du personnel,Dettes dûes aux salarié⋅e⋅s,Actif ou passif,Favori
+422,"Comité d'entreprise, d'établissement...",,Passif,
+424,Participation des salariés aux résultats,,Passif,
+4246,Réserve spéciale,,Passif,
+4248,Comptes courants,,Passif,
+425,Personnel - Avances et acomptes,,Actif,
+426,Personnel - Dépôts,,Passif,
+427,Personnel - Oppositions,,Passif,
+428,Personnel - Charges à payer et produits à recevoir,,Passif,
+4282,Dettes provisionnées pour congés à payer,,Passif,
+4284,Dettes provisionnées pour participation des salariés aux résultats,,Passif,
+4286,Autres charges à payer,,Passif,
+4287,Produits à recevoir,,Actif,
+43,SECURITE SOCIALE ET AUTRES ORGANISMES SOCIAUX,,Passif,
+431,Sécurité sociale,,Passif,
+437,Autres organismes sociaux,,Passif,
+438,Organismes sociaux - Charges à payer et produits à recevoir,,Passif,
+4382,Charges sociales sur congés à payer (voir remarque A),,Passif,
+4386,Autres charges à payer,,Passif,
+4387,Produits à recevoir,,Actif,
+44,ETAT ET AUTRES COLLECTIVITES PUBLIQUES,,Actif,
+441,Etat - Subventions à recevoir,,Actif,
+4411,Subventions d'investissement,,Actif,
+4417,Subventions d'exploitation,,Actif,
+4418,Subventions d'équilibre,,Actif,
+4419,Avances sur subventions,,Actif,
+442,Etat - Impôts et taxes recouvrables sur des tiers,,Passif,
+4424,Obligataires,,Passif,
+4425,Associés,,Passif,
+443,"Opérations particulières avec l'Etat, les collectivités publiques, les organismes internationaux",,Passif,
+4431,Créances sur l'Etat - TVA,,Passif,
+4438,Intérêts courus sur créances figurant au compte 4431,,Passif,
+444,Etat - Impôts sur les bénéfices,,Passif,
+445,Etat - Taxes sur le chiffre d'affaires à décaisser,,Passif,
+4452,TVA due intracommunautaire,,Passif,
+4455,Taxes sur le chiffre d'affaires,,Passif,
+44551,TVA à décaisser,,Passif,
+44558,Taxes assimilées à la TVA,,Passif,
+4456,Taxes sur le chiffre d'affaires déductibles,,Actif,
+44562,TVA sur immobilisations,,Actif,
+44563,TVA transférée par d'autres entreprises,,Actif,
+44566,TVA sur autres biens et services,,Actif,
+44567,Crédit de TVA à reporter,,Actif,
+44568,Taxes assimilées à la TVA,,Actif,
+4457,Taxes sur le chiffre d'affaires collectées par l'association,,Passif,
+44571,TVA collectée,,Passif,
+44578,Taxes assimilées à la TVA,,Passif,
+4458,Taxes sur le chiffre d'affaires à régulariser ou en attente,,Passif,
+44581,Acomptes - Régime simplifié d'imposition,,Actif,
+44582,Acomptes - Régime du forfait,,Actif,
+44583,Taxes sur le chiffre d'affaires à régulariser ou en attente - Actif (voir remarque A) - Remboursement de TVA demandé - TVA sur factures non parvenues,,Actif,
+44584,Taxes sur le chiffre d'affaires à régulariser ou en attente - Passif (voir remarque A) - TVA récupérée d'avance - TVA sur factures à établir (si le fait générateur n'est pas intervenu),,Passif,
+446,Obligations cautionnées,,Passif,
+447,"Autres impôts, taxes et versements assimilés",,Passif,
+448,Etat - Charges à payer et produits à recevoir,,Passif,
+4482,Charges fiscales sur congés à payer,,Passif,
+4486,Charges à payer,,Passif,
+4487,Produits à recevoir,,Actif,
+449,Quotas d'émissions à restituer à l'Etat,,Passif,
+45,CONFEDERATION - FEDERATION - UNION - ASSOCIATIONS AFFILIEES,,Passif,
+451,Confédération - Fédération - Union - Associations affiliées,,Passif,
+455,Sociétaires - Comptes courants,,Passif,
+4551,Principal,,Passif,
+4558,Intérêts courus,,Passif,
+458,Sociétaires - opérations faites en commun et en GIE,,Passif,
+4581,Opérations courantes,,Passif,
+4588,Intérêts courus,,Passif,
+46,DEBITEURS DIVERS ET CREDITEURS DIVERS,,Actif,
+462,Créances sur cessions d'immobilisations,,Actif,
+464,Dettes sur acquisitions de valeurs mobilières de placement,,Passif,
+465,Créances sur cessions de valeurs mobilières de placement,,Actif,
+466,Contributions publiques de financement à reverser,,Actif,
+467,Autres comptes débiteurs ou créditeurs,,Actif,
+468,Divers - Charges à payer et produits à recevoir,,Actif,
+4686,Charges à payer,,Passif,
+4687,Produits à recevoir,,Actif,
+47,COMPTES TRANSITOIRES OU D'ATTENTE,,Actif,
+471,Comptes d'attente,,Actif,
+472,Comptes d'attente,,Actif,
+473,Comptes d'attente,,Actif,
+474,Comptes d'attente,,Actif,
+475,Legs et donations en cours de réalisation,,Actif,
+476,Différences de conversion Actif (voir remarque D),,Actif,
+4761,Différences de conversion - ACTIF - sur éléments du FR,,Actif,
+4762,Différences de conversion - ACTIF - sur éléments du BFR,,Actif,
+4763,Différences de conversion - ACTIF - sur éléments de la Trésorerie,,Actif,
+477,Différences de conversion Passif (voir remarque D) ,,Passif,
+4771,Différences de conversion - PASSIF - sur éléments du FR,,Passif,
+4772,Différences de conversion - PASSIF - sur éléments du BFR,,Passif,
+4773,Différences de conversion - PASSIF - sur éléments de la Trésorerie,,Passif,
+478,Autres comptes transitoires,,Actif,
+48,COMPTES DE REGULARISATION,,Actif,
+481,Charges à répartir sur plusieurs exercices,,Actif,
+4816,Frais d'émission d'emprunts,,Actif,
+486,Charges constatées d'avance,,Actif,
+487,Produits constatés d'avance,,Passif,
+488,Comptes de répartition périodique des charges et produits,,Passif,
+4886,Charges,,Actif,
+4887,Produits,,Passif,
+489,Quotas d'émissions alloués par l'Etat,,Passif,
+49,DEPRECIATION DES COMPTES DE TIERS,,Actif,
+491,Dépréciation des comptes d'usagers,,Actif,
+495,"Dépréciation des comptes de confédérations, fédérations, unions, associations affiliées, adhérents",,Actif,
+4951,Confédérations - Fédérations - Unions - Associations affiliées,,Actif,
+4955,Sociétaires - Comptes courants,,Actif,
+4958,Sociétaires - Opérations faites en commun et en GIE,,Actif,
+496,Dépréciation des comptes de débiteurs divers,,Actif,
+4962,Créances sur cessions d'immobilisations,,Actif,
+4965,Créances sur cessions de valeurs mobilières de placement,,Actif,
+4967,Autres comptes débiteurs,,Actif,
+5,CLASSE 5 COMPTES FINANCIERS,,Actif,
+50,VALEURS MOBILIERES DE PLACEMENT,,Actif,
+501,Parts dans des entreprises liées,,Actif,
+502,Actions propres,,Actif,
+503,Actions,,Actif,
+5031,Titres cotés,,Actif,
+5035,Titres non cotés,,Actif,
+504,Autres titres conférant un droit de propriété,,Actif,
+506,Obligations,,Actif,
+5061,Titres cotés,,Actif,
+5065,Titres non cotés,,Actif,
+507,Bons du Trésor et bons de caisse à court terme,,Actif,
+508,Autres valeurs mobilières de placement et autres créances assimilées,,Actif,
+5081,Autres valeurs mobilières,,Actif,
+5082,Bons de souscription,,Actif,
+5088,"Intérêts courus sur obligations, bons et valeurs assimilés",,Actif,
+509,Versements restant à effectuer sur valeurs mobilières de placement non libérées,,Passif,
+51,"Banques, établissements financiers",,Actif,
+511,Valeurs à l'encaissement,,Actif,
+5112,Chèques à encaisser,,Actif ou passif,Favori
+5115,Paiements par carte à encaisser,,Actif ou passif,
+512,Banques,,Actif,
+514,Chèques postaux,,Actif,
+515,"""Caisses"" du Trésor et des établissements publics",,Actif,
+516,Sociétés de bourse,,Actif,
+517,Autres organismes financiers,,Actif,
+518,Intérêts courus,,Actif,
+5181,Intérêts courus à payer,,Passif,
+5188,Intérêts courus à recevoir,,Actif,
+519,Concours bancaires courants,,Passif,
+5191,Crédit de mobilisation de créances commerciales (CMCC),,Passif,
+5193,Mobilisation de créances nées à l'étranger,,Passif,
+5198,Intérêts courus sur concours bancaires courants,,Passif,
+52,INSTRUMENTS DE TRESORERIE,,Actif,
+53,Caisses,,Actif ou passif,
+530,Caisse,,Actif ou passif,Favori
+54,REGIES D'AVANCES ET ACCREDITIFS,,Actif,
+58,VIREMENTS INTERNES,,Actif,
+59,DEPRECIATION DES COMPTES FINANCIERS,,Actif,
+590,Dépréciation des valeurs mobilières de placement,,Actif,
+5903,Actions,,Actif,
+5904,Autres titres conférant un droit de propriété,,Actif,
+5906,Obligations,,Actif,
+5908,Autres valeurs mobilières de placement et créances assimilées,,Actif,
+6,CLASSE 6 COMPTES DE CHARGES,,Charge,
+60,ACHATS,,Charge,
+601,Achats stockés - matières premières (et fournitures),,Charge,
+6011,Matière (ou groupe) A,,Charge,
+6012,Matière (ou groupe) B,,Charge,
+6017,"Fournitures A, B, C,...",,Charge,
+602,Achats stockés Autres approvisionnements,,Charge,
+6021,Matières consommables,,Charge,
+60211,Matière (ou groupe) C,,Charge,
+60212,Matière (ou groupe) D,,Charge,
+6022,Fournitures consommables,,Charge,
+60221,Combustibles,,Charge,
+60222,Produits d'entretien,,Charge,
+60223,Fournitures d'atelier et d'usine,,Charge,
+60224,Fournitures de magasin,,Charge,
+60225,Fournitures de bureau,,Charge,
+60226,Carburants (voir remarque A),,Charge,
+60227,Autres fournitures de matériel (voir remarque A),,Charge,
+60228,Petit outillage (voir remarque A),,Charge,
+6026,Emballages,,Charge,
+60261,Emballages perdus,,Charge,
+60265,Emballages récupérables non identifiables,,Charge,
+60267,Emballages à usage mixte,,Charge,
+603,Variation des stocks (approvisionnements et marchandises),,Charge,
+6031,Variation des stocks de matières premières (et fournitures),,Charge,
+6032,Variation des stocks des autres approvisionnements,,Charge,
+6037,Variation des stocks de marchandises,,Charge,
+604,Achats d'études et prestations de services,,Charge,
+605,"Achats de matériels, équipements et travaux",,Charge,
+606,Achats non stockés de matières et fournitures,,Charge,
+6061,"Fournitures non stockables (eau, énergie...)","Facture d'eau, d’électricité, etc.",Charge,Favori
+6063,Fournitures d'entretien et petit équipement,"Vis, et matériel de bricolage (sauf outils) par exemple",Charge,Favori
+6064,Fournitures administratives,"Cartouches d'encre, papier, matériel bureautique, etc.",Charge,Favori
+6065,Petits logiciels,Par exemple contribution à un logiciel de gestion associative génial :-),Charge,Favori
+6066,Carburants (voir remarque A),,Charge,
+6067,Autres fournitures de matériel de transport (voir remarque A),,Charge,
+6068,Autres fournitures & matières,,Charge,Favori
+607,Achats de marchandises,,Charge,
+6071,Marchandises (ou groupe A),,Charge,
+6072,Marchandises (ou groupe B),,Charge,
+608,Frais accessoires d'achats (voir remarque B),,Charge,
+6081,Frais accessoires sur achats de matières premières,,Charge,
+6082,Frais accessoires sur achats d'autres approvisionnements stockés,,Charge,
+6085,Frais accessoires sur achats de matériels et équipements,,Charge,
+6086,Frais accessoires sur achats non stockés de matières et fournitures,,Charge,
+6087,Frais accessoires sur achats de marchandises,,Charge,
+609,"Rabais, remises et ristournes obtenus sur achats",,Charge,
+6091,de matières premières (et des fournitures),,Charge,
+6092,d'autres approvisionnements stockés,,Charge,
+6094,d'études et prestations de services,,Charge,
+6095,"de matériel, équipements et travaux",,Charge,
+6096,d'approvisionnements non stockés,,Charge,
+6097,de marchandises,,Charge,
+6098,"Rabais, remises et ristournes non affectés",,Charge,
+61,SERVICES EXTERIEURS,,Charge,
+611,Sous-traitance générale,,Charge,
+612,Redevances de crédit-bail,,Charge,
+6122,Crédit-bail immobilier,,Charge,
+6125,Crédit-bail mobilier,,Charge,
+613,Locations,,Charge,
+6132,Locations immobilières,Locations versées pour un local ou du matériel.,Charge,Favori
+6135,Locations mobilières,,Charge,
+6136,Malis sur emballages,,Charge,
+614,Charges locatives et de co-propriété,,Charge,
+615,Entretien et réparations,,Charge,
+6152,sur biens immobiliers,,Charge,
+6155,sur biens mobiliers,,Charge,
+6156,Maintenance,,Charge,
+616,Primes d'assurance,"Frais d’assurance local, activité, etc.",Charge,Favori
+6161,Multirisques,,Charge,
+6162,Assurance obligatoire de dommage-construction,,Charge,
+6163,Assurance transport,,Charge,
+61636,sur achats,,Charge,
+61637,sur ventes,,Charge,
+61638,sur autres biens,,Charge,
+6164,Risques d'exploitation,,Charge,
+6165,Insolvabilité usagers,,Charge,
+6168,Autres assurances,,Charge,
+617,Etudes et recherches,,Charge,
+618,Divers,,Charge,
+6181,Documentation générale,,Charge,
+6183,Documentation technique,,Charge,
+6185,"Frais de colloques, séminaires, conférences",,Charge,
+619,"Rabais, remises et ristournes obtenus sur services extérieurs",,Charge,
+62,AUTRES SERVICES EXTERIEURS,,Charge,
+621,Personnel extérieur à l'association,,Charge,
+6211,Personnel intérimaire,,Charge,
+6214,Personnel détaché ou prêté à l'association,,Charge,
+62141,Mises à disposition de personnel salarié,Frais de mise à disposition via un groupement d’employeurs,Charge,Favori
+622,Rémunérations d'intermédiaires et honoraires,,Charge,
+6221,Commissions et courtages sur achats,,Charge,
+6222,Commissions et courtages sur ventes,,Charge,
+6224,Rémunérations des transitaires,,Charge,
+6225,Rémunérations d'affacturage,,Charge,
+6226,Honoraires,,Charge,
+6227,Frais d'actes et de contentieux,,Charge,
+6228,Divers,,Charge,
+623,"Publicité, publications, relations publiques","Bulletins, affiches, communication, etc.",Charge,Favori
+6231,Annonces et insertions,,Charge,
+6232,Echantillons,,Charge,
+6233,Foires et expositions,,Charge,
+6234,Cadeaux aux usagers,,Charge,
+6235,Primes (concours...),,Charge,
+6236,Catalogues et imprimés,,Charge,
+6237,Publications,,Charge,
+6238,"Divers (pourboires, dons courants...)",,Charge,
+624,Transports de biens et transports collectifs du personnel,,Charge,
+6241,Transports sur achats,,Charge,
+6242,Transports sur ventes,,Charge,
+6243,Transports entre établissements ou chantiers,,Charge,
+6244,Transports administratifs,,Charge,
+6247,Transports collectifs du personnel,,Charge,
+6248,Divers,,Charge,
+625,"Déplacements, missions et réceptions","Billet de train, remboursement de frais kilométrique, etc.",Charge,Favori
+6251,Voyages et déplacements,,Charge,
+6255,Frais de déménagement,,Charge,
+6256,Missions,,Charge,
+6257,Réceptions,,Charge,
+626,Frais postaux et de télécommunications,"Facture d'accès à Internet, timbres, etc.",Charge,Favori
+627,Services bancaires et assimilés,Frais bancaires,Charge,Favori
+6271,"Frais sur titres (achat, vente, garde)",,Charge,
+6272,Commissions et frais sur émission d'emprunts,,Charge,
+6275,Frais sur effets,,Charge,
+6276,Locations de coffres,,Charge,
+6278,Autres frais et commissions sur prestations de services,,Charge,
+628,Divers,,Charge,
+6281,Concours divers (cotisations...),,Charge,
+6284,Frais de recrutement de personnel,,Charge,
+629,"Rabais, remises et ristournes obtenus sur autres services extérieurs",,Charge,
+63,"IMPOTS, TAXES ET VERSEMENTS ASSIMILES",,Charge,
+631,Impôts taxes et versements assimilés sur rémunérations (administrations des impôts),,Charge,
+6311,Taxe sur salaires,,Charge,
+6312,Taxe d'apprentissage,,Charge,
+6313,Participation des employeurs à la formation professionnelle continue,,Charge,
+6314,Cotisation pour défaut d'investissement obligatoire dans la construction,,Charge,
+6318,Autres,,Charge,
+633,Impôts taxes et versements assimilés sur rémunérations (autres organismes),,Charge,
+6331,Versement de transport,,Charge,
+6332,Allocation logement,,Charge,
+6333,Participation des employeurs à la formation professionnelle continue,,Charge,
+6334,Participation des employeurs à l'effort de construction,,Charge,
+6335,Versements libératoires ouvrant droit à l'exonération de la taxe d'apprentissage,,Charge,
+6338,Autres,,Charge,
+635,Autres impôts taxes et versements assimilés (administration des impôts),,Charge,
+6351,Impôts directs (sauf impôts sur les sociétés),,Charge,
+63511,Taxe professionnelle,,Charge,
+63512,Taxes foncières,,Charge,
+63513,Autres impôts locaux,,Charge,
+6352,Taxes sur le chiffre d'affaires non récupérables,,Charge,
+6353,Impôts indirects,,Charge,
+6354,Droits d'enregistrement et de timbre,,Charge,
+63541,Droits de mutation,,Charge,
+6358,Autres droits,,Charge,
+637,Autres impôts taxes et versements assimilés (autres organismes),,Charge,
+6371,Contribution sociale de solidarité à la charge des sociétés,,Charge,
+6372,Taxes perçues par les organismes publics internationaux,,Charge,
+6374,Impôts et taxes exigibles à l'étranger,,Charge,
+6378,Taxes diverses,,Charge,
+64,CHARGES DE PERSONNEL,,Charge,
+641,Rémunérations du personnel,,Charge,
+6411,"Salaires, appointements",,Charge,
+6412,Congés payés,,Charge,
+6413,Primes et gratifications,,Charge,
+6414,Indemnités et avantages divers,,Charge,
+6415,Supplément familial,,Charge,
+645,Charges de Sécurité sociale et prévoyance,,Charge,
+6451,Cotisations à l'URSSAF,,Charge,
+6452,Cotisations aux mutuelles,,Charge,
+6453,Cotisations aux caisses de retraites,,Charge,
+6454,Cotisations aux ASSEDIC,,Charge,
+6458,Cotisations aux autres organismes sociaux,,Charge,
+647,Autres charges sociales,,Charge,
+6471,Prestations directes,,Charge,
+6472,Versements aux comités d'entreprises et d'établissements,,Charge,
+6473,Versements aux comités d'hygiène et de sécurité,,Charge,
+6474,Versements aux autres oeuvres sociales,,Charge,
+6475,"Médecine du travail, pharmacie",,Charge,
+648,Autres charges de personnel,,Charge,
+649,Remboursement d'organismes sociaux,,Charge,
+65,AUTRES CHARGES DE GESTION COURANTE,,Charge,
+651,"Redevances pour concessions, brevets, licences, procédés, logiciels, droits et valeurs similaires",,Charge,
+6511,"Redevances pour concessions, brevets, licences, marques, procédés",,Charge,
+6516,Droits d'auteur et de reproduction,,Charge,
+6518,Autres droits et valeurs similaires,,Charge,
+653,Jetons de présence,,Charge,
+654,Pertes sur créances irrécouvrables,,Charge,
+6541,Créances de l'exercice,,Charge,
+6544,Créances des exercices antérieurs,,Charge,
+655,Quote-part de résultat sur opérations faites en commun,,Charge,
+6551,Quote-part de bénéfice transférée (compte du gérant),,Charge,
+6555,Quote-part de perte supportée (comptabilité des associés non gérants),,Charge,
+657,Subventions versées par l'organisation syndicale,,Charge,
+658,Charges diverses de gestion courante,,Charge,Favori
+ 66,CHARGES FINANCIERES,,Charge,
+661,Charges d'intérêts,,Charge,
+6611,Intérêts des emprunts et dettes,,Charge,
+66116,Intérêts des emprunts et dettes assimilés,,Charge,
+66117,Intérêts des dettes rattachées à des participations,,Charge,
+6615,Intérêts des comptes courants et des dépôts créditeurs,,Charge,
+6616,Intérêts bancaires et sur opérations de financements (escompte),,Charge,
+6617,Intérêts des obligations cautionnées,,Charge,
+6618,Intérêts des autres dettes,,Charge,
+66181,des dettes commerciales,,Charge,
+66188,des dettes diverses,,Charge,
+664,Pertes sur créances liées à des participations,,Charge,
+665,Escomptes accordés,,Charge,
+666,Pertes de change,,Charge,
+667,Charges nettes sur cessions de valeurs mobilières de placement,,Charge,
+668,Autres charges financières,,Charge,
+67,CHARGES EXCEPTIONNELLES,,Charge,
+670,Charges exceptionnelles,Autres dépenses exceptionnelles,Charge,Favori
+671,Charges exceptionnelles sur opérations de gestion,,Charge,
+6711,Pénalités sur marchés (et crédits payés sur achats et ventes),,Charge,
+6712,"Pénalités, amendes fiscales et pénales",,Charge,
+6713,Dons et libéralités,,Charge,
+6714,Créances devenues irrécouvrables dans l'exercice,,Charge,
+6715,Subventions accordées,,Charge,
+6717,Rappels d'impôts (autres qu'impôts sur les bénéfices),,Charge,
+6718,Autres charges exceptionnelles sur opérations de gestion,,Charge,
+672,Charges sur exercices antérieurs (voir remarque C),,Charge,
+6720,Achats et réductions sur achats concernant des exercices antérieurs,,Charge,
+67201,Achats de matières premières concernant des exercices antérieurs,,Charge,
+67202,Achats d'autres approvisionnements concernant des exercices antérieurs,,Charge,
+67204,Achats d'études et prestations de services concernant des exercices antérieurs,,Charge,
+67205,"Achats de matériel, équipements et travaux concernant des exercices antérieurs",,Charge,
+67206,Achats non stockés de matières et fournitures concernant des exercices antérieurs,,Charge,
+67207,Achats de marchandises concernant des exercices antérieurs,,Charge,
+6721,Services extérieurs concernant des exercices antérieurs (autres que crédit-bail),,Charge,
+6722,Autres services extérieurs concernant des exercices antérieurs,,Charge,
+67221,Personnel extérieur concernant des exercices antérieurs,,Charge,
+67228,Autres services extérieurs concernant des exercices antérieurs,,Charge,
+6723,"Impôts, taxes et versements assimilés concernant des exercices antérieurs",,Charge,
+67231,"Impôts, taxes et versements assimilés sur rémunérations concernant des exercices antérieurs",,Charge,
+67235,"Autres impôts, taxes et versements assimilés concernant des exercices antérieurs",,Charge,
+6724,Charges de personnel concernant des exercices antérieurs,,Charge,
+67241,Rémunérations concernant des exercices antérieurs,,Charge,
+67245,Autres charges de personnel concernant des exercices antérieurs,,Charge,
+6725,Autres charges de gestion courante concernant des exercices antérieurs,,Charge,
+67255,Quotes-parts de résultat sur opérations faites en commun concernant des exercices antérieurs,,Charge,
+67258,Autres charges de gestion courante concernant des exercices antérieurs,,Charge,
+6726,Charges financières concernant des exercices antérieurs,,Charge,
+67261,Intérêts et charges assimilés concernant des exercices antérieurs,,Charge,
+67266,Pertes de change concernant des exercices antérieurs,,Charge,
+673,Redevances de crédit-bail concernant des exercices antérieurs (voir remarque C),,Charge,
+6732,Crédit-bail immobilier concernant des exercices antérieurs,,Charge,
+6735,Crédit-bail mobilier concernant des exercices antérieurs,,Charge,
+675,Valeurs comptables des éléments d'actif cédés,,Charge,
+6751,Immobilisations incorporelles,,Charge,
+6752,Immobilisations corporelles,,Charge,
+6756,Immobilisations financières,,Charge,
+6758,Autres éléments d'actif,,Charge,
+678,Autres charges exceptionnelles,,Charge,
+6781,Malis provenant de clauses d'indexation,,Charge,
+6782,Lots,,Charge,
+6783,Malis provenant du rachat par l'entreprise d'actions et obligations émises par elle-même,,Charge,
+6788,Charges exceptionnelles diverses,,Charge,
+68,"DOTATIONS AUX AMORTISSEMENTS, DEPRECIATIONS, PROVISIONS ET ENGAGEMENTS",,Charge,
+681,Dotations aux amortissements dépréciations et provisions - Charges d'exploitation,,Charge,
+6811,Dotations aux amortissements des immobilisations incorporelles et corporelles,,Charge,
+68111,Immobilisations incorporelles,,Charge,
+68112,Immobilisations corporelles,,Charge,
+6812,Dotations aux amortissements des charges d'exploitation à répartir,,Charge,
+6815,Dotations aux provisions d'exploitation,,Charge,
+6816,Dotations aux dépréciations des immobilisations incorporelles et corporelles,,Charge,
+68161,Immobilisations incorporelles,,Charge,
+68162,Immobilisations corporelles,,Charge,
+6817,Dotations aux dépréciations des actifs circulants,,Charge,
+68173,Stocks et en cours,,Charge,
+68174,Créances,,Charge,
+686,Dotations aux amortissements dépréciations et provisions - Charges financières,,Charge,
+6861,Dotations aux amortissements des primes de remboursement des obligations,,Charge,
+6865,Dotations aux provisions financières,,Charge,
+6866,Dotations aux dépréciations des éléments financiers,,Charge,
+68662,Immobilisations financières,,Charge,
+68665,Valeurs mobilières de placement,,Charge,
+6868,Autres dotations,,Charge,
+687,Dotations aux amortissements dépréciations et provisions - Charges exceptionnelles,,Charge,
+6871,Dotations aux amortissements exceptionnels des immobilisations,,Charge,
+6872,Dotations aux provisions réglementées (immobilisations),,Charge,
+68725,Amortissements dérogatoires,,Charge,
+6873,Dotations aux provisions réglementées (stocks),,Charge,
+6874,Dotations aux autres provisions réglementées,,Charge,
+6875,Dotations aux provisions exceptionnelles,,Charge,
+6876,Dotations aux provisions pour dépréciations exceptionnelles,,Charge,
+689,Engagements à réaliser sur ressources affectées,,Charge,
+6893,Engagements à réaliser sur apports affectés,,Charge,
+6894,Engagements à réaliser sur subventions attribuées,,Charge,
+6895,Engagements à réaliser sur dons manuels affectés,,Charge,
+6896,Engagements à réaliser sur contributions de financement,,Charge,
+6897,Engagements à réaliser sur legs et donations affectés,,Charge,
+69,PARTICIPATION DES SALARIES - IMPOTS SUR LES BENEFICES ET ASSIMILES,,Charge,
+691,Participation des salariés aux résultats,,Charge,
+695,Impôt sur les sociétés,,Charge,
+6951,Impôts dus en France,,Charge,
+6952,Contribution additionnelle à l'impôt sur les bénéfices,,Charge,
+6954,Impôts dus à l'Etranger,,Charge,
+698,Intégration fiscale,,Charge,
+6981,Intégration fiscale - Charges,,Charge,
+6989,Intégration fiscale - Produits,,Charge,
+699,Produits - Report en arrière des déficits,,Charge,
+7,CLASSE 7 COMPTES DE PRODUITS,,Produit,
+70,"VENTES DE PRODUITS FABRIQUES, PRESTATIONS DE SERVICES, MARCHANDISES",,Produit,
+701,Ventes de produits finis,"Vente de produits fabriqués",Produit,Favori
+7011,Produit fini (ou groupe) A,,Produit,
+7012,Produit fini (ou groupe) B,,Produit,
+702,Ventes de produits intermédiaires,,Produit,
+703,Ventes de produits résiduels,,Produit,
+704,Travaux,,Produit,
+7041,Travaux de catégorie (ou activité) A,,Produit,
+7042,Travaux de catégorie (ou activité) B,,Produit,
+705,Etudes,,Produit,
+706,Prestations de services,,Produit,
+707,Ventes de marchandises,Ventes de produits achetés et revendus en l’état,Produit,Favori
+7071,Marchandise (ou groupe) A,,Produit,
+7072,Marchandise (ou groupe) B,,Produit,
+708,Produit des activités annexes,,Produit,
+7081,Produits des services exploités dans l'intérêt du personnel,,Produit,
+7082,Commissions et courtages,,Produit,
+7083,Locations diverses,,Produit,
+7084,Mise à disposition de personnel facturée,,Produit,
+7085,Ports et frais accessoires facturés,,Produit,
+7086,Bonis sur reprises d'emballages consignés,,Produit,
+7087,Bonifications obtenues des clients et primes sur ventes,,Produit,
+7088,Autres produits d'activités annexes (cessions d'approvisionnements...),,Produit,
+709,"Rabais, remises, ristournes accordés par l'association (ou fondation)",,Produit,
+7091,sur ventes de produits finis,,Produit,
+7092,sur ventes de produits intermédiaires,,Produit,
+7094,sur travaux,,Produit,
+7095,sur études,,Produit,
+7096,sur prestations de services,,Produit,
+7097,sur ventes de marchandises,,Produit,
+7098,sur produits des activités annexes,,Produit,
+71,PRODUCTION STOCKEE (ou destockage),,Produit,
+713,"Variations des stocks (en cours de production, produits)",,Produit,
+7133,Variation des en cours de production de biens,,Produit,
+71331,Produits en cours,,Produit,
+71335,Travaux en cours,,Produit,
+7134,Variation des en cours de production de services,,Produit,
+71341,Etudes en cours,,Produit,
+71345,Prestations de services en cours,,Produit,
+7135,Variation des stocks de produits,,Produit,
+71351,Produits intermédiaires,,Produit,
+71355,Produits finis,,Produit,
+71358,Produits résiduels,,Produit,
+72,PRODUCTION IMMOBILISEE,,Produit,
+721,Immobilisations incorporelles,,Produit,
+722,Immobilisations corporelles,,Produit,
+74,SUBVENTIONS D'EXPLOITATION,,Produit,
+740,Subventions reçues,,Produit,Favori
+75,AUTRES PRODUITS DE GESTION COURANTE,,Produit,
+751,"Redevances pour concessions, brevets, licences, marques, procédés, droits et valeurs similaires",,Produit,
+7511,"Redevances pour concessions, brevets, licences, marques, procédés, logiciels",,Produit,
+7516,Droits d'auteur et de reproduction,,Produit,
+7518,Autres droits et valeurs similaires,,Produit,
+752,Revenus des immeubles non affectés aux activités professionnelles,,Produit,
+753,"Jetons de présence et rémunérations d'administrateurs, gérants, ...",,Produit,
+754,Ressources liées à la générosité du public,Dons reçus,Produit,Favori
+755,Quotes-parts de résultat sur opérations faites en commun,,Produit,
+7551,Quote-part de perte transférée (comptabilité du gérant),,Produit,
+7555,Quote-part de bénéfice attribuée (comptabilité des associés non-gérants),,Produit,
+756,Cotisations,Cotisations des membres,Produit,Favori
+758,Produits divers de gestion courante,,Produit,
+7581,Dons manuels non affectés,,Produit,
+7582,Dons manuels affectés,,Produit,
+7583,Apports non affectés,,Produit,
+7584,Apports affectés,,Produit,
+7585,Legs et donations non affectés,,Produit,
+7586,Legs et donations affectés,,Produit,
+7587,Vente de dons en nature,,Produit,
+7588,Autres produits de la générosité publique,,Produit,
+76,PRODUITS FINANCIERS,,Produit,
+761,Produits des participations,,Produit,
+7611,Revenus des titres de participation,,Produit,
+7616,Revenus sur autres formes de participation,,Produit,
+7617,Revenus des créances rattachées à des participations,,Produit,
+762,Produits des autres immobilisations financières,,Produit,
+7621,Revenus des titres immobilisés,,Produit,
+7626,Revenus des prêts,,Produit,
+7627,Revenus des créances immobilisés,,Produit,
+763,Revenus des autres créances,,Produit,
+7631,Revenus des créances commerciales,,Produit,
+7638,Revenus des créances diverses,,Produit,
+764,Revenus des valeurs mobilières de placement,,Produit,
+765,Escomptes obtenus,,Produit,
+766,Gains de change,,Produit,
+767,Produits nets sur cessions de valeurs mobilières de placement,,Produit,
+768,Autres produits financiers,,Produit,
+77,PRODUITS EXCEPTIONNELS,,Produit,
+771,Produits exceptionnels sur opérations de gestion,,Produit,
+7711,Dédits et pénalités perçus sur achats et sur ventes,,Produit,
+7713,Libéralités reçues,,Produit,
+7714,Rentrées sur créances amorties,,Produit,
+7715,Subventions d'équilibre,,Produit,
+7717,Dégrèvements d'impôts autres qu'impôts sur les bénéfices,,Produit,
+7718,Autres produits exceptionnels sur opérations de gestion,,Produit,
+772,Produits sur exercices antérieurs (voir remarque C),,Produit,
+7720,Ventes et réductions sur ventes concernant des exercices antérieurs,,Produit,
+77201,Ventes de produits concernant des exercices antérieurs,,Produit,
+77204,Ventes de travaux concernant des exercices antérieurs,,Produit,
+77205,Ventes d'études et services concernant des exercices antérieurs,,Produit,
+77207,Ventes de marchandises concernant des exercices antérieurs,,Produit,
+77208,Produits des activités annexes concernant des exercices antérieurs,,Produit,
+7724,Subventions d'exploitation concernant des exercices antérieurs,,Produit,
+7725,Autres produits de gestion courante concernant des exercices antérieurs,,Produit,
+77255,Quotes-parts de résultat sur opérations faites en commun concernant des exercices antérieurs,,Produit,
+77258,Autres produits de gestion courante concernant des exercices antérieurs,,Produit,
+7726,Produits financiers concernant des exercices antérieurs,,Produit,
+77261,Produits des participations concernant des exercices antérieurs,,Produit,
+77262,Produits des autres immobilisations financières concernant des exercices antérieurs,,Produit,
+77263,Autres intérêts et produits assimilés,,Produit,
+77266,Gains de change concernant des exercices antérieurs,,Produit,
+775,Produits des cessions d'éléments d'actif,,Produit,
+7751,Immobilisations incorporelles,,Produit,
+7752,Immobilisations corporelles,,Produit,
+7756,Immobilisations financières,,Produit,
+7758,Autres éléments d'actif,,Produit,
+777,Quote-part des subventions d'investissement virée au résultat de l'exercice,,Produit,
+778,Autres produits exceptionnels,,Produit,
+7781,Bonis provenant de clauses d'indexation,,Produit,
+7782,Lots,,Produit,
+7783,Bonis provenant du rachat par l'entreprise d'actions et d'obligations émises par elle-même,,Produit,
+7788,Produits exceptionnels divers,,Produit,
+77881,Dons manuels non affectés,,Produit,
+77882,Dons manuels affectés,,Produit,
+77883,Apports affectés,,Produit,
+77884,Apports non affectés,,Produit,
+77885,Legs et donations non affectés,,Produit,
+77886,Legs et donations affectés,,Produit,
+77887,Vente de dons en nature,,Produit,
+77888,Autres produits de la générosité publique,,Produit,
+78,REPRISES SUR AMORTISSEMENTS DEPRECIATIONS ET PROVISIONS,,Produit,
+781,Reprises sur amortissements et provisions (à inscrire dans les produits d'exploitation),,Produit,
+7811,Reprises sur amortissements des immobilisations incorporelles et corporelles,,Produit,
+78111,Immobilisations incorporelles,,Produit,
+78112,Immobilisations corporelles,,Produit,
+7815,Reprises sur provisions d'exploitation,,Produit,
+7816,Reprises sur dépréciations des immobilisations incorporelles et corporelles,,Produit,
+78161,Immobilisations incorporelles,,Produit,
+78162,Immobilisations corporelles,,Produit,
+7817,Reprises sur dépréciations des actifs circulants,,Produit,
+78173,Stocks en cours,,Produit,
+78174,Créances,,Produit,
+786,"Reprises sur dépréciations, provisions (à inscrire dans les produits financiers)",,Produit,
+7865,Reprises sur provisions financières,,Produit,
+7866,Reprises sur dépréciations des éléments financiers,,Produit,
+78662,Immobilisations financières,,Produit,
+78665,Valeurs mobilières de placement,,Produit,
+787,"Reprises sur dépréciations, provisions (à inscrire dans les produits exceptionnels)",,Produit,
+7872,Reprises sur provisions réglementées (immobilisations),,Produit,
+78725,Amortissements dérogatoires,,Produit,
+78726,Provision spéciale de réévaluation,,Produit,
+78727,Plus-values réinvesties,,Produit,
+7873,Reprises sur provisions réglementées (stocks),,Produit,
+7874,Reprises sur autres provisions réglementées,,Produit,
+7875,Reprises sur provisions exceptionnelles,,Produit,
+7876,Reprises sur dépréciations exceptionnelles,,Produit,
+789,Report des ressources non utilisées des exercices antérieurs,,Produit,
+7893,Report des apports affectés non utilisées des exercices antérieurs,,Produit,
+7894,Report des subventions attribuées non utilisées des exercices antérieurs,,Produit,
+7895,Report des dons manuels affectés non utilisés des exercices antérieurs,,Produit,
+7896,Report des contributions de financement non utilisées des exercices antérieurs,,Produit,
+7897,Report des legs et donations affectées non utilisés des exercices antérieurs,,Produit,
+79,TRANSFERTS DE CHARGES,,Produit,
+791,Transferts de charges d'exploitation _,,Produit,
+7911,Transferts de consommations (comptes 60 à 62),,Produit,
+7912,Transferts d'autres charges (comptes 63 et 64),,Produit,
+796,Transferts de charges financières,,Produit,
+797,Transferts de charges exceptionnelles,,Produit,
+8,Classe 8 — Comptes spéciaux,,,
+86,Emplois des contributions volontaires en nature,,,
+860,Secours en nature,,Charge,Favori
+8601,Alimentaires,,Charge,Favori
+8602,Vestimentaires,,Charge,Favori
+861,Mise à dispositions gratuites de biens,,Charge,Favori
+8611,Locaux,,Charge,Favori
+8612,Matériels,,Charge,Favori
+862,Prestations,,Charge,Favori
+864,Personnel bénévole,,Charge,Favori
+87,Contributions volontaires en nature,,,
+870,Dons en nature,,Produit,Favori
+871,Prestations en nature,,Produit,Favori
+875,Bénévolat,,Produit,Favori
+89,Comptes de bilan,,,
+890,Bilan d'ouverture,,Actif ou passif,
+891,Bilan de clôture,,Actif ou passif,
diff --git a/src/include/data/dictionary.fr b/src/include/data/dictionary.fr
new file mode 100644
index 0000000..8234e69
--- /dev/null
+++ b/src/include/data/dictionary.fr
@@ -0,0 +1,3738 @@
+paysage
+lui-même
+erreur
+pénitence
+brûlant
+canon
+prudent
+débarrasser
+séparer
+retomber
+croix
+sauter
+nid
+grand-maman
+satisfait
+défense
+compter
+déboucher
+peintre
+tentation
+devant
+envie
+pleuvoir
+fièvre
+organiser
+persévérer
+ivre
+quart
+pourvu que
+tilleul
+barrière
+police
+pareil
+invention
+fer
+guerre
+cordialement
+campagnard
+infirmier
+crépuscule
+manier
+animal
+transporter
+mobile
+perfection
+marque
+joncher
+nouveau
+désespoir
+joindre
+cordonnier
+ruelle
+risquer
+janvier
+ménage
+couronne
+fusil
+loi
+page
+clou
+armée
+flacon
+blanc
+adresser
+maître
+caverne
+rentrée
+constant
+montre
+surface
+ruban
+cause
+division
+note
+luisant
+poche
+poursuite
+gazon
+ingratitude
+pré
+rose
+activité
+joyeux
+fâcher
+préoccuper
+obligeance
+rivière
+vache
+dessin
+but
+fond
+deux
+ceinture
+lorsque
+cantique
+tâcher
+expirer
+aviser
+gibecière
+lourd
+espiègle
+sommet
+carte
+vendeur
+opérer
+disparaître
+rire
+agréer
+visite
+précédent
+simplement
+dorer
+sagesse
+revivre
+manteau
+croûte
+intelligence
+lion
+chêne
+horreur
+grandeur
+saluer
+mauvais
+sentier
+estomac
+modeste
+nullement
+hâter
+millier
+saigner
+curé
+moineau
+fuite
+flaque
+crêpe
+gant
+s'emparer
+gare
+gentil
+sacrifice
+modérer
+brutal
+feuille
+pouvoir
+dossier
+clef
+terrain
+quarante
+actif
+fou
+réconforter
+gendarme
+ignorant
+grotte
+habiller
+stationner
+implorer
+chapitre
+soupir
+chaise
+rarement
+application
+s'écrier
+ressource
+ministre
+doyen
+jeu
+graisse
+chantre
+écho
+barre
+clouer
+bourgeon
+équipage
+commun
+obliger
+berceau
+outil
+coquille
+procureur
+germer
+séminaire
+partout
+agréablement
+énorme
+radieux
+retrousser
+sorte
+billet
+mademoiselle
+condamner
+aviateur
+tourbillonner
+boucher
+fumer
+décembre
+rang
+étouffer
+sens
+parcours
+tablier
+pleur
+bluet
+cesse
+papier
+bonté
+boucle
+avantageux
+tranche
+coller
+jeune
+drapeau
+gaieté
+chemin
+deviner
+auparavant
+orée
+passage
+souffle
+agir
+s'envoler
+apparition
+hors
+président
+deuil
+berger
+merveilleux
+clin d'oeil
+priver
+oser
+mariage
+forêt
+règne
+commode
+finir
+bosquet
+mouche
+tailleur
+essuyer
+téléphone
+couronner
+château
+tourbillon
+citoyen
+neveu
+paisible
+messager
+ci-joint
+ignorer
+sûrement
+tache
+dompteur
+préférence
+maison
+revêtir
+vicaire
+vendredi
+frapper
+chef
+bâiller
+seigneur
+appuyer
+livre
+complètement
+motif
+adjectif numéral
+train
+ruisselet
+arrêter
+danse
+blottir
+raisin
+raconter
+dahlia
+épauler
+réel
+morceau
+étudier
+amical
+mouchoir
+libre
+printanier
+voiture
+journal
+musée
+village
+charge
+pâquerette
+mari
+ombrage
+attention
+choc
+rapprocher
+prévoir
+bienveillance
+suprême
+sirène
+ceci
+giroflée
+gouverner
+fraîcheur
+circuler
+mélancolie
+exactement
+désireux
+commandement
+annoncer
+cueillette
+duc
+désobéissant
+chauffeur
+dessert
+murmurer
+exclamation
+formidable
+tant
+acclamer
+son
+désaltérer
+soeur
+fourrer
+industrie
+embarquer
+bienfaiteur
+mine
+joyeusement
+fidèle
+grès
+dictionnaire
+nuage
+miroir
+conscience
+équilibre
+tarder
+verdoyant
+parvenir
+boue
+refuser
+manuel
+grand
+réchauffer
+succéder
+bloc
+front
+père
+employé
+moissonneur
+là
+majesté
+réclamer
+auquel
+général
+obscurcir
+commerce
+singulier
+consolation
+passager
+mais
+événement
+administrer
+coucher
+rapidité
+respirer
+quai
+empereur
+farine
+préserver
+glisser
+ver
+sourd
+notaire
+citer
+débris
+habile
+qualité
+inerte
+football
+abri
+miel
+franchement
+essai
+production
+errer
+ah
+idéal
+agile
+tarte
+léger
+raisonnable
+projeter
+poésie
+massif
+pommier
+ensoleillé
+couvent
+sauver
+invitation
+métier
+poussière
+école
+fuir
+pleurer
+imaginer
+sautiller
+édifier
+multitude
+commerçant
+plat
+autel
+toujours
+craindre
+tuer
+cuisinière
+ingrat
+percher
+concert
+doubler
+trésor
+cimetière
+exercice
+savon
+éblouir
+éclat
+parc
+peau
+compléter
+escalier
+sous
+matinée
+recherche
+régner
+épreuve
+autrefois
+fauteuil
+apprendre
+pétale
+sujet
+lequel
+bien-être
+chauffage
+nerveux
+décision
+participer
+gronder
+aussi
+tombe
+éternité
+rejeter
+enveloppe
+habitude
+peut-être
+accabler
+confrère
+prêtre
+parfaitement
+soigneusement
+compliment
+d'après
+chéri
+oiseau
+coureur
+mentir
+faucheur
+héros
+arrêt
+graver
+intrigué
+plaine
+combler
+planche
+recouvrir
+imprudence
+brebis
+poule
+spectacle
+semblable
+anticiper
+mener
+hommage
+déshabiller
+moins
+volume
+poulet
+main
+imposant
+fendre
+paille
+résistance
+écrivain
+franchise
+casquette
+mien
+heure
+an
+oranger
+coucou
+plomb
+télégramme
+amende
+part
+combat
+applaudir
+rédaction
+exécution
+largement
+unique
+jardinier
+belge
+réserver
+colère
+lueur
+moyen
+coiffer
+guérir
+échec
+minute
+conseiller
+fontaine
+scier
+fabrique
+oisillon
+amicalement
+oreille
+laisser
+gosse
+tard
+autoriser
+emmener
+anxiété
+patron
+abbé
+désobéissance
+bazar
+destinée
+plume
+terrasse
+potager
+dessus
+régulièrement
+distinguer
+roseau
+malade
+éteindre
+pendule
+imperméable
+fougère
+pelage
+écriture
+journalier
+comprendre
+manoeuvre
+soudain
+dessous
+parmi
+aire
+bosselé
+goutte
+bateau
+veiller
+économiser
+intelligent
+ferveur
+avertir
+repousser
+ville
+convenable
+farce
+cultiver
+provenir
+acharner
+pic
+avril
+frontière
+patte
+merci
+prince
+précieux
+espoir
+allumette
+poumon
+bleu
+inutile
+proposer
+être
+attester
+couvert
+trou
+démontrer
+effroyable
+localité
+reverdir
+trop
+user
+ours
+locomotive
+ailleurs
+fournir
+piste
+béret
+profondément
+respecter
+lecture
+congrès
+ennuyer
+lendemain
+indigne
+eux
+détail
+compagne
+vérité
+capital
+bavarder
+grue
+sueur
+urgent
+habituel
+fragile
+ôter
+briller
+border
+souper
+marteau
+agent
+moisson
+mou
+repentir
+disperser
+commande
+situation
+atteindre
+musique
+voyage
+remettre
+écharpe
+fruit
+éclaircir
+particulier
+ardent
+foi
+bercer
+papa
+marbre
+guérison
+alcoolique
+apaiser
+marguerite
+hôpital
+séance
+bousculer
+aube
+cordial
+quelque
+chaque
+difficilement
+union
+inquiétude
+filleul
+ami
+explication
+toux
+limpide
+jeter
+réellement
+inquiéter
+activer
+capable
+résister
+reconnaissance
+troupe
+aussitôt
+niveau
+jeunesse
+meunier
+signature
+consulter
+bout
+périr
+amateur
+livrer
+rapide
+engloutir
+usage
+nombreux
+sagement
+tapis
+grossier
+bourgmestre
+demoiselle
+prodiguer
+limite
+clochette
+fortune
+échanger
+inquiet
+parapluie
+quelconque
+propos
+enquête
+réduire
+chance
+énergique
+source
+dent
+voilà
+féroce
+tasse
+envers
+capitaine
+contrée
+obstacle
+île
+plier
+moderne
+allemand
+muet
+introduction
+annuel
+habitation
+jambe
+mouton
+résoudre
+ferrer
+éclater
+sein
+naufrage
+galerie
+foule
+soulier
+voûte
+toutefois
+fourrure
+marquis
+punir
+féliciter
+discussion
+défiler
+dédaigner
+tailler
+tomber
+clé
+côté
+broder
+encore
+jambon
+poulailler
+habiter
+chauffer
+normal
+aventurer
+professeur
+approuver
+peur
+automobile
+conduire
+ardeur
+vôtre
+centime
+emporter
+fois
+moquer
+éducation
+fourmillière
+pieux
+copier
+étinceler
+regagner
+trait
+esclave
+géant
+attrait
+allonger
+rude
+santé
+bébé
+décharger
+obscur
+commercial
+battre
+partir
+parure
+mesure
+encombrer
+reflet
+reproche
+recueillir
+cathédrale
+illustrer
+chiffre
+timbre
+courir
+climat
+genre
+daigner
+tente
+gauche
+imagination
+chérir
+guichet
+bulletin
+classique
+poursuivre
+renseigner
+boisson
+ranimer
+sabot
+chute
+brillant
+volaille
+grandiose
+orgueil
+brun
+plaintif
+moqueur
+bruit
+jadis
+achat
+baigner
+accourir
+inscrire
+plancher
+religieux
+joue
+avec
+accident
+figurer
+surtout
+buis
+courageusement
+moine
+employer
+baguette
+foudre
+signaler
+succès
+parfumer
+découvrir
+bête
+sien
+corniche
+renoncer
+relever
+mérite
+résonner
+type
+tournée
+préparation
+sillon
+étable
+relation
+chasseur
+cas
+description
+porc
+pourrir
+blesser
+favori
+puisque
+laid
+calculer
+veine
+chameau
+indifférent
+dégager
+crucifix
+faine
+renouveler
+principe
+crayon
+revenir
+frémir
+élégant
+secret
+parce que
+opinion
+mélanger
+colonel
+utiliser
+soin
+cinquante
+sonore
+écouler
+tel
+colline
+seulement
+futur
+expédier
+tuile
+chapeau
+mouiller
+fureur
+grâce
+consentement
+fervent
+paletot
+vêtir
+manger
+étude
+ménager
+proie
+héroïque
+pli
+canal
+lin
+effectuer
+condoléances
+savoir
+instructif
+azuré
+cristal
+plus
+chemise
+jouer
+immobile
+achever
+quatrième
+serviteur
+spécial
+science
+rechercher
+alentours
+mont
+soyeux
+cruel
+service
+jugement
+négligence
+oie
+mortel
+voyager
+fourneau
+gâteau
+début
+ciseaux
+carrefour
+mobilier
+gambader
+arroser
+imiter
+redevenir
+rapporter
+gaz
+inviter
+fort
+hardi
+passé
+riche
+oeuvre
+souterrain
+créature
+tendre
+souvent
+ralentir
+agrément
+entasser
+faute
+scintiller
+peiner
+froid
+traiter
+gorge
+remarquable
+veston
+princesse
+peinture
+basse
+provision
+poteau
+guider
+plusieurs
+série
+mûr
+affectueux
+refuge
+salaire
+innocent
+véhicule
+style
+consacrer
+gardien
+retrouver
+coupable
+charmer
+créateur
+renard
+panorama
+couteau
+rêver
+confectionner
+appartement
+traîner
+astre
+appétit
+invisible
+détester
+raide
+réponse
+ceux
+creuser
+carré
+aujourd'hui
+ficelle
+trottoir
+moulin
+glacer
+vêtement
+hâte
+appeler
+mission
+renouvellement
+dernier
+missionnaire
+fille
+gratitude
+juillet
+vieil
+déchaîner
+commettre
+attraper
+parsemer
+quantité
+gazouiller
+blancheur
+foie
+satin
+serein
+salut
+connaître
+novembre
+avenue
+curiosité
+porte-plume
+merveille
+chaland
+article
+geler
+mansarde
+alcool
+monstre
+chanter
+océan
+ressort
+sifflet
+renoncule
+entrée
+balle
+monter
+effort
+introduire
+juin
+vaillant
+valoir
+voisin
+eh
+dresser
+dortoir
+sobre
+médecin
+répéter
+problème
+négliger
+bicyclette
+couche
+facile
+visiteur
+aise
+assiette
+aurore
+sincère
+magnifique
+estimer
+durée
+accorder
+reculer
+café
+fameux
+serviette
+ainsi
+voix
+protéger
+circulation
+agréable
+communier
+chrysanthème
+animation
+végétation
+justement
+cochon
+courant
+légume
+nation
+table
+jardin
+extérieur
+attentif
+important
+noeud
+décrire
+vivant
+merle
+vermeil
+forger
+comparer
+redoubler
+forcer
+incliner
+bien-aimé
+préau
+ériger
+gras
+dentelle
+rayon
+régime
+foncer
+avant
+célébrer
+générosité
+perle
+envoi
+sonnette
+suffire
+crever
+réfectoire
+indication
+paternel
+ronronner
+péril
+second
+grappe
+courageux
+lierre
+cuire
+cousin
+souhaiter
+trembler
+pas
+patronage
+mur
+mâchoire
+paresse
+garde
+ronce
+courber
+furieux
+dedans
+brouillard
+expression
+promener
+intention
+bille
+perte
+déception
+situer
+bonne
+désormais
+azur
+roulotte
+public
+gerbe
+crème
+isoler
+séjour
+repas
+donc
+madame
+vernir
+corridor
+périlleux
+fauvette
+encourir
+position
+fin
+vide
+jusque
+agrémenter
+autorité
+pauvre
+plage
+enfouir
+marché
+vraiment
+signifier
+portière
+griffe
+minuscule
+plonger
+messe
+impossibilité
+ouvrage
+paix
+ambulance
+petit
+vacances
+corriger
+engager
+humble
+longuement
+pratiquer
+jaunir
+salutation
+dérober
+bouche
+nécessaire
+causer
+dessein
+rouler
+huit
+villa
+façonner
+malgré
+sang
+alors
+silencieusement
+nettoyer
+dévouement
+conseil
+tunnel
+parent
+fauve
+cabane
+distraction
+opération
+fruitier
+poupée
+crier
+tapage
+soir
+sot
+sentiment
+prolonger
+grimper
+pur
+produit
+satisfaction
+rustique
+chambre
+excuse
+rouge
+protection
+désolation
+étendre
+crise
+pourtant
+communiant
+enseignement
+ample
+physique
+négligent
+lanterne
+recommencer
+conserver
+chaux
+menteur
+suffisant
+vivre
+abondant
+étagère
+douter
+glace
+liberté
+docile
+pensionnat
+debout
+abattre
+clairon
+noircir
+victoire
+épuiser
+tiroir
+chevalier
+considérer
+cuiller
+réunion
+reste
+depuis
+cortège
+heureusement
+panache
+poignée
+brin
+peser
+lettre
+naissance
+réunir
+lâcher
+venir
+rassurer
+porte
+resplendir
+taper
+associer
+doute
+promettre
+sourire
+surmonter
+bienveillant
+chaleur
+nièce
+flatter
+régaler
+moelleux
+surprise
+écurie
+danger
+distinction
+offrir
+travail
+exposer
+colis
+flot
+quotidien
+permettre
+ennemi
+invoquer
+voler
+artiste
+attrister
+linge
+tombeau
+répondre
+donner
+transmettre
+août
+seul
+planter
+acquitter
+lire
+horizon
+splendeur
+amour
+kilomètre
+jardinage
+ornement
+charme
+vers
+mineur
+tempête
+circonstance
+maint
+caresse
+oeillet
+dimension
+gâter
+immaculé
+veille
+baisser
+peuplier
+rouleau
+lilas
+lumineux
+laborieux
+gland
+écrire
+flocon
+parole
+cuillère
+favoriser
+composition
+gravure
+nature
+tantôt
+dominer
+boire
+fouet
+spécialement
+coquelicot
+fourniture
+lampe
+grange
+pétrir
+port
+retraite
+douloureux
+amusement
+bibelot
+enlever
+clément
+sabre
+lutter
+pluie
+musicien
+pie
+phrase
+aventure
+perspective
+solide
+vertu
+refaire
+apostolique
+orgue
+quinze
+réciter
+bordure
+débarquer
+cycliste
+randonnée
+renvoyer
+épargner
+volonté
+savoureux
+tous
+essayer
+aliment
+haleine
+côte
+humide
+obtenir
+complet
+onduler
+ressentir
+crime
+pigeon
+vapeur
+chaume
+cirque
+chiffon
+ferraille
+ramasser
+s'éloigner
+cent
+manche
+planer
+affreux
+jurer
+trimestre
+triomphe
+pénible
+grelotter
+rigole
+commander
+épine
+suivre
+gourmand
+confier
+hurler
+joujou
+dîner
+dévorer
+plafond
+parler
+enfermer
+avion
+bienheureux
+titre
+lien
+peine
+aucun
+riant
+dont
+admirable
+triompher
+conférence
+tiède
+mourir
+diriger
+terminer
+prononcer
+acier
+splendide
+image
+histoire
+céleste
+brigand
+résultat
+dos
+foin
+rayonner
+venger
+ensuite
+patience
+ébats
+allégresse
+rideau
+malin
+flatteur
+luire
+robuste
+ardoise
+sacoche
+menton
+bassin
+mai
+doux
+date
+bonbon
+dévouer
+enflammer
+goût
+instituteur
+habit
+lier
+sympathie
+naître
+marraine
+animer
+pourquoi
+chausser
+communication
+mouvement
+sève
+aubépine
+vif
+clos
+bal
+hasard
+trouble
+ménagère
+scolaire
+marche
+prouver
+comble
+couver
+théâtre
+affliger
+écouter
+contrarier
+lieu
+étoile
+frotter
+four
+caprice
+fixe
+puissant
+ménagerie
+croître
+culotte
+tort
+pelouse
+maudire
+confus
+occuper
+tournant
+angoisse
+fièrement
+exécuter
+commission
+servir
+poil
+catastrophe
+selon
+bord
+chose
+vaisselle
+rage
+louer
+étirer
+énormément
+entre
+juge
+poutre
+blanchir
+fréquent
+las
+mériter
+inconvénient
+fréquenter
+bourgeonner
+demi
+voie
+centre
+chevelure
+raccourcir
+affiche
+découper
+succulent
+impression
+architecte
+blouse
+procurer
+faner
+utilité
+vigne
+bêche
+vain
+reprise
+chair
+secouer
+anxieux
+recommander
+onze
+velours
+moindre
+sillonner
+cité
+évangile
+duvet
+quatre
+farouche
+noyer
+si
+caillou
+manoeuvrer
+négociant
+chou
+royaume
+attirer
+missel
+rougir
+limiter
+flanc
+dommage
+dehors
+difficile
+litière
+intime
+penser
+huile
+savant
+souhait
+piété
+calmer
+faiblesse
+bénir
+préparatif
+pinson
+goûter
+membre
+écarter
+défendre
+wagon
+gamin
+pain
+insigne
+valeur
+recommandation
+butiner
+hanneton
+redoutable
+consoler
+appartenir
+soupe
+mettre
+hausser
+chocolat
+aligner
+gracieux
+haillon
+niche
+bourdonner
+peindre
+briser
+quoi
+attaquer
+nuit
+paraître
+lisière
+cesser
+recevoir
+sport
+maire
+marin
+violence
+usine
+survenir
+meule
+proprement
+saint
+possession
+cinq
+dessiner
+quant
+falloir
+politesse
+trente
+fier
+épaule
+embaumer
+dès
+abaisser
+pécher
+noir
+accrocher
+tas
+vie
+interdire
+progrès
+sacrement
+rusé
+orgueilleux
+courrier
+déjà
+au-dessus
+guetter
+pâte
+vagabond
+plante
+rôle
+avoine
+voleur
+craquement
+taquiner
+affectueusement
+portée
+grain
+porteur
+buisson
+toile
+tressaillir
+intellectuel
+mordre
+bière
+dette
+apprêter
+camion
+net
+bravo
+groupe
+espace
+aimable
+épée
+dur
+menuisier
+précaution
+volée
+orphelin
+accepter
+estrade
+bambin
+célèbre
+bétail
+déborder
+univers
+tremper
+oeuf
+toit
+bec
+coiffure
+hauteur
+filer
+bouleverser
+réserve
+talus
+expédition
+fatiguer
+annonce
+acte
+vendre
+samedi
+appliquer
+conviction
+ranger
+pan
+camarade
+terrestre
+quelquefois
+poli
+voiler
+dépendre
+directeur
+unir
+suspendre
+paradis
+dormir
+décorer
+cadet
+plan
+saut
+national
+gémir
+gloire
+terme
+indiquer
+mélodie
+exactitude
+tacher
+douzaine
+répartir
+propice
+distance
+région
+mendier
+parterre
+flèche
+idée
+bref
+confondre
+couler
+verre
+oncle
+étalage
+familier
+fumée
+désirer
+boutique
+boîte
+industriel
+corde
+veuf
+refléter
+gaiement
+arrondissement
+refermer
+lèvre
+banque
+tableau
+s'écrouler
+instant
+pardon
+turbulent
+somme
+chrétien
+rompre
+vol
+concours
+enfin
+renaître
+loup
+envelopper
+commune
+bondir
+barbe
+paître
+outre
+corbeille
+exposition
+fleurir
+pension
+pays
+brusquement
+âne
+vue
+soulever
+recourir
+coussin
+avantage
+balancer
+cigarette
+nouvelle
+charité
+pitié
+suffisamment
+secourir
+cela
+long
+longer
+trouver
+doucement
+passant
+demander
+réalité
+demeure
+queue
+procession
+fondre
+aisément
+bonheur
+respect
+changement
+aiguille
+vaste
+centaine
+transformer
+prospérité
+sacrifier
+prochain
+geste
+lointain
+flamand
+tenter
+commencement
+là-bas
+diamant
+prier
+propriété
+hirondelle
+nécéssité
+continuellement
+fatigue
+rive
+travailleur
+kermesse
+quelqu'un
+solitude
+sursauter
+salir
+évidemment
+vieillard
+cadeau
+office
+acquérir
+péniblement
+environner
+grille
+grammaire
+végétal
+pipe
+fête
+semaine
+profondeur
+délicat
+détacher
+retour
+souffrir
+supporter
+gouvernement
+barque
+lambeau
+seuil
+étranger
+froisser
+tourment
+d'abord
+personnel
+prudence
+remède
+intéresser
+étudiant
+manque
+jacinthe
+villageois
+renfermer
+égarer
+herbe
+poire
+armoire
+présent
+prétendre
+joli
+signer
+plaindre
+offre
+sucer
+ressembler
+maladie
+tandis que
+caresser
+couleur
+électricité
+plaisir
+bras
+tonneau
+bruyant
+proclamer
+couture
+bienvenue
+cage
+calvaire
+connaissance
+tenir
+propre
+confesser
+degré
+maintenant
+droit
+lancer
+gelée
+reconnaissant
+ancien
+colonne
+nord
+maussade
+talent
+contempler
+fermer
+vélo
+ni
+garantir
+résigner
+brut
+blond
+reporter
+vite
+aisance
+gêner
+blé
+forge
+nourrir
+barquette
+abord
+teinte
+pardessus
+ravir
+emploi
+étage
+sauf
+frêle
+prêt
+lièvre
+créer
+pâture
+extrême
+victime
+tendresse
+rue
+inconnu
+possible
+croquer
+encre
+anglais
+chasser
+rester
+charbonnage
+sinistre
+carnet
+effrayer
+myosotis
+fouetter
+expliquer
+écorce
+ravage
+sublime
+revue
+entretien
+géographie
+boucler
+gravement
+quel
+or
+lis
+écolier
+dégât
+taire
+insister
+onde
+supplier
+chariot
+mécanique
+baiser
+vouloir
+fossé
+mois
+porter
+exercer
+puis
+poulain
+illusion
+sécurité
+marier
+gîte
+tapisser
+domestique
+amer
+étincelant
+garnir
+providence
+espérer
+cartable
+fonds
+alouette
+ébranler
+estime
+soleil
+valise
+entourer
+insecte
+armer
+sortir
+jouir
+éclore
+mécontent
+loyal
+primaire
+contenu
+généralement
+persuader
+infini
+anniversaire
+fenêtre
+action
+forgeron
+agiter
+fortement
+réveil
+accomplir
+disposition
+ordinairement
+embellir
+mesurer
+arme
+souci
+graine
+soirée
+robe
+proverbe
+manifester
+pantalon
+dictée
+bouleau
+illuminer
+fêter
+relatif
+certes
+élever
+mort
+natal
+drôle
+point
+modèle
+exister
+voeu
+beauté
+admirer
+redresser
+par
+cours
+varier
+envahir
+content
+retenir
+amuser
+s'efforcer
+obéir
+pondre
+logis
+avouer
+museau
+parti
+grandir
+promeneur
+enfance
+autour
+flamme
+durer
+adversaire
+préférer
+retirer
+informer
+mardi
+terreur
+étonner
+aimer
+tricot
+entrer
+consentir
+carrière
+aérer
+réaliser
+régiment
+renverser
+foire
+immédiatement
+chien
+accompagner
+traitement
+inondation
+combien
+épargne
+détruire
+faible
+champ
+aigu
+arranger
+monument
+baptême
+punition
+abandonner
+rez-de-chaussée
+troupeau
+sale
+rien
+afin
+famille
+agitation
+tabac
+coupe
+fillette
+sud
+carton
+file
+habituer
+triste
+catholique
+sévèrement
+permission
+match
+retentir
+fabriquer
+communal
+défunt
+rare
+remporter
+jour
+période
+sec
+labeur
+lenteur
+débattre
+montant
+bouger
+joie
+sac
+demeurer
+muraille
+sage
+facilement
+bas
+abîme
+attentivement
+tuyau
+munir
+tricoter
+raison
+nègre
+morne
+accueil
+exquis
+lisse
+apporter
+bise
+emballer
+examiner
+américain
+réformer
+admettre
+servante
+droite
+occasion
+église
+éléphant
+garniture
+établir
+récolter
+hésiter
+avance
+compassion
+égard
+sensible
+emplacement
+montrer
+docteur
+retourner
+comme
+acheter
+étincelle
+pont
+zèle
+déterminer
+continuer
+vieillesse
+attribuer
+enfoncer
+partager
+course
+rond
+trancher
+tourmenter
+ravissant
+migrateur
+odorant
+s'empresser
+malheureux
+dimanche
+barreau
+cependant
+drap
+haine
+importer
+attachement
+sacré
+croire
+discuter
+plumier
+bouton
+araignée
+romain
+groseillier
+diminuer
+convertir
+saisir
+interroger
+garder
+atelier
+respiration
+chaudement
+distribution
+collège
+société
+tromper
+roue
+réfugier
+patin
+remuer
+mignon
+corbeau
+statue
+perdrix
+croiser
+cygne
+exciter
+peu
+nager
+remords
+découverte
+demande
+paquet
+perche
+meuble
+pis
+ferme
+froment
+symbole
+tellement
+examen
+sable
+art
+rattraper
+charbon
+gonfler
+monde
+correspondance
+soumettre
+entendre
+cerise
+entraîner
+misérable
+admiration
+imprimer
+établissement
+brumeux
+bureau
+crèche
+tirelire
+infirme
+fils
+sinon
+mille
+oui
+charrette
+troisième
+viser
+arrière
+empressement
+péché
+acheminer
+grêle
+coton
+extraire
+maintenir
+chacun
+placer
+avancer
+soutenir
+preuve
+réjouir
+provoquer
+couvercle
+tulipe
+étourdi
+postal
+dépenser
+dater
+produire
+percer
+reprocher
+émouvoir
+cache-cache
+lait
+mémoire
+bonsoir
+étaler
+volontiers
+ouate
+tigre
+naturellement
+davantage
+richesse
+avaler
+brèche
+serrer
+conformément
+paisiblement
+marchandise
+vigueur
+caisse
+darder
+principalement
+racine
+cueillir
+bouquet
+ruine
+baptiser
+épouvantable
+cadran
+arriver
+aboyer
+rôder
+reconnaître
+chaussure
+apôtre
+attendre
+incident
+violent
+fromage
+muscle
+lutte
+pratique
+ange
+propreté
+studieux
+malle
+bossu
+femme
+repos
+reconduire
+spectateur
+accord
+observation
+grouper
+ruisseler
+figure
+charmant
+vitesse
+époux
+familial
+âme
+honorer
+enterrement
+monnaie
+éclabousser
+sapin
+désespérer
+juger
+opposer
+disputer
+menu
+arbuste
+lot
+bourrasque
+supérieur
+puissance
+cuivre
+payer
+papillon
+échantillon
+pièce
+composer
+incendie
+parcourir
+patrie
+calcul
+apprécier
+timide
+orage
+labourer
+appel
+vierge
+chasse
+recours
+embrasser
+salle
+cadre
+voile
+million
+moitié
+feuillage
+haut
+puits
+augmenter
+juste
+derrière
+blessure
+midi
+calendrier
+clair
+hiver
+fleur
+magique
+touffu
+violette
+libérer
+caractère
+choix
+plumage
+remonter
+étonnement
+impatiemment
+fixer
+proposition
+banc
+trois
+irriter
+coffre
+regret
+brise
+divin
+endormir
+précéder
+allée
+noisette
+tourner
+carotte
+mère
+boulevard
+faire
+règle
+témoin
+rame
+affectionner
+sans
+adresse
+chant
+cou
+hermine
+tordre
+chevet
+panier
+fleuve
+argent
+silence
+manquer
+bourgeois
+siège
+replier
+masse
+cime
+dépens
+excuser
+actuel
+mal
+apercevoir
+tribunal
+renouveau
+domicile
+abeille
+cher
+dépêcher
+malheur
+condition
+riz
+passion
+sermon
+pointe
+arrivée
+impatient
+sérieusement
+brave
+soif
+joueur
+muguet
+carrousel
+accueillir
+continuel
+contraire
+dicter
+vanter
+régulier
+sain
+encrier
+vallée
+canne
+tendrement
+imprudent
+décéder
+précipiter
+boule
+portefeuille
+réparer
+universel
+pâtisserie
+démarche
+neiger
+souriant
+branche
+narcisse
+étrange
+eau
+clown
+parrain
+jeudi
+février
+moudre
+fil
+affaire
+salon
+sauvage
+rôti
+ouverture
+poudre
+repasser
+perroquet
+mare
+rameau
+extrémité
+disparition
+réception
+coudre
+pardonner
+central
+observer
+dépouiller
+celle-ci
+absolument
+amitié
+inférieur
+tâche
+profit
+illustre
+honorable
+assembler
+sergent
+faciliter
+pupitre
+politique
+changer
+basse-cour
+spacieux
+officier
+délaisser
+exemple
+trouer
+accuser
+descente
+beau
+chaîne
+saison
+vérifier
+deuxième
+femelle
+abîmer
+tristement
+portrait
+entrouvrir
+défenseur
+redouter
+représenter
+parages
+endosser
+excellent
+bénédiction
+combattre
+récréation
+coup
+horrible
+ramage
+constater
+képi
+paroisse
+tranquille
+décider
+rencontre
+broyer
+maigre
+paire
+ombre
+matière
+obscurité
+scène
+envier
+chanson
+signal
+exprimer
+monotone
+s'absenter
+septembre
+utile
+enrichir
+matin
+catéchisme
+aumône
+marron
+taille
+canot
+projet
+parfum
+congé
+imposer
+départ
+mars
+vitre
+sucre
+semer
+coin
+tartine
+sommeil
+infiniment
+raccommoder
+hypocrite
+intérêt
+tournoyer
+prendre
+adoucir
+averse
+monseigneur
+pompier
+givre
+méchant
+stupéfaction
+aboutir
+genêt
+orange
+malette
+hangar
+ordonner
+crainte
+fiancé
+épi
+clarté
+meilleur
+prêcher
+brochure
+gagner
+calme
+sur
+hier
+dangereux
+pois
+défaire
+convenir
+indispensable
+vainqueur
+fabrication
+prière
+prisonnier
+aîné
+gigantesque
+vente
+s'évanouir
+envoyer
+charger
+pinceau
+pousser
+retard
+autant
+distrait
+arrondir
+simplicité
+pâle
+assez
+céder
+difficulté
+généreux
+perdre
+mousse
+franc
+redescendre
+siècle
+contribuer
+excursion
+marchander
+hygiène
+délivrer
+esprit
+dix
+objet
+néanmoins
+besoin
+diable
+verser
+éprouver
+marcher
+nul
+transport
+vrai
+dame
+exaucer
+présence
+quatorze
+étroit
+épanouir
+commandant
+force
+adroit
+rouiller
+réveiller
+conduite
+mélancolique
+communion
+sursaut
+borne
+conquérir
+clocher
+étoffe
+heurter
+confiture
+épais
+gris
+honteux
+quartier
+dépasser
+ronde
+distribuer
+facteur
+asseoir
+rencontrer
+méthode
+absence
+rappeler
+constituer
+sort
+enfant
+grossir
+mât
+seau
+offenser
+fatal
+honneur
+hérissé
+prêter
+différence
+soulager
+traverser
+pourvoir
+seconde
+adorer
+sévir
+odeur
+colorer
+malheureusement
+charitable
+nu
+mouvoir
+agacer
+loisir
+supposer
+oh
+tricolore
+appui
+approcher
+institut
+bûcheron
+laver
+religion
+respectueux
+avoir
+chercher
+flamber
+savourer
+tortue
+déclarer
+éclair
+honnête
+temps
+danser
+brusque
+même
+particulièrement
+humeur
+durant
+plupart
+réussir
+barrage
+maman
+cacher
+cheval
+absolu
+modestie
+nuisible
+pencher
+tour
+peupler
+plateau
+grippe
+quitter
+lever
+régler
+coffret
+celui-ci
+obéissant
+délice
+sortie
+vivement
+vin
+osier
+enthousiasme
+firmament
+déplacer
+prairie
+enterrer
+costume
+cave
+acide
+trajet
+manière
+discours
+dizaine
+bandit
+empêcher
+poêle
+profession
+mélodieux
+relativement
+cheminée
+soupirer
+convaincre
+faucher
+adopter
+peuple
+été
+méditer
+pénétrer
+passer
+rassembler
+trace
+façade
+façon
+attaque
+apparence
+solitaire
+voici
+échelle
+époque
+immense
+fâcheux
+ici
+principal
+étang
+bonnet
+kilogramme
+français
+mendiant
+aborder
+automne
+lunette
+marchand
+doigt
+air
+conséquence
+acheteur
+haie
+bâton
+probablement
+digne
+bleuet
+sécher
+enseigner
+mêler
+tige
+interruption
+coûter
+noix
+poirier
+os
+redire
+âgé
+vilain
+contact
+pourpre
+douceur
+lac
+ruiner
+aisé
+contenir
+lundi
+langage
+espérance
+direction
+température
+brique
+sûr
+cinéma
+raser
+sel
+primevère
+douze
+charlatan
+remarquer
+entreprendre
+parfait
+mer
+regarder
+bientôt
+piquer
+dérouler
+économie
+balayer
+autrui
+homme
+évêque
+sitôt
+échapper
+minuit
+vingt
+orner
+défaut
+colonial
+couvrir
+cuir
+chaud
+canard
+rafraîchir
+couper
+harmonieux
+poids
+bourse
+purifier
+différent
+amusant
+très
+marronnier
+pensionnaire
+bienfaisant
+médaille
+navire
+comte
+oeil
+interrompre
+miette
+charrue
+mauve
+violet
+guêpe
+endroit
+souverain
+former
+administration
+aide
+bizarre
+désigner
+tonnerre
+hache
+aspirer
+bougie
+rêve
+franchir
+bain
+regard
+journée
+cerisier
+pendre
+touffe
+attente
+ténèbres
+prodigieux
+songer
+forme
+révéler
+verdure
+monsieur
+intérieur
+cri
+plein
+appétissant
+soldat
+sévère
+écluse
+local
+beaucoup
+genou
+coq
+saule
+race
+géranium
+roi
+souiller
+pomme
+privation
+botte
+large
+bâtir
+rejoindre
+thé
+question
+devoir
+terre
+client
+noble
+sombre
+installer
+valet
+habileté
+presser
+risque
+lit
+mirer
+remerciement
+rocher
+élargir
+désastre
+impossible
+misère
+moment
+coutume
+loin
+bon
+frayeur
+cloche
+pâlir
+cadavre
+honte
+carreau
+jaillir
+souvenir
+vulgaire
+hôtel
+dispenser
+inspecteur
+prévenir
+élève
+mieux
+treize
+profond
+suc
+parer
+pâtre
+déployer
+laine
+condisciple
+lys
+influence
+déchirer
+après-midi
+inonder
+occasionner
+surveiller
+déranger
+disposer
+transformation
+verdâtre
+giboulée
+piano
+face
+allure
+écrit
+ton
+matinal
+remise
+vent
+paresseux
+flotter
+grave
+creux
+autrement
+plainte
+ruche
+hêtre
+louange
+alerte
+récompense
+intervenir
+rossignol
+visage
+six
+coude
+visible
+parquet
+mètre
+perpétuel
+pavé
+solliciter
+forestier
+exhaler
+don
+directement
+équipe
+choeur
+témoigner
+faveur
+différer
+friandise
+ennuyeux
+mince
+bois
+gaufre
+tôt
+bond
+tronc
+année
+tenue
+emplir
+rosier
+moustache
+bonjour
+pied
+humanité
+injure
+roux
+gravir
+descendre
+domaine
+pêcher
+souple
+voisinage
+ça
+faim
+reposer
+premier
+élancer
+argenter
+liste
+égal
+propriétaire
+subitement
+salade
+sincèrement
+serviable
+avenir
+concerner
+arbre
+déménager
+correction
+car
+cabine
+griffer
+poing
+aiguiser
+corps
+ligue
+nommer
+sol
+destination
+lutin
+métal
+assidu
+voyageur
+attacher
+successivement
+transparent
+abondamment
+cercle
+bijou
+sifflement
+encourager
+souffler
+machine
+atmosphère
+parfois
+royal
+ramener
+canif
+nez
+détourner
+boeuf
+soigner
+chèvre
+dans
+auteur
+décourager
+aveugle
+fréquemment
+soigneux
+douleur
+langue
+sentir
+assurer
+tracer
+mystérieux
+bâtiment
+lent
+certain
+nombre
+flûte
+taillis
+désagréable
+bourdonnement
+mensonge
+cérémonie
+larme
+constamment
+importance
+allumer
+fée
+bouder
+poste
+chat
+récompenser
+ciel
+ivoire
+amener
+vénérer
+quoique
+autre
+cultivateur
+ventre
+filet
+s'enfuir
+faux
+halte
+embarras
+brume
+vigoureux
+escalader
+dissiper
+miracle
+brouter
+gratter
+exact
+légèrement
+instruction
+photographie
+exprès
+classe
+cour
+compte
+longueur
+laboureur
+protecteur
+fonction
+sou
+s'agenouiller
+favorable
+dire
+loger
+toilette
+balançoire
+résolution
+jonquille
+dénudé
+nom
+satisfaire
+plutôt
+brûler
+court
+pour
+corolle
+hameau
+subir
+majestueux
+chagrin
+également
+habitant
+cirer
+jaune
+merveilleusement
+émerveiller
+campagne
+réflexion
+relire
+affection
+enchanté
+distraire
+boulangerie
+considérable
+mystère
+ordinaire
+soulagement
+coeur
+lumière
+glissant
+interpeller
+neige
+silencieux
+refrain
+ouvrir
+récolte
+lune
+éclatant
+jaloux
+besogne
+tête
+surprendre
+rapidement
+vague
+extraordinaire
+personnage
+foyer
+bouteille
+grive
+tante
+remarque
+tout
+matériel
+espèce
+finalement
+garçon
+betterave
+débiter
+voir
+rhume
+gymnastique
+multicolore
+serrure
+bien
+craquer
+suivant
+démolir
+guère
+entièrement
+poitrine
+conter
+bruyamment
+écraser
+ballon
+effacer
+délicieux
+courage
+retardataire
+contenter
+ennui
+rosée
+gibier
+nappe
+carabine
+déjeuner
+centimètre
+tram
+chef-d'oeuvre
+photographier
+sept
+sembler
+toucher
+exiger
+lasser
+présenter
+surgir
+suave
+grincer
+cuisine
+contre
+fermier
+calice
+verger
+arracher
+quand
+fleurette
+approche
+remplacer
+souris
+pendant
+non
+place
+civil
+hélas
+destiner
+bibliothèque
+éternel
+reprendre
+consister
+élan
+imprévu
+encadrer
+lors
+mot
+liquide
+désert
+gazouillement
+personne
+attarder
+boueux
+fouiller
+entonner
+devenir
+double
+abuser
+simple
+frissonner
+désordre
+voltiger
+communiquer
+instruire
+chanteur
+fourmi
+banquier
+bonhomme
+fait
+rendez-vous
+angle
+écureuil
+remercier
+pin
+renseignement
+pointu
+dernièrement
+cheveu
+rat
+conte
+électrique
+dépôt
+octobre
+promesse
+boulanger
+ensemble
+émotion
+rendre
+ordre
+poète
+couloir
+plaie
+actuellement
+tramway
+fraise
+arbitre
+aller
+conclure
+auprès
+auberge
+abriter
+travailler
+volet
+bataille
+frère
+occupation
+sérieux
+assaut
+rater
+intéressant
+instrument
+intense
+chapelet
+divers
+frais
+couverture
+neuf
+lui
+mélange
+soie
+plaire
+grenier
+désoler
+soutien
+paysan
+vaincre
+tambour
+visiter
+compliquer
+prime
+maîtresse
+sanglot
+ravin
+printemps
+certainement
+éveiller
+remplir
+état
+impatience
+déplaire
+environ
+confiance
+banane
+seize
+moyenne
+vider
+velouté
+pensée
+refroidir
+brindille
+morale
+profiter
+pot
+assister
+téléphoner
+poser
+affairé
+claquer
+entier
+dieu
+suite
+quinzaine
+plaque
+fléau
+solennel
+vitrine
+rigoureux
+leçon
+adieu
+détour
+collection
+gai
+lieue
+construire
+demain
+sincérité
+âge
+horloge
+avis
+apparaître
+trompette
+lugubre
+rapport
+abondance
+anneau
+programme
+viande
+pêcheur
+coquet
+lamentable
+lentement
+inspirer
+ronger
+témoignage
+lapin
+épouser
+poussin
+longtemps
+entretenir
+lécher
+pittoresque
+terrier
+chaussée
+murmure
+comment
+procéder
+répandre
+tissu
+cahier
+houille
+corne
+entrevoir
+jamais
+véritable
+menacer
+éclairer
+laitier
+reparaître
+casser
+pêche
+palais
+marquer
+tinter
+humidité
+compagnon
+tranquillement
+acclamation
+commencer
+représentant
+exemplaire
+étrennes
+bande
+prix
+facilité
+rapiécer
+conversation
+multiple
+embarrasser
+pompe
+prompt
+tirer
+comparaison
+vert
+compagnie
+gros
+prise
+yeux
+vice
+cerf
+ligne
+poisson
+choisir
+excellence
+troubler
+hôte
+absent
+camp
+mode
+reine
+près
+vieux
+trotter
+désobéir
+justice
+proche
+glissoire
+heureux
+route
+étendue
+jouet
+affaiblir
+prison
+poireau
+veau
+aile
+mûrir
+militaire
+moral
+boiteux
+tristesse
+chaumière
+préparer
+partie
+éviter
+rider
+secours
+clairière
+existence
+vêpres
+rétablir
+grenouille
+bagage
+construction
+représentation
+après
+sonner
+balcon
+torrent
+revoir
+montagne
+réfléchir
+aider
+posséder
+bienfait
+expérience
+feu
+ivresse
+naturel
+gentiment
+épouvanter
+développer
+récit
+souffrance
+regretter
+combattant
+grand-père
+siffler
+entrain
+superbe
+station
+moteur
+nourriture
+ajouter
+duquel
+presque
+beurre
+atteler
+guide
+cendre
+rentrer
+signe
+pierre
+numéro
+effet
+maternel
+promenade
+travers
+chapelle
+énergie
+ouvrier
+grand-mère
+humain
+milieu
+gens
+tacheter
+ruisseau
+leur
+vase
+précisément
+désir
+singe
+diviser
+soupçonner
+terrible
+curieux
+déposer
+traîneau
+culture
+mercredi
+goal
+chez
+magasin
+association
+chelou
+teuf
+ordinateur
+tablette
+smartphone
+paheko
+association
+asso
+comptabilité
+compta
+gestion
\ No newline at end of file
diff --git a/src/include/data/schema.sql b/src/include/data/schema.sql
new file mode 120000
index 0000000..028e316
--- /dev/null
+++ b/src/include/data/schema.sql
@@ -0,0 +1 @@
+../migrations/1.3/schema.sql
\ No newline at end of file
diff --git a/src/include/data/users_fields_presets.ini b/src/include/data/users_fields_presets.ini
new file mode 100644
index 0000000..e35fa53
--- /dev/null
+++ b/src/include/data/users_fields_presets.ini
@@ -0,0 +1,162 @@
+; Ce fichier contient la configuration par défaut des champs des fiches membres.
+; La configuration est ensuite enregistrée au format INI dans la table
+; config de la base de données.
+;
+; Syntaxe :
+;
+; [nom_du_champ] ; Nom unique du champ, ne peut contenir que des lettres et des tirets bas
+; type = text
+; label = "Super champ trop cool"
+; required = true
+;
+; Description des options possibles pour chaque champ :
+;
+; type: (défaut: text) OBLIGATOIRE
+; certains types gérés par de HTML5 :
+; text, number, date, datetime, url, email, checkbox, file, password, tel
+; champs spécifiques :
+; - country = sélecteur de pays
+; - textarea = texte multi lignes
+; - multiple = multiples cases à cocher (jusqu'à 32, binaire)
+; - select = un choix parmis plusieurs
+; label: OBLIGATOIRE
+; Titre du champ
+; help:
+; Texte d'aide sur les fiches membres
+; options[]:
+; pour définir les options d'un champ de type select ou multiple
+; required:
+; true = obligatoire, la fiche membre ne pourra être enregistrée si ce champ est vide
+; false = facultatif (défaut)
+; user_access_level:
+; 2 = modifiable par le membre
+; 1 = visible par le membre (défaut)
+; 0 = visible uniquement par un admin
+; management_access_level:
+; 9 = visible par les membres ayant accès en administration
+; 2 = visible uniquement par les personnes ayant accès en écriture aux membres
+; 1 = visible par les personnes ayant accès en lecture aux membres
+; list_table: 'true' si doit être listé par défaut dans la liste des membres
+; sql: SQL code for GENERATED columns
+; depends[]: list of fields that need to be existing in order to install this field
+
+[numero]
+type = number
+label = "Numéro de membre"
+required = true
+list_table = true
+default = true
+
+[pronom]
+type = "select"
+label = "Pronom"
+required = false
+default = false
+list_table = true
+options[] = "elle"
+options[] = "il"
+options[] = "iel"
+install_help = "Pour identifier la personne par rapport à son genre"
+
+[nom]
+type = text
+label = "Nom & prénom"
+required = true
+list_table = true
+default = true
+
+[email]
+; ce champ est facultatif et de type 'email'
+type = email
+label = "Adresse E-Mail"
+required = false
+default = true
+
+[password]
+; ce champ est obligatoirement présent et de type 'password'
+; le titre ne peut être modifié
+label = "Mot de passe"
+type = password
+required = false
+default = true
+
+[adresse]
+type = textarea
+label = "Adresse postale"
+help = "Indiquer ici le numéro, le type de voie, etc."
+default = true
+
+[code_postal]
+type = text
+label = "Code postal"
+default = true
+
+[ville]
+type = text
+label = "Ville"
+list_table = true
+default = true
+
+[pays]
+type = country
+label = "Pays"
+default = false
+
+[telephone]
+type = tel
+label = "Numéro de téléphone"
+default = true
+
+[lettre_infos]
+type = checkbox
+label = "Inscription à la lettre d'information"
+install_help = "Case à cocher pour indiquer que le membre souhaite recevoir la lettre d'information de l'association"
+default = true
+
+[annee_naissance]
+type = year
+label = "Année de naissance"
+install_help = "Recommandé, plutôt que la date de naissance qui est une information très sensible."
+default = false
+
+[age_annee]
+type = virtual
+label = "Âge"
+install_help = "Déterminé en utilisant l'année de naissance"
+depends[] = annee_naissance
+default = false
+sql = "strftime('%Y', date('now')) - annee_naissance"
+
+[date_naissance]
+type = date
+label = "Date de naissance complète"
+default = false
+install_help = "Attention, cette information est très sensible, il est déconseillé par le RGPD de la demander aux membres. Il est préférable de demander seulement l'année de naissance."
+
+[age_date]
+type = virtual
+label = "Âge"
+install_help = "Déterminé en utilisant la date de naissance"
+depends[] = date_naissance
+default = false
+sql = "CAST(strftime('%Y.%m%d', date('now')) - strftime('%Y.%m%d', date_naissance) as int)"
+
+[photo]
+type = file
+label = "Photo"
+default = false
+
+[date_inscription]
+type = date
+label = "Date d'inscription"
+help = "Date à laquelle le membre a été inscrit à l'association pour la première fois"
+default = true
+default_value = "NOW()"
+
+[anciennete]
+type = virtual
+label = "Ancienneté"
+install_help = "Nombre d'années depuis la date d'inscription"
+depends[] = date_inscription
+default = false
+sql = "CAST(strftime('%Y.%m%d', date('now')) - strftime('%Y.%m%d', date_inscription) as INT)"
diff --git a/src/include/init.php b/src/include/init.php
new file mode 100644
index 0000000..ab33891
--- /dev/null
+++ b/src/include/init.php
@@ -0,0 +1,423 @@
+ %s\n\n", $title, $info, $url);
+ }
+ else {
+ printf('%s %s
', $title, $url, $info);
+ }
+
+ exit(1);
+}
+
+if (!defined('Paheko\WWW_URL') && $host !== null) {
+ define('Paheko\WWW_URL', \KD2\HTTP::getScheme() . '://' . $host . WWW_URI);
+}
+
+static $default_config = [
+ 'CACHE_ROOT' => DATA_ROOT . '/cache',
+ 'SHARED_CACHE_ROOT' => DATA_ROOT . '/cache/shared',
+ 'WEB_CACHE_ROOT' => DATA_ROOT . '/cache/web/%host%',
+ 'DB_FILE' => DATA_ROOT . '/association.sqlite',
+ 'DB_SCHEMA' => ROOT . '/include/data/schema.sql',
+ 'PLUGINS_ROOT' => DATA_ROOT . '/plugins',
+ 'ALLOW_MODIFIED_IMPORT' => true,
+ 'SHOW_ERRORS' => true,
+ 'MAIL_ERRORS' => false,
+ 'ERRORS_REPORT_URL' => null,
+ 'REPORT_USER_EXCEPTIONS' => 0,
+ 'ENABLE_TECH_DETAILS' => true,
+ 'HTTP_LOG_FILE' => null,
+ 'ENABLE_UPGRADES' => true,
+ 'USE_CRON' => false,
+ 'ENABLE_XSENDFILE' => false,
+ 'DISABLE_EMAIL' => false,
+ 'SMTP_HOST' => false,
+ 'SMTP_USER' => null,
+ 'SMTP_PASSWORD' => null,
+ 'SMTP_PORT' => 587,
+ 'SMTP_SECURITY' => 'STARTTLS',
+ 'SMTP_HELO_HOSTNAME' => null,
+ 'MAIL_RETURN_PATH' => null,
+ 'MAIL_BOUNCE_PASSWORD' => null,
+ 'MAIL_SENDER' => null,
+ 'ADMIN_URL' => WWW_URL . 'admin/',
+ 'NTP_SERVER' => 'fr.pool.ntp.org',
+ 'ADMIN_COLOR1' => '#20787a',
+ 'ADMIN_COLOR2' => '#85b9ba',
+ 'ADMIN_BACKGROUND_IMAGE' => WWW_URL . 'admin/static/bg.png',
+ 'FORCE_CUSTOM_COLORS' => false,
+ 'DISABLE_INSTALL_FORM' => false,
+ 'FILE_STORAGE_BACKEND' => 'SQLite',
+ 'FILE_STORAGE_CONFIG' => null,
+ 'FILE_STORAGE_QUOTA' => null,
+ 'FILE_VERSIONING_POLICY' => null,
+ 'FILE_VERSIONING_MAX_SIZE' => null,
+ 'API_USER' => null,
+ 'API_PASSWORD' => null,
+ 'PDF_COMMAND' => 'auto',
+ 'PDF_USAGE_LOG' => null,
+ 'PDFTOTEXT_COMMAND' => null,
+ 'CALC_CONVERT_COMMAND' => null,
+ 'DOCUMENT_THUMBNAIL_COMMANDS' => null,
+ 'SQL_DEBUG' => null,
+ 'SYSTEM_SIGNALS' => [],
+ 'LOCAL_LOGIN' => null,
+ 'LEGAL_HOSTING_DETAILS' => null,
+ 'ALERT_MESSAGE' => null,
+ 'DISABLE_INSTALL_PING' => false,
+ 'WOPI_DISCOVERY_URL' => null,
+ 'SQLITE_JOURNAL_MODE' => 'TRUNCATE',
+];
+
+foreach ($default_config as $const => $value)
+{
+ $const = sprintf('Paheko\\%s', $const);
+
+ if (!defined($const))
+ {
+ define($const, $value);
+ }
+}
+
+// Check SMTP_SECURITY value
+if (SMTP_SECURITY) {
+ $const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);
+
+ if (!defined($const)) {
+ throw new \LogicException('Configuration: SMTP_SECURITY n\'a pas une valeur reconnue. Valeurs acceptées: STARTTLS, TLS, SSL, NONE.');
+ }
+}
+
+// Used for private files, just in case WWW_URL is not the same domain as ADMIN_URL
+define('Paheko\BASE_URL', str_replace('/admin/', '/', ADMIN_URL));
+define('Paheko\ADMIN_URI', preg_replace('!(^https?://[^/]+)!', '', ADMIN_URL));
+
+const HELP_URL = 'https://paheko.cloud/aide?from=%s';
+const HELP_PATTERN_URL = 'https://paheko.cloud/%s';
+const WEBSITE = 'https://fossil.kd2.org/paheko/';
+const PING_URL = 'https://paheko.cloud/ping/';
+const PLUGINS_URL = 'https://paheko.cloud/plugins/list.json';
+
+const USER_TEMPLATES_CACHE_ROOT = CACHE_ROOT . '/utemplates';
+const STATIC_CACHE_ROOT = CACHE_ROOT . '/static';
+const SHARED_USER_TEMPLATES_CACHE_ROOT = SHARED_CACHE_ROOT . '/utemplates';
+const SMARTYER_CACHE_ROOT = SHARED_CACHE_ROOT . '/compiled';
+
+// Used to get around some providers misconfiguration issues
+if (isset($_SERVER['HTTP_X_OVHREQUEST_ID'])) {
+ define('Paheko\HOSTING_PROVIDER', 'OVH');
+}
+else {
+ define('Paheko\HOSTING_PROVIDER', null);
+}
+
+// PHP devrait être assez intelligent pour chopper la TZ système mais nan
+// il sait pas faire (sauf sur Debian qui a le bon patch pour ça), donc pour
+// éviter le message d'erreur à la con on définit une timezone par défaut
+// Pour utiliser une autre timezone, il suffit de définir date.timezone dans
+// un .htaccess ou dans CONFIG_FILE
+if (!ini_get('date.timezone') || ini_get('date.timezone') === 'UTC') {
+ if (($tz = @date_default_timezone_get()) && $tz !== 'UTC') {
+ ini_set('date.timezone', $tz);
+ }
+ else {
+ ini_set('date.timezone', 'Europe/Paris');
+ }
+}
+
+class ValidationException extends UserException
+{
+}
+
+class APIException extends \LogicException
+{
+}
+
+// activer le gestionnaire d'erreurs/exceptions
+ErrorManager::setEnvironment(SHOW_ERRORS ? ErrorManager::DEVELOPMENT : ErrorManager::PRODUCTION | ErrorManager::CLI_DEVELOPMENT);
+ErrorManager::setLogFile(DATA_ROOT . '/error.log');
+
+// activer l'envoi de mails si besoin est
+if (MAIL_ERRORS) {
+ ErrorManager::setEmail(MAIL_ERRORS);
+}
+
+if (ERRORS_REPORT_URL) {
+ ErrorManager::setRemoteReporting(ERRORS_REPORT_URL, true);
+}
+
+ErrorManager::setContext([
+ 'root_directory' => ROOT,
+ 'paheko_data_root' => DATA_ROOT,
+ 'paheko_version' => paheko_version(),
+]);
+
+
+ErrorManager::setProductionErrorTemplate(defined('Paheko\ERRORS_TEMPLATE') && ERRORS_TEMPLATE ? ERRORS_TEMPLATE : 'Erreur interne
+ Erreur interne Désolé mais le serveur a rencontré une erreur interne
+ et ne peut répondre à votre requête. Merci de ré-essayer plus tard.
+ Si vous suspectez un bug dans Paheko, vous pouvez suivre
+ ces instructions
+ pour le rapporter.
+ Un-e responsable a été notifié-e et cette erreur sera corrigée dès que possible.
+ L\'erreur a été enregistrée dans les journaux système (error.log) sous la référence : {$ref}
+ ← Retour à la page d\'accueil
+ ');
+
+ErrorManager::setHtmlHeader('
+ \__/ (xx) //||\\\\
+
+');
+
+function user_error(UserException $e)
+{
+ if (REPORT_USER_EXCEPTIONS > 0) {
+ \Paheko\Form::reportUserException($e);
+ }
+
+ if (PHP_SAPI == 'cli')
+ {
+ echo $e->getMessage();
+ }
+ else
+ {
+ // Flush any previous output, such as module HTML code etc.
+ @ob_end_clean();
+
+ if ($e->getCode() >= 400) {
+ http_response_code($e->getCode());
+ }
+
+ // Don't use Template class as there might be an error there due do the context (eg. install/upgrade)
+ $tpl = new \KD2\Smartyer(ROOT . '/templates/error.tpl');
+ $tpl->setCompiledDir(SMARTYER_CACHE_ROOT);
+
+ $tpl->assign('error', $e->getMessage());
+ $tpl->assign('html_error', $e->getHTMLMessage());
+ $tpl->assign('admin_url', ADMIN_URL);
+ $tpl->display();
+ }
+
+ exit;
+}
+
+if (REPORT_USER_EXCEPTIONS < 2) {
+ // Message d'erreur simple pour les erreurs de l'utilisateur
+ ErrorManager::setCustomExceptionHandler('\Paheko\UserException', '\Paheko\user_error');
+}
+
+// Clé secrète utilisée pour chiffrer les tokens CSRF etc.
+if (!defined('Paheko\SECRET_KEY'))
+{
+ $key = base64_encode(random_bytes(64));
+ Install::setLocalConfig('SECRET_KEY', $key);
+ define('Paheko\SECRET_KEY', $key);
+}
+
+// Intégration du secret pour les tokens CSRF
+Form::tokenSetSecret(SECRET_KEY);
+
+EntityManager::setGlobalDB(DB::getInstance());
+
+Translate::setLocale('fr_FR');
+
+// This is specific to OVH and other hosting providers who don't set up their servers properly
+// see https://www.prestashop.com/forums/topic/393496-prestashop-16-webservice-authentification-on-ovh/
+if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) && !empty($_SERVER['HTTP_AUTHORIZATION'])) {
+ @list($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) = explode(':', base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6)));
+}
+
+/*
+ * Vérifications pour enclencher le processus d'installation ou de mise à jour
+ */
+
+if (!defined('Paheko\INSTALL_PROCESS'))
+{
+ $exists = file_exists(DB_FILE);
+
+ if (!$exists) {
+ if (in_array('install.php', get_included_files())) {
+ die('Erreur de redirection en boucle : problème de configuration ?');
+ }
+
+ Utils::redirect(ADMIN_URL . 'install.php');
+ }
+
+ $v = DB::getInstance()->version();
+
+ if (version_compare($v, paheko_version(), '<')) {
+ if (!empty($_POST)) {
+ http_response_code(500);
+ readfile(ROOT . '/templates/static/upgrade_post.html');
+ exit;
+ }
+
+ Utils::redirect(ADMIN_URL . 'upgrade.php');
+ }
+}
diff --git a/src/include/lib/KD2/Brindille.php b/src/include/lib/KD2/Brindille.php
new file mode 100644
index 0000000..1d6a919
--- /dev/null
+++ b/src/include/lib/KD2/Brindille.php
@@ -0,0 +1,1029 @@
+ '(?:>=|<=|===|!==|==|!=|>|<|!)',
+ self::T_ANDOR => '(?:&&|\|\|)',
+ //self::T_OPEN_PARENTHESIS => '\(',
+ //self::T_CLOSE_PARENTHESIS => '\)',
+ self::T_VAR => self::RE_VARIABLE,
+ self::T_SCALAR => self::RE_SCALAR,
+ self::T_SPACE => self::RE_SPACE,
+ ];
+
+ const TOK_VAR_BLOCK = [
+ self::T_VAR => self::RE_VARIABLE,
+ self::T_PARAMS => self::RE_PARAMETERS,
+ self::T_SPACE => self::RE_SPACE,
+ ];
+
+ const PARSE_PATTERN = '%
+ # start of block
+ \{\{
+ # ignore spaces at start of block
+ \s*
+ # capture block type/name
+ (if|else\s?if|else|endif|literal|/literal|
+ # sections, variables, functions, MUST have a valid name
+ [:$#/]([\w._]+)|
+ # quoted strings can be chained to modifiers as well
+ [\'"]
+ # end of capture group
+ )
+ # Arguments etc.
+ ((?!\}\}).*?)?
+ # end of block
+ \}\}
+ # regexp modifiers
+ %sx';
+
+ public array $_stack = [];
+
+ protected array $_sections = [];
+
+ // Escape is the only mandatory modifier
+ protected array $_modifiers = ['escape' => 'htmlspecialchars'];
+ protected array $_modifiers_with_instance = [];
+ protected array $_functions = [];
+ protected array $_blocks = [];
+
+ public array $_variables = [0 => []];
+
+ public function registerDefaults()
+ {
+ $this->registerFunction('assign', [self::class, '__assign']);
+
+ // This is because PHP 8.1 sucks (string functions no longer accept NULL)
+ // so we need to force NULLs as strings
+ $this->registerModifier('escape', function ($str) {
+ if (is_scalar($str) || is_null($str)) {
+ return htmlspecialchars((string)$str);
+ }
+ else {
+ return 'Error: cannot escape this value! '
+ . htmlspecialchars(print_r($str, true)) . ' ';
+ }
+ });
+
+ $this->registerModifier('args', 'sprintf');
+ $this->registerModifier('nl2br', 'nl2br');
+ $this->registerModifier('strip_tags', 'strip_tags');
+ $this->registerModifier('count', function ($var) {
+ if (is_countable($var)) {
+ return count($var);
+ }
+
+ return null;
+ });
+ $this->registerModifier('cat', function() { return implode('', func_get_args()); });
+
+ $this->registerModifier('date_format', function ($date, $format = '%d/%m/%Y %H:%M') {
+ $tz = null;
+
+ if (is_object($date)) {
+ $tz = $date->getTimezone();
+ $date = $date->getTimestamp();
+ }
+ elseif (!is_int($date) && !(is_string($date) && ctype_digit($date))) {
+ $date = strtotime($date);
+ }
+
+ return Translate::strftime($format, $date);
+ });
+
+ $this->registerSection('foreach', [self::class, '__foreach']);
+ }
+
+ public function assign(string $key, $value, ?int $level = null, bool $throw_on_invalid_name = true): void
+ {
+ if (!preg_match('/^[\w\d_]*$/', $key)) {
+ if ($throw_on_invalid_name) {
+ throw new \InvalidArgumentException('Invalid variable name: ' . $key);
+ }
+
+ // For assign from a section, don't throw an error, just ignore
+
+ return;
+ }
+
+ if (!count($this->_variables)) {
+ $this->_variables = [0 => []];
+ }
+
+ if (null === $level) {
+ $level = count($this->_variables)-1;
+ }
+
+ $this->_variables[$level][$key] = $value;
+ }
+
+ public function assignArray(array $array, ?int $level = null, bool $throw_on_invalid_name = true): void
+ {
+ foreach ($array as $key => $value) {
+ $this->assign($key, $value, $level, $throw_on_invalid_name);
+ }
+ }
+
+ public function checkModifierExists(string $name)
+ {
+ return array_key_exists($name, $this->_modifiers) || array_key_exists($name, $this->_modifiers_with_instance);
+ }
+
+ public function registerModifier(string $name, callable $callback, bool $pass_instance_as_first_argument = false): void
+ {
+ unset($this->_modifiers_with_instance[$name], $this->_modifiers[$name]);
+
+ if ($pass_instance_as_first_argument) {
+ $this->_modifiers_with_instance[$name] = $callback;
+ }
+ else {
+ $this->_modifiers[$name] = $callback;
+ }
+ }
+
+ public function registerSection(string $name, callable $callback): void
+ {
+ $this->_sections[$name] = $callback;
+ }
+
+ public function registerFunction(string $name, callable $callback): void
+ {
+ $this->_functions[$name] = $callback;
+ }
+
+ public function registerCompileBlock(string $name, callable $callback): void
+ {
+ $this->_blocks[$name] = $callback;
+ }
+
+ public function render(string $tpl_code): string
+ {
+ $code = $this->compile($tpl_code);
+
+ try {
+ ob_start();
+
+ eval('?>' . $code);
+
+ return ob_get_clean();
+ }
+ catch (\Throwable $e) {
+ $lines = explode("\n", $code);
+ $code = $lines[$e->getLine()-1] ?? $code;
+ throw new Brindille_Exception(sprintf("[%s] Line %d: %s\n%s", get_class($e), $e->getLine(), $e->getMessage(), $code), 0, $e);
+ }
+ }
+
+ public function compile(string $code): string
+ {
+ $this->_stack = [];
+
+ // Remove PHP tags
+ $code = strtr($code, [
+ '' => '=\'\'?>',
+ '?>' => '=\'?>\'?>'
+ ]);
+
+ $keep_whitespaces = false !== strpos($code, '{{**keep_whitespaces**}}');
+
+ // Remove comments, but do not affect the number of lines
+ $code = preg_replace_callback('/\{\{\*(?:(?!\*\}\}).*?)\*\}\}/s', function ($match) {
+ return '';
+ }, $code);
+
+ $return = preg_replace_callback(self::PARSE_PATTERN, function ($match) use ($code) {
+ $offset = $match[0][1];
+ $line = 1 + substr_count($code, "\n", 0, $offset);
+
+ try {
+ $all = $match[0][0];
+ $start = !empty($match[2][0]) ? substr($match[1][0], 0, 1) : $match[1][0];
+ $name = $match[2][0] ?? $match[1][0];
+ $params = $match[3][0] ?? null;
+
+ return $this->_walk($all, $start, $name, $params, $line);
+ }
+ catch (Brindille_Exception $e) {
+ throw new Brindille_Exception(sprintf('Line %d: %s', $line, $e->getMessage()), 0, $e);
+ }
+ }, $code, -1, $count, PREG_OFFSET_CAPTURE);
+
+ if (count($this->_stack)) {
+ $line = 1 + substr_count($code, "\n");
+ throw new Brindille_Exception(sprintf('Line %d: missing closing tag "%s"', $line, $this->_lastName()));
+ }
+
+ // Remove comments altogether
+ $return = preg_replace('!<\?php /\*.*?\*/ \?>!s', '', $return);
+
+ if ($keep_whitespaces) {
+ $return = str_replace(["\r\n", "\r"], "\n", $return);
+ // Keep whitespaces, but PHP is eating the line break after a closing tag, so double it
+ $return = str_replace("?>\n", "?>\n\n", $return);
+ }
+ else {
+ // Remove whitespaces between PHP logic blocks (not echo blocks)
+ // this is to avoid sending data to browser in logic code, eg. redirects
+ $return = preg_replace('!\s\?>(\s+)<\?php\s!', ' $1 ', $return);
+ }
+
+ return $return;
+ }
+
+ public function get(string $name)
+ {
+ $array =& $this->_variables;
+
+ for ($vars = end($array); key($array) !== null; $vars = prev($array)) {
+ // Dots at the start of a variable name mean: go back X levels in variable stack
+ if (substr($name, 0, 1) == '.') {
+ $name = substr($name, 1);
+ continue;
+ }
+
+ if (array_key_exists($name, $vars)) {
+ return $vars[$name];
+ }
+
+ $found = false;
+
+ if (strstr($name, '.')) {
+ $return = $this->_magic($name, $vars, $found);
+
+ if ($found) {
+ return $return;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public function getAllVariables(): array
+ {
+ $out = [];
+
+ foreach ($this->_variables as $vars) {
+ $out = array_merge($out, $vars);
+ }
+
+ return $out;
+ }
+
+ protected function _magic(string $expr, $var, &$found = null)
+ {
+ $i = 0;
+ $keys = explode('.', $expr);
+
+ while (null !== ($key = array_shift($keys)))
+ {
+ if ($i++ > 20)
+ {
+ // Limit the amount of recusivity we can go through
+ $found = false;
+ return null;
+ }
+
+ if (is_object($var))
+ {
+ // Test for constants
+ if (defined(get_class($var) . '::' . $key))
+ {
+ $found = true;
+ return constant(get_class($var) . '::' . $key);
+ }
+
+ if (!property_exists($var, $key))
+ {
+ $found = false;
+ return null;
+ }
+
+ $var = $var->$key;
+ }
+ elseif (is_array($var))
+ {
+ if (!array_key_exists($key, $var))
+ {
+ $found = false;
+ return null;
+ }
+
+ $var = $var[$key];
+ }
+ }
+
+ $found = true;
+ return $var;
+ }
+
+ public function _push(int $type, ?string $name = null, ?array $params = []): void
+ {
+ $this->_stack[] = func_get_args();
+ }
+
+ public function _pop(): ?array
+ {
+ return array_pop($this->_stack);
+ }
+
+ public function _lastType(): int
+ {
+ return count($this->_stack) ? end($this->_stack)[0] : self::NONE;
+ }
+
+ public function _lastName(): ?string
+ {
+ if ($this->_stack) {
+ return end($this->_stack)[1];
+ }
+
+ return null;
+ }
+
+ protected function _walk(string $all, ?string $start, string $name, ?string $params, int $line): string
+ {
+ if ($name == 'literal') {
+ $this->_push(self::LITERAL, $name);
+ return '';
+ }
+ elseif ($start == '/literal') {
+ if ($this->_lastType() != self::LITERAL) {
+ throw new Brindille_Exception('closing of a literal block that wasn\'t opened');
+ }
+
+ $this->_pop();
+ return '';
+ }
+ elseif ($this->_lastType() == self::LITERAL) {
+ return $all;
+ }
+
+ $params = trim((string) $params);
+
+ // Variable
+ if ($start == '$') {
+ return sprintf('=%s?>', $this->_variable('$' . $name . $params, true, $line));
+ }
+
+ if ($start == '"' || $start == '\'') {
+ return sprintf('=%s?>', $this->_variable($start . $name . $params, true, $line));
+ }
+
+ if ($start == '#' && array_key_exists($name, $this->_sections)) {
+ return $this->_section($name, $params, $line);
+ }
+ elseif ($start == 'if') {
+ $this->_push(self::IF, 'if');
+ return $this->_if($name, $params, 'if', $line);
+ }
+ elseif ($start == 'elseif') {
+ if ($this->_lastType() != self::IF) {
+ throw new Brindille_Exception('"elseif" block is not following a "if" block');
+ }
+
+ $this->_pop();
+ $this->_push(self::IF, 'if');
+ return $this->_if($name, $params, 'elseif', $line);
+ }
+ elseif ($start == 'else') {
+ return $this->_else($line);
+ }
+ elseif (array_key_exists($start . $name, $this->_blocks) && substr($name, 0, 5) !== 'else:') {
+ return $this->_block($start . $name, $params, $line);
+ }
+ elseif ($start == '/') {
+ return $this->_close($name, $all);
+ }
+ elseif ($start == ':' && array_key_exists($name, $this->_functions)) {
+ return $this->_function($name, $params, $line);
+ }
+
+ throw new Brindille_Exception('Unknown block: ' . $all);
+ }
+
+ public function callModifier(string $name, int $line, ... $params) {
+ try {
+ if (isset($this->_modifiers[$name])) {
+ return $this->_modifiers[$name](...$params);
+ }
+ else {
+ return $this->_modifiers_with_instance[$name]($this, $line, ...$params);
+ }
+ }
+ catch (\Exception | \ArgumentCountError $e) {
+ $message = preg_replace('/in\s+.*?\son\sline\s\d+|to\s+function\s+.*?,/', '', $e->getMessage());
+ throw new Brindille_Exception(sprintf("line %d: modifier '%s' has returned an error: %s\nParameters: %s", $line, $name, $message, json_encode($params)), 0, $e);
+ }
+ }
+
+ public function _function(string $name, string $params, int $line): string {
+ if (!isset($this->_functions[$name])) {
+ throw new Brindille_Exception(sprintf('line %d: unknown function "%s"', $line, $name));
+ }
+
+ $params = $this->_parseArguments($params, $line);
+ $params = $this->_exportArguments($params);
+
+ return sprintf('=$this->_callFunction(%s, %s, %d)?>',
+ var_export($name, true),
+ $params,
+ $line
+ );
+ }
+
+ public function _callFunction(string $name, array $params, int $line) {
+ try {
+ return call_user_func($this->_functions[$name], $params, $this, $line);
+ }
+ catch (\Exception $e) {
+ throw new Brindille_Exception(sprintf("line %d: function '%s' has returned an error: %s\nParameters: %s", $line, $name, $e->getMessage(), json_encode($params)));
+ }
+ }
+
+ public function _section(string $name, string $params, int $line): string
+ {
+ $this->_push(self::SECTION, $name);
+
+ if (!isset($this->_sections[$name])) {
+ throw new Brindille_Exception(sprintf('line %d: unknown section "%s"', $line, $name));
+ }
+
+ $params = $this->_parseArguments($params, $line);
+ $params = $this->_exportArguments($params);
+
+ return sprintf('_sections[%s], %s, $this, %d) as $key => $value): $this->_variables[] = []; $this->assignArray(array_merge($value, [\'__\' => $value, \'_\' => $key]), null, false); ?>',
+ var_export($name, true),
+ $params,
+ $line
+ );
+ }
+
+ public function _block(string $name, string $params, int $line): string
+ {
+ if (!isset($this->_blocks[$name])) {
+ throw new Brindille_Exception(sprintf('unknown section "%s"', $name));
+ }
+
+ return call_user_func($this->_blocks[$name], $name, $params, $this, $line);
+ }
+
+ public function _if(string $name, string $params, string $tag_name, int $line)
+ {
+ try {
+ $tokens = self::tokenize($params, self::TOK_IF_BLOCK);
+ }
+ catch (\InvalidArgumentException $e) {
+ throw new Brindille_Exception(sprintf('Error in "if" block: %s', $e->getMessage()));
+ }
+
+ $code = '';
+ $count = count($tokens);
+
+ foreach ($tokens as $i => $token) {
+ $prev = null;
+ $next = null;
+
+ for ($j = $i - 1; $j >= 0; $j--) {
+ $prev = $tokens[$j];
+
+ // Skip spaces
+ if ($prev->type === self::T_SPACE) {
+ continue;
+ }
+
+ break;
+ }
+
+ for ($j = $i + 1; $j < $count; $j++) {
+ $next = $tokens[$j];
+
+ // Skip spaces
+ if ($next->type !== self::T_SPACE) {
+ break;
+ }
+ }
+
+ // Validate if condition: a scalar or variable can only follow a non-scalar/variable
+ if ($token->type === self::T_SCALAR || $token->type === self::T_VAR) {
+ if ($prev && ($prev->type === self::T_SCALAR || $prev->type === self::T_VAR)) {
+ throw new Brindille_Exception(sprintf('Error in "if" block: unexpected "%s" after "%s" at position %d', $token->value, $prev->value, $token->offset));
+ }
+ }
+ elseif ($token->type === self::T_OPERATOR && $token->value === '!') {
+ if (!$next || ($next->type !== self::T_VAR && $next->type !== self::T_SCALAR)) {
+ throw new Brindille_Exception(sprintf('Error in "if" block: unexpected operator "%s" before "%s" at position %d', $token->value, $prev->value, $token->offset));
+ }
+ }
+ // a non-scalar/variable can only follow a variable/scalar value
+ // eg. "$var && $var === 1" is correct, but "$var && && 1" is not
+ elseif ($token->type !== self::T_SPACE) {
+ if ($prev && !($prev->type === self::T_SCALAR || $prev->type === self::T_VAR)) {
+ throw new Brindille_Exception(sprintf('Error in "if" block: unexpected "%s" after "%s" at position %d', $token->value, $prev->value, $token->offset));
+ }
+ }
+
+ if ($token->type === self::T_VAR) {
+ $code .= $this->_variable($token->value, false, $line);
+ }
+ else {
+ $code .= $token->value;
+ }
+ }
+
+ return sprintf('', $tag_name, $code);
+ }
+
+ public function _else(int $line): string
+ {
+ $type = $this->_lastType();
+
+ if ($type != self::IF && $type != self::SECTION) {
+ throw new Brindille_Exception('"else" block is not following a "if" or section block');
+ }
+
+ $name = $this->_lastName();
+ $this->_pop();
+ $this->_push(self::ELSE, $name);
+
+ if (isset($this->_blocks['else:' . $name])) {
+ return $this->_block('else:' . $name, '', $line);
+ }
+ elseif ($type == self::SECTION) {
+ return '_variables); endforeach; if (!isset($last) || !count($last)): ?>';
+ }
+ else {
+ return '';
+ }
+ }
+
+ public function _close(string $name, string $block): string
+ {
+ if ($this->_lastName() != $name) {
+ throw new Brindille_Exception(sprintf('"%s": block closing does not match last block "%s" opened', $block, $this->_lastName()));
+ }
+
+ $type = $this->_lastType();
+ $this->_pop();
+
+ if ($type == self::IF || $type == self::ELSE) {
+ return '';
+ }
+ else {
+ return '_variables); endforeach; ?>';
+ }
+ }
+
+ /**
+ * Parse a variable, either from a {$block} or from an argument: {block arg=$bla|rot13}
+ */
+ public function _variable(string $raw, bool $escape, int $line): string
+ {
+ // Split by pipe (|) except if enclosed in quotes
+ $modifiers = preg_split('/\|(?=(([^\'"]*["\']){2})*[^\'"]*$)/', $raw);
+ $var = array_shift($modifiers);
+
+ $pre = $post = '';
+
+ if (count($modifiers))
+ {
+ $modifiers = array_reverse($modifiers);
+
+ foreach ($modifiers as &$modifier)
+ {
+ $_post = '';
+
+ $pos = strpos($modifier, ':');
+
+ // Arguments
+ if ($pos !== false)
+ {
+ $mod_name = trim(substr($modifier, 0, $pos));
+ $raw_args = substr($modifier, $pos+1);
+ $arguments = [];
+
+ // Split by two points (:) except if enclosed in quotes
+ $arguments = preg_split('/\s*:\s*|("(?:\\\\.|[^"])*?"|\'(?:\\\\.|[^\'])*?\'|[^:\'"\s]+)/', trim($raw_args), 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
+ $arguments = array_map([$this, '_exportArgument'], $arguments);
+
+ $_post .= ', ' . implode(', ', $arguments);
+ }
+ else
+ {
+ $mod_name = trim($modifier);
+ }
+
+ // Disable autoescaping
+ if ($mod_name == 'raw') {
+ $escape = false;
+ continue;
+ }
+ else if ($mod_name == 'escape') {
+ $escape = false;
+ }
+
+ // Modifiers MUST be registered at compile time
+ if (!$this->checkModifierExists($mod_name)) {
+ throw new Brindille_Exception('Unknown modifier name: ' . $mod_name);
+ }
+
+ $post = $_post . ')' . $post;
+ $pre .= '$this->callModifier(' . var_export($mod_name, true) . ', ' . $line . ', ';
+ }
+ }
+
+ $search = false;
+
+ $var = $this->_exportArgument($var);
+
+ $var = $pre . $var . $post;
+
+ unset($pre, $post, $arguments, $mod_name, $modifier, $modifiers, $pos, $_post);
+
+ // auto escape
+ if ($escape)
+ {
+ $var = '$this->callModifier(\'escape\', ' . $line . ', ' . $var . ')';
+ }
+
+ return $var;
+ }
+
+ /**
+ * Parse block arguments, this is similar to parsing HTML arguments
+ * @param string $str List of arguments
+ * @param integer $line Source code line
+ * @return array
+ */
+ public function _parseArguments(string $str, int $line)
+ {
+ $args = [];
+ $name = null;
+ $state = 0;
+ $last_value = '';
+
+ preg_match_all('/(?:"(?:\\\\"|[^\"])*?"|\'(?:\\\\\'|[^\'])*?\'|(?>[^"\'=\s]+))+|[=]/i', $str, $match);
+
+ foreach ($match[0] as $value)
+ {
+ if ($state == 0)
+ {
+ $name = $value;
+ }
+ elseif ($state == 1)
+ {
+ if ($value != '=')
+ {
+ throw new Brindille_Exception('Expecting \'=\' after \'' . $last_value . '\'');
+ }
+ }
+ elseif ($state == 2)
+ {
+ if ($value == '=')
+ {
+ throw new Brindille_Exception('Unexpected \'=\' after \'' . $last_value . '\'');
+ }
+
+ $args[$name] = $this->_variable($value, false, $line);
+ $name = null;
+ $state = -1;
+ }
+
+ $last_value = $value;
+ $state++;
+ }
+
+ unset($state, $last_value, $name, $str, $match);
+
+ return $args;
+ }
+
+ public function _exportArgument(string $raw_arg): string
+ {
+ if (substr($raw_arg, 0, 1) == '$') {
+ return sprintf('$this->get(%s)', var_export(substr($raw_arg, 1), true));
+ }
+
+ return var_export($this->getValueFromArgument($raw_arg), true);
+ }
+
+ /**
+ * Export an array to a string, like var_export but without escaping of strings
+ *
+ * This is used to reference variables and code in arrays
+ *
+ * @param array $args Arguments to export
+ * @return string
+ */
+ public function _exportArguments(array $args): string
+ {
+ if (!count($args)) {
+ return '[]';
+ }
+
+ $out = '[';
+
+ foreach ($args as $key=>$value)
+ {
+ $out .= var_export($key, true) . ' => ' . $value . ', ';
+ }
+
+ $out = substr($out, 0, -2);
+
+ $out .= ']';
+
+ return $out;
+ }
+
+ /**
+ * Returns string value from a quoted or unquoted block argument
+ * @param string $arg Extracted argument ({foreach from=$loop item="value"} => [from => "$loop", item => "\"value\""])
+ */
+ public function getValueFromArgument(string $arg)
+ {
+ static $replace = [
+ '\\"' => '"',
+ '\\\'' => '\'',
+ '\\n' => "\n",
+ '\\t' => "\t",
+ '\\\\' => '\\',
+ ];
+
+ if (strlen($arg) && ($arg[0] == '"' || $arg[0] == "'"))
+ {
+ return strtr(substr($arg, 1, -1), $replace);
+ }
+
+ switch ($arg) {
+ case 'true':
+ return true;
+ case 'false':
+ return false;
+ case 'null':
+ return null;
+ default:
+ if (ctype_digit($arg)) {
+ return (int)$arg;
+ }
+
+ return $arg;
+ }
+ }
+
+ /**
+ * Tokenize a string following a list of regexps
+ * @see https://github.com/nette/tokenizer
+ * @return array a list of tokens, each is an object with a value, a type (the array index of $tokens) and the offset position
+ * @throws \InvalidArgumentException if an unknown token is encountered
+ */
+ static public function tokenize(string $input, array $tokens): array
+ {
+ $pattern = '~(' . implode(')|(', $tokens) . ')~A';
+ preg_match_all($pattern, $input, $match, PREG_SET_ORDER);
+
+ $types = array_keys($tokens);
+ $count = count($types);
+
+ $len = 0;
+
+ foreach ($match as &$token) {
+ $type = null;
+
+ for ($i = 1; $i <= $count; $i++) {
+ if (!isset($token[$i])) {
+ break;
+ } elseif ($token[$i] !== '') {
+ $type = $types[$i - 1];
+ break;
+ }
+ }
+
+ $token = (object) ['value' => $token[0], 'type' => $type, 'offset' => $len];
+ $len += strlen($token->value);
+ }
+
+ if ($len !== strlen($input)) {
+ $text = substr($input, 0, $len);
+ $line = substr_count($text, "\n") + 1;
+ $col = $len - strrpos("\n" . $text, "\n") + 1;
+ $token = str_replace("\n", '\n', substr($input, $len, 10));
+
+ throw new \InvalidArgumentException("Unexpected '$token' on line $line, column $col");
+ }
+
+ return $match;
+ }
+
+ static public function __foreach(array $params, $tpl, $line): \Generator
+ {
+ if (array_key_exists('count', $params)) {
+ for ($i = 0; $i < (int)$params['count']; $i++) {
+ $array = [];
+
+ if (isset($params['key']) && is_string($params['key'])) {
+ $array[$params['key']] = $i;
+ }
+
+ yield $array;
+ }
+
+ return;
+ }
+
+ if (!array_key_exists('from', $params)) {
+ throw new Brindille_Exception(sprintf('line %d: missing parameter: "from" or "count"', $line));
+ }
+
+ if (null == $params['from']) {
+ return null;
+ }
+
+ if (!is_iterable($params['from'])) {
+ return null;
+ }
+
+ foreach ($params['from'] as $key => $value) {
+ $array = [];
+
+ if (is_object($value)) {
+ $value = (array)$value;
+ }
+
+ if (is_array($value) && is_string(key($value))) {
+ $array = $value;
+ }
+
+ if (isset($params['item']) && is_string($params['item'])) {
+ $array[$params['item']] = $value;
+ }
+
+ if (isset($params['key']) && is_string($params['key'])) {
+ $array[$params['key']] = $key;
+ }
+
+ yield $array;
+ }
+ }
+
+ /**
+ * Default '{{:assign' function
+ *
+ * This *always* assigns variables to level 0 so that the variables are kept in all contexts
+ *
+ * This allows these syntaxes:
+ * {{:assign name="Mr Lonely"}} => {{$name}}
+ * {{:assign var="people" age=42 name="Mr Lonely"}} => {{$people.age}} {{$people.name}}
+ * {{:assign .="user"}} => {{$user.name}} (within a section)
+ * {{:assign var="people[address]" value="42 street"}}
+ */
+ static public function __assign(array $params, Brindille $tpl, int $line)
+ {
+ $unset = [];
+
+ // Special case: {{:assign .="user" ..="loop"}}
+ foreach ($params as $key => $value) {
+ if (!preg_match('/^\.+$/', $key)) {
+ continue;
+ }
+
+ $level = count($tpl->_variables) - strlen($key);
+
+ self::__assign(array_merge($tpl->_variables[$level], ['var' => $value]), $tpl, $line);
+ unset($params[$key]);
+ }
+
+ if (isset($params['var'])) {
+ $var = $params['var'];
+ unset($params['var']);
+
+ $has_dot = false !== strpos($var, '.');
+ $has_bracket = false !== strpos($var, '[');
+
+ if ($has_bracket && $has_dot) {
+ // You can't have both
+ throw new Brindille_Exception(sprintf('Invalid variable name: ' . $var));
+ }
+ elseif ($has_bracket) {
+ $separator = '[';
+ }
+ else {
+ $separator = '.';
+ }
+
+ $parts = explode($separator, $var);
+
+ $var_name = array_shift($parts);
+ $unset[] = $var_name;
+
+ if (!isset($tpl->_variables[0][$var_name]) || !is_array($tpl->_variables[0][$var_name])) {
+ $tpl->_variables[0][$var_name] = [];
+ }
+
+ $prev =& $tpl->_variables[0][$var_name];
+
+ // To assign to arrays, eg. {{:assign var="rows[0][label]"}}
+ // or {{:assign var="rows.0.label"}}
+ foreach ($parts as $sub) {
+ $sub = trim($sub, '\'" ' . ($separator === '[' ? '[]' : '.'));
+
+ if (null === $prev || !is_array($prev)) {
+ $prev = [];
+ }
+
+ // Empty key: just increment
+ if (!strlen($sub)) {
+ $sub = count($prev);
+ }
+
+ if (!array_key_exists($sub, $prev)) {
+ $prev[$sub] = [];
+ }
+
+ $prev =& $prev[$sub];
+ }
+
+ // If value is supplied, and nothing else is supplied, then use this value
+ if (array_key_exists('value', $params) && count($params) == 1) {
+ $prev = $params['value'];
+ }
+ // Same for 'from', but use it as a variable name
+ // {{:assign var="test" from="types.%s"|args:$type}}
+ elseif (array_key_exists('from', $params) && count($params) == 1) {
+ $prev = is_string($params['from']) ? $tpl->get($params['from']) : null;
+ }
+ // Or else assign all params
+ else {
+ $prev = $params;
+ }
+
+ unset($prev);
+ }
+ // {{:assign bla="blou" address="42 street"}}
+ else {
+ $unset = array_keys($params);
+
+ try {
+ $tpl->assignArray($params, 0);
+ }
+ catch (\InvalidArgumentException $e) {
+ throw new Brindille_Exception(sprintf('line %d: %s', $line, $e->getMessage()));
+ }
+ }
+
+ // Unset all variables of the same name in children contexts,
+ // as we expect the assigned variable to be accessible right away
+ // If we don't do that, calling {{:assign}} in a section with a variable
+ // named like an existing one, and then {{$variable}} in the same section,
+ // the variable from the section will be used instead of the one just assigned
+ foreach ($unset as $name) {
+ for ($i = count($tpl->_variables) - 1; $i > 0; $i--) {
+ unset($tpl->_variables[$i][$name]);
+ }
+ }
+ }
+}
+
+class Brindille_Exception extends \RuntimeException
+{
+
+}
\ No newline at end of file
diff --git a/src/include/lib/KD2/CacheCookie.php b/src/include/lib/KD2/CacheCookie.php
new file mode 100644
index 0000000..b12a089
--- /dev/null
+++ b/src/include/lib/KD2/CacheCookie.php
@@ -0,0 +1,338 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+/**
+ * Cache Cookie
+ * (C) 2011-2014 BohwaZ
+ * Inspired by Frank Denis (C) 2011 Public domain
+ * https://00f.net/2011/01/19/thoughts-on-php-sessions/
+ */
+
+class CacheCookie
+{
+ /**
+ * Name of the cookie
+ * @var string
+ */
+ protected $name = 'cache';
+
+ /**
+ * Secret key/random hash
+ * @var string
+ */
+ protected $secret_key = null;
+
+ /**
+ * Digest method for hash_hmac
+ * @var string
+ */
+ protected $digest_method = 'sha256';
+
+ /**
+ * Delay before expiration when we should renew the cookie
+ * before it expires (in minutes)
+ * @var integer
+ */
+ protected $auto_renew = 30;
+
+ /**
+ * Default cookie path
+ * @var string
+ */
+ protected $path = '/';
+
+ /**
+ * Default cookie domain
+ * @var string
+ */
+ protected $domain = null;
+
+ /**
+ * Default cookie duration, in minutes
+ * Will also determine data validity
+ * @var integer
+ */
+ protected $duration = 0;
+
+ /**
+ * True if the cookie should only be sent over a SSL/TLS connection
+ * @var boolean
+ */
+ protected $secure = false;
+
+ /**
+ * Start timestamp used to store a shorter timestamp in the cookie
+ * @var integer
+ */
+ protected $start_timestamp = 1391209200; //2014-02-01 00:00:00
+
+ /**
+ * Cookie content
+ * @var array
+ */
+ protected $content = null;
+
+ /**
+ * Cookie HTTP only parameter
+ * @var boolean
+ */
+ protected $httponly = false;
+
+ /**
+ * Create a new CacheCookie instance and setup default parameters
+ * @param string $name Cookie name
+ * @param string $secret Secret random hash (should stay the same for at least the cookie duration)
+ * @param int $duration Cookie duration, in minutes, set to 0 (zero) to make the cookie lasts for the
+ * whole user agent session (cookie will be deleted when the browser is closed).
+ * @param string $path Cookie path
+ * @param string $domain Cookie domain, if left null the current HTTP_HOST or SERVER_NAME will be used
+ * @param string $secure Set to TRUE if the cookie should only be sent on a secure connection
+ */
+ public function __construct($name = null, $secret = null, $duration = null, $path = null, $domain = null, $secure = false, $httponly = false)
+ {
+ if (!is_null($name))
+ {
+ $this->name = $name;
+ }
+
+ if (!is_null($secret))
+ {
+ $this->secret = $secret;
+ }
+ else
+ {
+ // Default secret key
+ $this->secret = \hash('sha256', (isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : ''));
+ }
+
+ if (!is_null($duration))
+ {
+ $this->duration = (int) $duration;
+ }
+
+ if (!is_null($path))
+ {
+ $this->path = $path;
+ }
+
+ if (!is_null($domain))
+ {
+ $this->domain = $domain;
+ }
+
+ $this->secure = (bool)$secure;
+ $this->httponly = (bool)$httponly;
+ }
+
+ public function setAutoRenew($renew)
+ {
+ $this->auto_renew = (int) $renew;
+ }
+
+ /**
+ * Gets the cookie content
+ * @return array Data contained in the cookie
+ */
+ protected function _getCookie()
+ {
+ if (!is_null($this->content))
+ {
+ return $this->content;
+ }
+
+ $cookie = null;
+ $this->content = new \stdClass;
+
+ if (!empty($_COOKIE[$this->name]))
+ {
+ $cookie = $_COOKIE[$this->name];
+ }
+
+ if (!empty($cookie) && (substr_count($cookie, '|') >= 2))
+ {
+ list($digest, $expire, $data) = explode('|', $cookie, 3);
+
+ // Check data expiration and integrity
+ if (!empty($digest) && !empty($data) && !empty($expire)
+ && ($expire > round((time() - $this->start_timestamp) / 60))
+ && hash_equals($digest, hash_hmac($this->digest_method, $expire . '|' . $data, $this->secret)))
+ {
+ if (substr($data, 0, 1) == '{')
+ {
+ $this->content = (object) json_decode($data, true);
+ }
+ elseif (function_exists('msgpack_unpack'))
+ {
+ $this->content = (object) msgpack_unpack($data);
+ }
+
+ // If the cookie will expire soon we try to renew it first
+ if ($this->auto_renew && ($expire - round((time() - $this->start_timestamp)/60) <= $this->auto_renew))
+ {
+ $this->save();
+ }
+ }
+ else
+ {
+ // Invalid cookie: just remove it
+ $this->save();
+ }
+ }
+
+ return $this->content;
+ }
+
+ /**
+ * Sends the cookie content to the user-agent
+ * @return boolean TRUE for success,
+ * or RuntimeException if the HTTP headers have already been sent
+ */
+ public function save()
+ {
+ if (headers_sent())
+ {
+ throw new \RuntimeException('Cache cookie can not be saved as headers have '
+ . 'already been sent to the user agent.');
+ }
+
+ $headers = headers_list(); // List all headers
+ header_remove(); // remove all headers
+ $regexp = '/^Set-Cookie\\s*:\\s*' . preg_quote($this->name) . '=/';
+
+ foreach ($headers as $header)
+ {
+ // Re-add every header except the one for this cookie
+ if (!preg_match($regexp, $header))
+ {
+ header($header, true);
+ }
+ }
+
+ if (!empty($this->content) && count($this->content) > 0)
+ {
+ if (function_exists('msgpack_pack'))
+ {
+ $data = msgpack_pack($this->content);
+ }
+ else
+ {
+ $data = json_encode($this->content);
+ }
+
+ // Store expiration time in minutes
+ $data = round((time() - $this->start_timestamp + $this->duration*60)/60) . '|' . $data;
+
+ $cookie = hash_hmac($this->digest_method, $data, $this->secret) . '|' . $data;
+
+ $duration = $this->duration ? time() + $this->duration * 60 : 0;
+
+ if (strlen($cookie . $this->path . $duration . $this->domain . $this->name) > 4080)
+ {
+ throw new \OverflowException('Cache cookie can not be saved as its size exceeds 4KB.');
+ }
+
+ setcookie($this->name, $cookie, $duration, $this->path, $this->domain, $this->secure, true);
+ $_COOKIE[$this->name] = $cookie;
+ }
+ else
+ {
+ setcookie($this->name, '', 1, $this->path, $this->domain, $this->secure, true);
+ unset($_COOKIE[$this->name]);
+ }
+
+ return true;
+ }
+
+ /**
+ * Set a key/value pair in the cache cookie
+ * @param mixed $key Key (integer or string)
+ * @param mixed $value Value (integer, string, boolean, array, float...)
+ */
+ public function set($key, $value)
+ {
+ $this->_getCookie();
+
+ if (is_null($value))
+ {
+ unset($this->content->$key);
+ }
+ else
+ {
+ $this->content->$key = $value;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get data from the cache cookie, if $key is NULL then all the keys will be returned
+ * @param mixed $key Data key
+ * @return mixed NULL if the key is not found, or content of the requested key
+ */
+ public function get($key = null)
+ {
+ $content = $this->_getCookie();
+
+ if (is_null($key))
+ {
+ return $content;
+ }
+
+ if (!isset($content->$key))
+ {
+ return null;
+ }
+ else
+ {
+ return $content->$key;
+ }
+ }
+
+ /**
+ * Delete the cookie and all its data
+ * @return boolean TRUE
+ */
+ public function delete()
+ {
+ $content = $this->get();
+
+ foreach ($content as $key=>$value)
+ {
+ $this->set($key, null);
+ }
+
+ return $this->save();
+ }
+
+ /**
+ * Returns raw cookie data
+ * @return string cookie content
+ */
+ public function getRawData()
+ {
+ if (isset($_COOKIE[$this->name]))
+ return $_COOKIE[$this->name];
+
+ return null;
+ }
+}
diff --git a/src/include/lib/KD2/DB/AbstractEntity.php b/src/include/lib/KD2/DB/AbstractEntity.php
new file mode 100644
index 0000000..d8c9ee1
--- /dev/null
+++ b/src/include/lib/KD2/DB/AbstractEntity.php
@@ -0,0 +1,562 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2\DB;
+
+/**
+ * AbstractEntity: a generic entity that can be extended to build your entities
+ * Use the EntityManager to persist entities in a database
+ *
+ * @author bohwaz
+ * @license AGPLv3
+ */
+
+abstract class AbstractEntity
+{
+ protected $_exists = false;
+
+ protected $_modified = [];
+ protected $_types = [];
+
+ static protected $_types_cache = [];
+
+ /**
+ * Default constructor
+ */
+ public function __construct()
+ {
+ // Generate types cache
+ if (empty(self::$_types_cache[static::class]) && empty($this->_types)) {
+ $r = new \ReflectionClass(static::class);
+
+ foreach ($r->getProperties(\ReflectionProperty::IS_PROTECTED) as $p) {
+ if ($p->name[0] == '_') {
+ // Skip internal stuff
+ continue;
+ }
+
+ if (array_key_exists($p->name, $this->_types)) {
+ $type = $this->_types[$p->name];
+ }
+ else {
+ $t = $p->getType();
+
+ if (null === $t) {
+ throw new \LogicException(sprintf('Property "%s" of entity "%s" has no type', $p->name, static::class));
+ }
+
+ $type = $t->getName();
+ $type = ($t->allowsNull() ? '?' : '') . $type;
+ }
+
+ $this->_types[$p->name] = $type;
+ }
+ }
+
+ self::_loadEntityTypesCache($this->_types);
+ }
+
+ static protected function _loadEntityTypesCache(array $types)
+ {
+ if (!empty(self::$_types_cache[static::class])) {
+ return;
+ }
+
+ foreach ($types as $name => $type) {
+ $nullable = false;
+
+ if ($type[0] === '?') {
+ $type = substr($type, 1);
+ $nullable = true;
+ }
+
+ $prop = (object) compact('name', 'nullable', 'type');
+ $prop->boolean = $type === 'bool' || $type === 'boolean';
+ $prop->integer = $type === 'int' || $type === 'integer';
+ $prop->float = $type === 'float' || $type === 'double';
+ $prop->string = $type === 'string';
+ $prop->array = $type === 'array';
+ $prop->object = !$prop->boolean && !$prop->integer && !$prop->float && !$prop->string && !$prop->array;
+ $prop->class = $prop->object ? $type : null;
+ $prop->stdclass = $prop->class === 'stdClass';
+ $prop->datetime = $prop->class === 'DateTime' || $prop->class === 'DateTimeInterface';
+ $prop->date = $prop->class === Date::class || $prop->class === 'date';
+
+ self::$_types_cache[static::class][$name] = $prop;
+ }
+ }
+
+ public function __wakeup(): void
+ {
+ if (empty(self::$_types_cache[static::class])) {
+ self::_loadEntityTypesCache($this->_types);
+ }
+ }
+
+ /**
+ * Loads data from an array into the entity properties
+ * Used for example to load data from a database. This will convert string values to typed properties.
+ * @param array $data
+ * @return self
+ */
+ public function load(array $data): self
+ {
+ $properties = self::$_types_cache[static::class];
+
+ foreach ($data as $key => $value) {
+ if (!array_key_exists($key, $properties)) {
+ throw new \RuntimeException(sprintf('"%s" is not a property of the entity "%s"', $key, static::class));
+ }
+ }
+
+ foreach ($properties as $name => $prop) {
+ if (!array_key_exists($name, $data)) {
+ throw new \RuntimeException('Missing key in array: ' . $name);
+ }
+
+ $value = $data[$name];
+
+ if (is_int($value) && $prop->boolean) {
+ $value = (bool) $value;
+ }
+ elseif (is_string($value) && !$prop->string) {
+ $value = $this->transformValue($name, $value);
+ }
+
+ $this->$name = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Import data from an array of user-supplied values, only keys corresponding to entity properties
+ * will be used, others will be ignored.
+ * @param array|null $source Source data array, if none is supplied $_POST will be used
+ * @return void
+ */
+ public function import(array $source = null): self
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ unset($source['id']);
+
+ $data = array_intersect_key($source, self::$_types_cache[static::class]);
+
+ foreach ($data as $key => $value) {
+ $prop = self::$_types_cache[static::class][$key];
+
+ if ($prop->nullable && is_string($value) && trim($value) === '') {
+ $value = null;
+ }
+
+ $value = $this->filterUserValue($prop->type, $value, $key);
+ $this->setFromAnyValue($key, $value);
+ }
+
+ return $this;
+ }
+
+ protected function filterUserValue(string $type, $value, string $key)
+ {
+ if (is_null($value)) {
+ return $value;
+ }
+
+ switch ($type)
+ {
+ case 'date':
+ case Date::class:
+ $d = new Date($value);
+ $d->setTime(0, 0, 0);
+ return $d;
+ case 'DateTime':
+ return new \DateTime($value);
+ case 'int':
+ return (int) $value;
+ case 'float':
+ return (float) $value;
+ case 'bool':
+ return (bool) $value;
+ case 'string':
+ return trim((string) $value);
+ }
+
+ return $value;
+ }
+
+ protected function assert($test, string $message = null): void
+ {
+ if ($test) {
+ return;
+ }
+
+ if (null === $message) {
+ $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
+ $caller_class = array_pop($backtrace);
+ $caller = array_pop($backtrace);
+ $message = sprintf('Entity assertion fail from class %s on line %d', $caller_class['class'], $caller['line']);
+ }
+
+ throw new \UnexpectedValueException($message);
+ }
+
+ public function selfCheck(): void
+ {
+ $this->assert(!isset($this->id) || (is_numeric($this->id) && $this->id > 0));
+
+ foreach (self::$_types_cache[static::class] as $prop_name => $prop) {
+ // Skip ID
+ if ($prop_name == 'id') {
+ continue;
+ }
+
+ if (!isset($this->$prop_name) && !$prop->nullable) {
+ throw new \UnexpectedValueException(sprintf('Entity property "%s" cannot be left NULL', $prop_name));
+ }
+ }
+ }
+
+ public function asArray(bool $for_database = false): array
+ {
+ $vars = get_object_vars($this);
+
+ // Remove internal stuff
+ foreach ($vars as $key => &$value) {
+ if ($key[0] == '_') {
+ unset($vars[$key]);
+ continue;
+ }
+
+ if (!$for_database) {
+ continue;
+ }
+
+ $value = $this->getAsString($key);
+ }
+
+ return $vars;
+ }
+
+ public function getAsString(string $key, $value = null)
+ {
+ if (!isset($this->$key)) {
+ return null;
+ }
+
+ $value ??= $this->$key;
+
+ switch (gettype($value)) {
+ case 'object':
+ if ($value instanceof \stdClass) {
+ return json_encode($value);
+ }
+ elseif ($value instanceof Date) {
+ return $value->format('Y-m-d');
+ }
+ elseif ($value instanceof \DateTimeInterface) {
+ return $value->format('Y-m-d H:i:s');
+ }
+
+ return (string) $value;
+ case 'bool':
+ case 'boolean':
+ return (int) $value;
+ case 'array':
+ return json_encode($value);
+ case 'int':
+ case 'integer':
+ case 'double':
+ case 'float':
+ return $value;
+ default:
+ return (string) $value;
+ }
+ }
+
+ /**
+ * Returns an array containing *OLD* values of modified properties
+ * (*NEW* value is stored in object)
+ *
+ * Note that modified properties are cleared after save()
+ */
+ public function getModifiedProperties(): array
+ {
+ return $this->_modified;
+ }
+
+ /**
+ * @deprecated
+ */
+ public function modifiedProperties(bool $for_database = false): array
+ {
+ return array_intersect_key($this->asArray($for_database), $this->_modified);
+ }
+
+ /**
+ * Returns the *OLD* value of a modified property
+ */
+ public function getModifiedProperty(string $key)
+ {
+ return $this->_modified[$key] ?? null;
+ }
+
+ public function clearModifiedProperties(?array $properties = null): void
+ {
+ if (null === $properties) {
+ $this->_modified = [];
+ return;
+ }
+
+ foreach ($properties as $key) {
+ unset($this->_modified[$key]);
+ }
+ }
+
+ public function isModified(?string $property = null): bool
+ {
+ if ($property !== null) {
+ return array_key_exists($property, $this->_modified);
+ }
+ else {
+ return count($this->_modified) > 0;
+ }
+ }
+
+ public function id(int $id = null): int
+ {
+ if (null !== $id) {
+ $this->id = $id;
+ }
+
+ if (!isset($this->id)) {
+ throw new \LogicException('This entity does not have an ID yet');
+ }
+
+ return $this->id;
+ }
+
+ public function exists(bool $exists = null): bool
+ {
+ if (null !== $exists) {
+ $this->_exists = $exists;
+
+ if ($exists === false) {
+ unset($this->id);
+ }
+ }
+
+ return $this->_exists;
+ }
+
+ public function setFromAnyValue(string $key, $value)
+ {
+ $this->set($key, $this->transformValue($key, $value));
+ }
+
+ /**
+ * Transforms the value from loosely typed to be suitable to expected type of a property
+ * eg. (string)'42' => (int)42
+ */
+ public function transformValue(string $key, $value)
+ {
+ $prop = self::$_types_cache[static::class][$key] ?? null;
+
+ if (null === $prop) {
+ throw new \InvalidArgumentException(sprintf('Unknown "%s" property: "%s"', static::class, $key));
+ }
+
+ if (is_string($value) && trim($value) === '' && $prop->nullable) {
+ $value = null;
+ }
+ elseif (($prop->float || $prop->integer) && is_string($value) && is_numeric($value)) {
+ $value = (int)$value;
+ }
+ elseif ($prop->datetime && is_string($value) && strlen($value) === 19 && ($d = \DateTime::createFromFormat('!Y-m-d H:i:s', $value))) {
+ $value = $d;
+ }
+ elseif ($prop->datetime && is_string($value) && strlen($value) === 16 && ($d = \DateTime::createFromFormat('!Y-m-d H:i', $value))) {
+ $value = $d;
+ }
+ elseif ($prop->date && is_string($value) && strlen($value) === 10 && ($d = Date::createFromFormat('!Y-m-d', $value))) {
+ $value = $d;
+ }
+ elseif ($prop->date && is_object($value) && $value instanceof \DateTime && !($value instanceof Date)) {
+ $value = Date::createFromInterface($value);
+ }
+ elseif ($prop->boolean && is_numeric($value) && ($value == 0 || $value == 1)) {
+ $value = (bool) $value;
+ }
+ elseif ($prop->array && is_string($value)) {
+ $value = json_decode($value, true);
+
+ if (null === $value) {
+ throw new \RuntimeException(sprintf('Cannot decode JSON string for key "%s"', $key));
+ }
+ }
+ elseif ($prop->stdclass && is_string($value)) {
+ $value = json_decode($value);
+
+ if (null === $value) {
+ throw new \RuntimeException(sprintf('Cannot decode JSON string for key "%s"', $key));
+ }
+ }
+
+ return $value;
+ }
+
+ public function set(string $key, $value)
+ {
+ $prop = self::$_types_cache[static::class][$key] ?? null;
+
+ if (null === $prop) {
+ throw new \InvalidArgumentException(sprintf('Unknown "%s" property: "%s"', static::class, $key));
+ }
+
+ if (isset($this->$key) && is_object($this->$key)) {
+ $original_value = clone $this->$key;
+ }
+ elseif (isset($this->$key)) {
+ $original_value = $this->$key;
+ }
+ else {
+ $original_value = null;
+ }
+
+ if (null === $value && !$prop->nullable) {
+ throw new \UnexpectedValueException(sprintf('Unexpected NULL value for "%s"', $key));
+ }
+
+ if ($prop->date && is_object($value) && !($value instanceof Date)) {
+ $value = Date::createFromInterface($value);
+ }
+
+ if (null !== $value && !$this->_checkValueType($value, $prop)) {
+ $found_type = $this->_getValueType($value);
+
+ if ('object' == $found_type) {
+ $found_type = get_class($value);
+ }
+
+ throw new \UnexpectedValueException(sprintf('Value of type \'%s\' for property \'%s\' is invalid (expected \'%s\')', $found_type, $key, $prop->type));
+ }
+
+ // Normalize line breaks to \n
+ if (is_string($value) && (!isset($this->$key) || $this->$key !== $value)) {
+ $value = str_replace("\r\n", "\n", $value);
+ $value = str_replace("\r", "\n", $value);
+ }
+
+ $this->$key = $value;
+
+ // For storing a modified object, compare its string value, not the object, as DateTime !== DateTime
+ if (is_object($value) && is_object($original_value)) {
+ $compare_value = $this->getAsString($key, $original_value);
+ $value = $this->getAsString($key, $value);
+ }
+ else {
+ $compare_value = $original_value;
+ }
+
+ // Only modify entity if value has changed
+ if ($value !== $compare_value) {
+ $this->_modified[$key] = $original_value;
+ }
+ }
+
+ public function get(string $key)
+ {
+ return $this->$key ?? null;
+ }
+
+ public function __set(string $key, $value)
+ {
+ $this->set($key, $value);
+ }
+
+ public function __get(string $key)
+ {
+ return $this->get($key);
+ }
+
+ public function __isset($key)
+ {
+ return property_exists($this, $key) && isset($this->$key);
+ }
+
+ /**
+ * Make sure the cloned object doesn't have the same ID, it's a brand new entity!
+ */
+ public function __clone()
+ {
+ unset($this->id);
+ $this->_exists = false;
+ }
+
+ protected function _checkValueType($value, \stdClass $prop): bool
+ {
+ $type = $this->_getValueType($value);
+
+ if ($type !== 'object' && isset($prop->$type) && $prop->$type === true) {
+ return true;
+ }
+ elseif ($prop->date && $value instanceof Date) {
+ return true;
+ }
+ elseif ($prop->datetime && $value instanceof \DateTimeInterface) {
+ return true;
+ }
+ elseif ($prop->stdclass && $value instanceof \stdClass) {
+ return true;
+ }
+ elseif ($prop->class && $value instanceof $prop->class) {
+ return true;
+ }
+
+ return false;
+ }
+
+ protected function _getValueType($value)
+ {
+ $type = gettype($value);
+
+ // Type names are not consistent in PHP...
+ // see https://mlocati.github.io/articles/php-type-hinting.html
+ $type = $type === 'double' ? 'float': $type;
+
+ return $type;
+ }
+
+ // Helpful helpers
+ public function save(bool $selfcheck = true): bool
+ {
+ return EntityManager::getInstance(static::class)->save($this, $selfcheck);
+ }
+
+ public function delete(): bool
+ {
+ return EntityManager::getInstance(static::class)->delete($this);
+ }
+}
diff --git a/src/include/lib/KD2/DB/AbstractEntity.php.UTC b/src/include/lib/KD2/DB/AbstractEntity.php.UTC
new file mode 100644
index 0000000..01251cb
--- /dev/null
+++ b/src/include/lib/KD2/DB/AbstractEntity.php.UTC
@@ -0,0 +1,407 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2\DB;
+
+/**
+ * AbstractEntity: a generic entity that can be extended to build your entities
+ * Use the EntityManager to persist entities in a database
+ *
+ * @author bohwaz
+ * @license AGPLv3
+ */
+
+abstract class AbstractEntity
+{
+ protected $id;
+
+ protected $_exists = false;
+
+ protected $_modified = [];
+ protected $_types = [];
+
+ /**
+ * Default constructor
+ */
+ public function __construct()
+ {
+ // Generate _types array
+ if (version_compare(PHP_VERSION, '7.4', '>=') && empty($this->_types)) {
+ $r = new \ReflectionClass(static::class);
+ foreach ($r->getProperties(\ReflectionProperty::IS_PROTECTED) as $p) {
+ if ($p->name[0] == '_') {
+ // Skip internal stuff
+ continue;
+ }
+
+ if ($p->name == 'id') {
+ $type = 'int';
+ }
+ else {
+ $t = $p->getType();
+
+ if (null === $t) {
+ throw new \LogicException(sprintf('Property "%s" of entity "%s" has no type', $p->name, static::class));
+ }
+
+ $type = $t->getName();
+ $type = ($t->allowsNull() ? '?' : '') . $type;
+ }
+
+ $this->_types[$p->name] = $type;
+ }
+ }
+ }
+
+ /**
+ * Loads data from an array into the entity properties
+ * Used for example to load data from a database. This will convert string values to typed properties.
+ * @param array $data
+ * @return void
+ */
+ public function load(array $data): void
+ {
+ $properties = array_keys($this->_types);
+
+ foreach ($data as $key => $value) {
+ if (!in_array($key, $properties)) {
+ throw new \RuntimeException(sprintf('"%s" is not a property of the entity "%s"', $key, static::class));
+ }
+ }
+
+ foreach ($properties as $key) {
+ if (!array_key_exists($key, $data)) {
+ throw new \RuntimeException('Missing key in array: ' . $key);
+ }
+
+ $value = $data[$key];
+ $this->set($key, $value, true, false);
+ }
+ }
+
+ /**
+ * Import data from an array of user-supplied values, only keys corresponding to entity properties
+ * will be used, others will be ignored.
+ * @param array|null $source Source data array, if none is supplied $_POST will be used
+ * @return void
+ */
+ public function import(array $source = null): self
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ $data = array_intersect_key($source, $this->_types);
+
+ foreach ($data as $key => $value) {
+ $type = $this->_types[$key];
+
+ if (substr($type, 0, 1) == '?') {
+ $type = substr($type, 1);
+ }
+
+ $value = $this->filterUserValue($type, $value, $key);
+ $this->set($key, $value, true, true);
+ }
+
+ return $this;
+ }
+
+ protected function filterUserValue(string $type, $value, string $key)
+ {
+ if (is_null($value)) {
+ return $value;
+ }
+
+ switch ($type)
+ {
+ case 'date':
+ return \DateTime::createFromFormat('!Y-m-d', $value);
+ case 'DateTime':
+ return new \DateTime($value);
+ case 'int':
+ return (int) $value;
+ case 'bool':
+ return (bool) $value;
+ case 'string':
+ return trim($value);
+ }
+
+ return $value;
+ }
+
+ protected function assert(?bool $test, string $message = null): void
+ {
+ if ($test) {
+ return;
+ }
+
+ if (null === $message) {
+ $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
+ $caller_class = array_pop($backtrace);
+ $caller = array_pop($backtrace);
+ $message = sprintf('Entity assertion fail from class %s on line %d', $caller_class['class'], $caller['line']);
+ }
+
+ throw new \UnexpectedValueException($message);
+ }
+
+ public function selfCheck(): void
+ {
+ $this->assert(is_null($this->id) || (is_numeric($this->id) && $this->id > 0));
+
+ foreach ($this->_types as $key => $type) {
+ // Skip ID
+ if ($key == 'id') {
+ continue;
+ }
+
+ if (is_null($this->$key) && substr($type, 0, 1) != '?') {
+ throw new \UnexpectedValueException(sprintf('Entity property "%s" cannot be left null', $key));
+ }
+ }
+ }
+
+ public function asArray($for_database = false): array
+ {
+ $vars = get_object_vars($this);
+
+ // Remove internal stuff
+ foreach ($vars as $key => &$value) {
+ if ($key[0] == '_') {
+ unset($vars[$key]);
+ continue;
+ }
+
+ if (!$for_database) {
+ continue;
+ }
+
+ $value = $this->getAsString($key);
+ }
+
+ return $vars;
+ }
+
+ public function getAsString(string $key)
+ {
+ if (null === $this->$key) {
+ return null;
+ }
+
+ $type = $this->_types[$key];
+
+ if (substr($type, 0, 1) == '?') {
+ $type = substr($type, 1);
+ }
+
+ switch ($type) {
+ // Export dates
+ case 'date':
+ $v = clone $this->$key;
+ $v->setTimezone(new \DateTimeZone('UTC'));
+ return $v->format('Y-m-d');
+ case 'DateTime':
+ $v = clone $this->$key;
+ $v->setTimezone(new \DateTimeZone('UTC'));
+ return $v->format('Y-m-d H:i:s');
+ case 'bool':
+ return (int) $this->$key;
+ default:
+ return $this->$key;
+ }
+ }
+
+ public function modifiedProperties($for_database = false): array
+ {
+ return array_intersect_key($this->asArray($for_database), $this->_modified);
+ }
+
+ public function isModified(): bool
+ {
+ return count($this->_modified) > 0;
+ }
+
+ public function id(int $id = null): int
+ {
+ if (null !== $id) {
+ $this->id = $id;
+ }
+
+ if (null === $this->id) {
+ throw new \LogicException('This entity does not have an ID yet');
+ }
+
+ return $this->id;
+ }
+
+ public function exists(bool $exists = null): bool
+ {
+ if (null !== $exists) {
+ $this->_exists = $exists;
+ }
+
+ return $this->_exists;
+ }
+
+ public function set(string $key, $value, bool $loose = false, bool $check_for_changes = true) {
+ if (!property_exists($this, $key)) {
+ throw new \InvalidArgumentException(sprintf('Unknown "%s" property: "%s"', static::class, $key));
+ }
+
+ if (isset($this->$key)) {
+ $original_value = $this->getAsString($key);
+ }
+ else {
+ $original_value = null;
+ }
+
+ $type = $this->_types[$key];
+ $nullable = false;
+
+ if ($type[0] == '?') {
+ $nullable = true;
+ $type = substr($type, 1);
+ }
+
+ if ($loose) {
+ if (is_string($value) && trim($value) === '' && $nullable) {
+ $value = null;
+ }
+
+ if ($value !== null) {
+ if ($type == 'int' && is_string($value) && ctype_digit($value)) {
+ $value = (int)$value;
+ }
+ elseif ($type == 'DateTime' && is_string($value) && strlen($value) === 19 && ($d = \DateTime::createFromFormat('Y-m-d H:i:s-e', $value . '-UTC'))) {
+ $value = $d;
+ }
+ elseif ($type == 'date' && is_string($value) && strlen($value) === 10 && ($d = \DateTime::createFromFormat('!Y-m-d-e', $value . '-UTC'))) {
+ $value = $d;
+ }
+ elseif ($type == 'bool' && is_numeric($value) && ($value == 0 || $value == 1)) {
+ $value = (bool) $value;
+ }
+ }
+ }
+
+ if (!$nullable && null === $value) {
+ throw new \RuntimeException(sprintf('Unexpected NULL value for "%s"', $key));
+ }
+
+ if (null !== $value && !$this->_checkType($key, $value, $type)) {
+ $found_type = $this->_getType($value);
+
+ if ('object' == $found_type) {
+ $found_type = get_class($value);
+ }
+
+ throw new \UnexpectedValueException(sprintf('Value of type \'%s\' for property \'%s\' is invalid (expected \'%s\')', $found_type, $key, $type));
+ }
+
+ $this->$key = $value;
+
+ if ($check_for_changes && $original_value !== $this->getAsString($key)) {
+ $this->_modified[$key] = true;
+ }
+ }
+
+ public function get(string $key)
+ {
+ return $this->$key;
+ }
+
+ public function __set(string $key, $value)
+ {
+ $this->set($key, $value, false, true);
+ }
+
+ public function __get(string $key)
+ {
+ return $this->$key;
+ }
+
+ public function __isset($key)
+ {
+ return isset($this->$key);
+ }
+
+ /**
+ * Make sure the cloned object doesn't have the same ID, it's a brand new entity!
+ */
+ public function __clone()
+ {
+ $this->id = null;
+ $this->_exists = false;
+ }
+
+ protected function _checkType(string $key, $value, string $type): bool
+ {
+ if (false !== strpos($type, '|')) {
+ $types = explode('|', $type);
+
+ foreach ($types as $type) {
+ if ($this->_checkType($key, $value, $type)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ switch ($type) {
+ case 'date':
+ return is_object($value) && $value instanceof \DateTimeInterface;
+ case 'DateTime':
+ return is_object($value) && $value instanceof \DateTimeInterface;
+ default:
+ return $this->_getType($value) == $type;
+ }
+ }
+
+ protected function _getType($value)
+ {
+ $type = gettype($value);
+
+ // Type names are not consistent in PHP...
+ // see https://mlocati.github.io/articles/php-type-hinting.html
+ $type = strtr($type, ['boolean' => 'bool', 'integer' => 'int', 'double' => 'float']);
+
+ if ($type === 'object') {
+ $type = get_class($value);
+ }
+
+ return $type;
+ }
+
+ // Helpful helpers
+ public function save(): bool
+ {
+ return EntityManager::getInstance(static::class)->save($this);
+ }
+
+ public function delete(): bool
+ {
+ return EntityManager::getInstance(static::class)->delete($this);
+ }
+}
diff --git a/src/include/lib/KD2/DB/DB.php b/src/include/lib/KD2/DB/DB.php
new file mode 100644
index 0000000..c4d5c3c
--- /dev/null
+++ b/src/include/lib/KD2/DB/DB.php
@@ -0,0 +1,1039 @@
+
+
+ Copyright (c) 2001-2020 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 KD2FW. If not, see .
+*/
+
+/**
+ * DB: a generic wrapper around PDO, adding easier access functions
+ *
+ * @author bohwaz http://bohwaz.net/
+ * @license AGPLv3
+ */
+
+namespace KD2\DB;
+
+use PDO;
+use PDOException;
+use PDOStatement;
+
+class DB_Exception extends \RuntimeException {}
+
+class DB
+{
+ /**
+ * @var int
+ */
+ protected $transaction = 0;
+
+ /**
+ * Attributes for PDO instance
+ * @var array
+ */
+ protected $pdo_attributes = [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ,
+ PDO::ATTR_TIMEOUT => 5, // in seconds
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ];
+
+ /**
+ * Current driver
+ * @var null
+ */
+ protected $driver;
+
+ /**
+ * Store PDO object
+ * @var null
+ */
+ protected $pdo;
+
+ protected $sqlite_functions = [
+ 'base64_encode' => 'base64_encode',
+ 'rank' => [__CLASS__, 'sqlite_rank'],
+ 'haversine_distance' => [__CLASS__, 'sqlite_haversine'],
+ 'escape_like' => ['$this', 'escapeLike'],
+ ];
+
+ protected $sqlite_collations = [
+ ];
+
+ /**
+ * Statements cache
+ * @var array
+ */
+ protected $statements = [];
+
+ protected $callback = null;
+
+ /**
+ * Class construct, expects a driver configuration
+ * @param array $driver Driver configurtaion
+ */
+ public function __construct(string $name, array $params)
+ {
+ $driver = (object) [
+ 'type' => $name,
+ 'url' => null,
+ 'user' => null,
+ 'password' => null,
+ 'options' => [],
+ 'tables_prefix' => '',
+ ];
+
+ if ($name == 'mysql')
+ {
+ if (empty($params['host']))
+ {
+ throw new \BadMethodCallException('No host parameter passed.');
+ }
+
+ if (empty($params['user']))
+ {
+ throw new \BadMethodCallException('No user parameter passed.');
+ }
+
+ if (empty($params['password']))
+ {
+ throw new \BadMethodCallException('No password parameter passed.');
+ }
+
+ if (empty($params['charset']))
+ {
+ $params['charset'] = 'utf8mb4';
+ }
+
+ if (empty($params['port']))
+ {
+ $params['port'] = 3306;
+ }
+
+ $driver->url = sprintf('mysql:charset=%s;host=%s;port=%d', $params['charset'], $params['host'], $params['port']);
+
+ if (!empty($params['database']))
+ {
+ $driver->url .= ';dbname=' . $params['database'];
+ }
+
+ $driver->user = $params['user'];
+ $driver->password = $params['password'];
+
+ if (empty($this->pdo_attributes[PDO::MYSQL_ATTR_INIT_COMMAND])) {
+ $this->pdo_attributes[PDO::MYSQL_ATTR_INIT_COMMAND] = sprintf('SET NAMES %s COLLATE %s;', $params['charset'], 'utf8mb4_unicode_ci');
+ }
+ }
+ else if ($name == 'sqlite')
+ {
+ if (empty($params['file']))
+ {
+ throw new \BadMethodCallException('No file parameter passed.');
+ }
+
+ $driver->url = 'sqlite:' . $params['file'];
+
+ if (isset($params['flags']) && defined('PDO::SQLITE_ATTR_OPEN_FLAGS')) {
+ $driver->options[PDO::SQLITE_ATTR_OPEN_FLAGS] = $params['flags'];
+ }
+ }
+ else
+ {
+ throw new \BadMethodCallException('Invalid driver name.');
+ }
+
+ $this->driver = $driver;
+ }
+
+ public function __destruct()
+ {
+ $this->statements = [];
+ }
+
+ /**
+ * Connect to the currently defined driver if needed
+ * @return void
+ */
+ public function connect(): void
+ {
+ if ($this->pdo)
+ {
+ return;
+ }
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this);
+ }
+
+ try {
+ $this->pdo = new PDO($this->driver->url, $this->driver->user, $this->driver->password, $this->driver->options);
+
+ // Set attributes
+ foreach ($this->pdo_attributes as $attr => $value)
+ {
+ $this->pdo->setAttribute($attr, $value);
+ }
+ }
+ catch (PDOException $e)
+ {
+ // Catch exception to avoid showing password in backtrace
+ throw new PDOException('Unable to connect to database. Check username and password.');
+ }
+
+ if ($this->driver->type == 'sqlite')
+ {
+ // Enhance SQLite with default functions
+ foreach ($this->sqlite_functions as $name => $callback)
+ {
+ if (is_array($callback) && $callback[0] === '$this') {
+ $callback = [$this, $callback[1]];
+ }
+
+ $this->pdo->sqliteCreateFunction($name, $callback);
+ }
+
+ // Force to rollback any outstanding transaction
+ register_shutdown_function(function () {
+ if ($this->inTransaction())
+ {
+ $this->rollback();
+ }
+ });
+ }
+
+ $this->driver->password = '******';
+ }
+
+ public function close(): void
+ {
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this);
+ }
+
+ $this->pdo = null;
+ }
+
+ protected function applyTablePrefix(string $statement): string
+ {
+ if (strpos('__PREFIX__', $statement) !== false)
+ {
+ $statement = preg_replace('/(?<=\s|^)__PREFIX__(?=\w)/', $this->driver->tables_prefix, $statement);
+ }
+
+ return $statement;
+ }
+
+ public function query(string $statement)
+ {
+ $this->connect();
+ $statement = $this->applyTablePrefix($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ $out = $this->pdo->query($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ return $out;
+ }
+
+ /**
+ * Execute an SQL statement and return the number of affected rows
+ * returns the number of rows that were modified or deleted by the SQL statement you issued. If no rows were affected, returns 0.
+ * It may return FALSE, even if the operation completed successfully
+ *
+ * @see https://www.php.net/manual/en/pdo.exec.php
+ * @param string $statement SQL Query
+ * @return bool|int
+ */
+ public function exec(string $statement)
+ {
+ $this->connect();
+ $statement = $this->applyTablePrefix($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ $out = $this->pdo->exec($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ return $out;
+ }
+
+ public function execMultiple(string $statement)
+ {
+ $this->connect();
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ $this->begin();
+
+ $emulate = null;
+
+ // Store user-set prepared emulation setting for later
+ if ($this->driver->type == 'mysql') {
+ $emulate = $this->pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES);
+ }
+
+ try
+ {
+ if ($this->driver->type == 'mysql')
+ {
+ // required to allow multiple queries in same statement
+ $this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
+
+ $st = $this->prepare($statement);
+ $st->execute();
+
+ while ($st->nextRowset())
+ {
+ // Iterate over rowsets, see https://bugs.php.net/bug.php?id=61613
+ }
+
+ $return = $this->commit();
+ }
+ else
+ {
+ $return = $this->pdo->exec($statement);
+ $this->commit();
+ }
+ }
+ catch (PDOException $e)
+ {
+ $this->rollBack();
+ throw $e;
+ }
+ finally
+ {
+ // Restore prepared statement attribute
+ if ($this->driver->type == 'mysql') {
+ $this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, $emulate);
+ }
+ }
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ return $return;
+ }
+
+ public function createFunction(string $name, callable $callback): bool
+ {
+ if ($this->driver->type != 'sqlite')
+ {
+ throw new \LogicException('This driver does not support functions.');
+ }
+
+ if ($this->pdo)
+ {
+ return $this->pdo->sqliteCreateFunction($name, $callback);
+ }
+ else
+ {
+ $this->sqlite_functions[$name] = $callback;
+ return true;
+ }
+ }
+
+ public function import(string $file)
+ {
+ if (!is_readable($file))
+ {
+ throw new \RuntimeException(sprintf('Cannot read file %s', $file));
+ }
+
+ return $this->execMultiple(file_get_contents($file));
+ }
+
+ public function prepare(string $statement, array $driver_options = [])
+ {
+ $this->connect();
+ $statement = $this->applyTablePrefix($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ $return = $this->pdo->prepare($statement, $driver_options);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ return $return;
+ }
+
+ public function begin()
+ {
+ $this->transaction++;
+
+ if ($this->transaction == 1) {
+ $this->connect();
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this, ... func_get_args());
+ }
+
+ return $this->pdo->beginTransaction();
+ }
+
+ return true;
+ }
+
+ public function inTransaction()
+ {
+ return $this->transaction > 0;
+ }
+
+ public function commit()
+ {
+ if ($this->transaction == 0) {
+ throw new \LogicException('Cannot commit a transaction: no transaction is running');
+ }
+
+ $this->transaction--;
+
+ if ($this->transaction == 0) {
+ $this->connect();
+ $return = $this->pdo->commit();
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this, ... func_get_args());
+ }
+
+ return $return;
+ }
+
+ return true;
+ }
+
+ public function rollback()
+ {
+ if ($this->transaction == 0) {
+ throw new \LogicException('Cannot rollback a transaction: no transaction is running');
+ }
+
+ $this->transaction = 0;
+ $this->connect();
+ $return = $this->pdo->rollBack();
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this, ... func_get_args());
+ }
+
+ return $return;
+ }
+
+ public function lastInsertId(string $name = null): string
+ {
+ $this->connect();
+ return $this->pdo->lastInsertId($name);
+ }
+
+ public function lastInsertRowId(): string
+ {
+ return $this->lastInsertId();
+ }
+
+ /**
+ * Quotes a string for use in a query
+ * @see https://www.php.net/manual/en/pdo.quote.php
+ */
+ public function quote(string $value, int $parameter_type = PDO::PARAM_STR): string
+ {
+ if ($this->driver->type == 'sqlite')
+ {
+ // PHP quote() is truncating strings on NUL bytes
+ // https://bugs.php.net/bug.php?id=63419
+
+ $value = str_replace("\0", '\\0', $value);
+ }
+
+ $this->connect();
+ return $this->pdo->quote($value, $parameter_type);
+ }
+
+ /**
+ * Quotes an identifier (table name or column name) for use in a query
+ * @param string $value Identifier to quote
+ * @return string Quoted identifier
+ */
+ public function quoteIdentifier(string $value): string
+ {
+ // see https://www.codetinkerer.com/2015/07/08/escaping-column-and-table-names-in-mysql-part2.html
+ if ($this->driver->type == 'mysql')
+ {
+ if (strlen($value) > 64)
+ {
+ throw new \OverflowException('MySQL column or table names cannot be longer than 64 characters.');
+ }
+
+ if (substr($value, 0, -1) == ' ')
+ {
+ throw new \UnexpectedValueException('MySQL column or table names cannot end with a space character');
+ }
+
+ if (preg_match('/[\0\.\/\\\\]/', $value))
+ {
+ throw new \UnexpectedValueException('Invalid MySQL column or table name');
+ }
+
+ return sprintf('`%s`', str_replace('`', '``', $value));
+ }
+ else
+ {
+ return sprintf('"%s"', str_replace('"', '""', $value));
+ }
+ }
+
+ public function escapeLike(string $value, string $escape_character): string
+ {
+ return strtr($value, [
+ $escape_character => $escape_character . $escape_character,
+ '%' => $escape_character . '%',
+ '_' => $escape_character . '_',
+ ]);
+ }
+
+ /**
+ * Quote identifier, eg. 'users.index' => '"users"."index"'
+ */
+ public function quoteIdentifiers(string $value): string
+ {
+ $value = explode('.', $value);
+ $value = array_map([$this, 'quoteIdentifier'], $value);
+ return implode('.', $value);
+ }
+
+ public function preparedQuery(string $query, ...$args)
+ {
+ $key = md5($query . implode(',', array_keys($args)));
+
+ // Use statements cache!
+ if (!array_key_exists($key, $this->statements)) {
+ $this->statements[$key] = $this->prepare($query);
+ }
+
+ return $this->execute($this->statements[$key], ...$args);
+ }
+
+ public function execute($statement, ...$args)
+ {
+ // Only one argument, which is an array: this is an associative array
+ if (isset($args[0]) && is_array($args[0]))
+ {
+ $args = $args[0];
+ }
+
+ if (!is_array($args) && !is_object($args)) {
+ throw new \InvalidArgumentException('Expecting an array or object as query arguments');
+ }
+
+ $args = (array) $args;
+
+ foreach ($args as &$arg)
+ {
+ if (is_object($arg) && $arg instanceof \DateTimeInterface)
+ {
+ $arg = $arg->format('Y-m-d H:i:s');
+ }
+ }
+
+ unset($arg);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ $statement->execute($args);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ return $statement;
+ }
+
+ public function iterate(string $query, ...$args): iterable
+ {
+ $st = $this->preparedQuery($query, ...$args);
+
+ while ($row = $st->fetch())
+ {
+ yield $row;
+ }
+
+ unset($st);
+
+ return;
+ }
+
+ public function get(string $query, ...$args): array
+ {
+ return $this->preparedQuery($query, ...$args)->fetchAll();
+ }
+
+ /**
+ * Return results from a SQL query in an associative flat array:
+ * SELECT id, name FROM table;
+ * -> [42 => "PJ Harvey", 44 => "Tori Amos",...]
+ */
+ public function getAssoc(string $query, ...$args): array
+ {
+ $st = $this->preparedQuery($query, ...$args);
+ $out = [];
+
+ while ($row = $st->fetch(PDO::FETCH_NUM))
+ {
+ $out[$row[0]] = $row[1];
+ }
+
+ return $out;
+ }
+
+ /**
+ * Return results grouped by the first column:
+ * SELECT id, * FROM table;
+ * -> [42 => {id: 42, date: ..., name...}, 43 => ...]
+ */
+ public function getGrouped(string $query, ...$args): array
+ {
+ $st = $this->preparedQuery($query, ...$args);
+ $out = [];
+
+ while ($row = $st->fetch(PDO::FETCH_ASSOC))
+ {
+ $out[current($row)] = (object) $row;
+ }
+
+ return $out;
+ }
+
+ /**
+ * Return results grouped by columns, multidimensional
+ * SELECT month, * FROM table;
+ * -> ['202101' => [{id: 42, month: ..., name...}, {id: 43, ...}]]
+ */
+ public function getGroupedMulti(string $query, ...$args): array
+ {
+ $r = $this->iterate($query, ...$args);
+ $out = [];
+
+ foreach ($r as $row)
+ {
+ $row = (array)$row;
+ $levels = count($row) - 1;
+ $prev =& $out;
+
+ for ($i = 0; $i < $levels; $i++) {
+ $key = current($row);
+ if (!isset($prev[$key])) {
+ $prev[$key] = [];
+ }
+
+ $prev =& $prev[$key];
+ next($row);
+ }
+
+ $prev = (object)$row;
+ }
+
+ return $out;
+ }
+
+ /**
+ * Return results associative by the first column:
+ * SELECT month, name, SUM(amount) FROM table;
+ * -> ['202101' => ['Tori Amos' => 20000,...]]
+ */
+ public function getAssocMulti(string $query, ...$args): array
+ {
+ $r = $this->iterate($query, ...$args);
+ $out = [];
+
+ foreach ($r as $row)
+ {
+ $row = (array)$row;
+ $levels = count($row) - 1;
+ $prev =& $out;
+
+ for ($i = 0; $i < $levels; $i++) {
+ $key = current($row);
+ if (!isset($prev[$key])) {
+ $prev[$key] = [];
+ }
+
+ $prev =& $prev[$key];
+ next($row);
+ }
+
+ $prev = end($row);
+ }
+
+ return $out;
+ }
+
+ /**
+ * Runs a query and returns the first row
+ * @param string $query SQL query
+ * @return object
+ *
+ * Accepts one or more arguments as part of bindings for the statement
+ */
+ public function first(string $query, ...$args)
+ {
+ $st = $this->preparedQuery($query, ...$args);
+
+ return $st->fetch();
+ }
+
+ /**
+ * Runs a query and returns the first column
+ * @param string $query SQL query
+ * @return object
+ *
+ * Accepts one or more arguments as part of bindings for the statement
+ */
+ public function firstColumn(string $query, ...$args)
+ {
+ $st = $this->preparedQuery($query, ...$args);
+
+ return $st->fetchColumn();
+ }
+
+ /**
+ * Inserts a row in $table, using $fields as data to fill
+ * @param string $table Table name
+ * @param array|object $fields List of columns as an associative array
+ * @param null|string $clause INSERT clause (eg. 'OR IGNORE' etc.)
+ * @return boolean
+ */
+ public function insert(string $table, $fields, string $clause = null)
+ {
+ assert(is_array($fields) || is_object($fields));
+
+ $fields = (array) $fields;
+
+ $fields_names = array_keys($fields);
+ $query = sprintf('INSERT %s INTO %s (%s) VALUES (:%s);', (string) $clause, $this->quoteIdentifier($table),
+ implode(', ', array_map([$this, 'quoteIdentifier'], $fields_names)), implode(', :', $fields_names));
+
+ return (bool) $this->preparedQuery($query, $fields);
+ }
+
+ /**
+ * Insert a row in $table, or if it already exists (according to primary key/unique constraint), it is ignored
+ * @param string $table Table name
+ * @param array|object $fields List of columns as an associative array
+ * @return boolean
+ */
+ public function insertIgnore(string $table, $fields)
+ {
+ if ($this->driver->type == 'mysql') {
+ $clause = 'IGNORE';
+ }
+ elseif ($this->driver->type == 'sqlite') {
+ $clause = 'OR IGNORE';
+ }
+ else {
+ throw new \RuntimeException('Unsupported driver for INSERT IGNORE');
+ }
+
+ return $this->insert($table, $fields, $clause);
+ }
+
+ /**
+ * Updates lines in $table using $fields, selecting using $where
+ * @param string $table Table name
+ * @param array|object $fields List of fields to update
+ * @param string $where Content of the WHERE clause
+ * @param array|object $args Arguments for the WHERE clause
+ * @return boolean
+ */
+ public function update(string $table, $fields, string $where = null, $args = null)
+ {
+ assert(is_string($table));
+ assert((is_string($where) && strlen($where)) || is_null($where));
+ assert(is_array($fields) || is_object($fields));
+ assert(is_null($args) || is_array($args) || is_object($args), 'Arguments for the WHERE clause must be an array or object');
+
+ // Convert to array
+ $fields = (array) $fields;
+ $args = (array) $args;
+
+ foreach ($args as $key => $arg) {
+ if (is_int($key)) {
+ throw new \LogicException('Arguments must be a named array, not an indexed array');
+ }
+ }
+
+ // No fields to update? no need to do a query
+ if (empty($fields))
+ {
+ return false;
+ }
+
+ $column_updates = [];
+
+ foreach ($fields as $key => $value)
+ {
+ if (is_object($value) && $value instanceof \DateTimeInterface)
+ {
+ $value = $value->format('Y-m-d H:i:s');
+ }
+
+ // Append to arguments
+ $args['field_' . $key] = $value;
+
+ $column_updates[] = sprintf('%s = :field_%s', $this->quoteIdentifier($key), $key);
+ }
+
+ if (is_null($where))
+ {
+ $where = '1';
+ }
+
+ // Final query assembly
+ $column_updates = implode(', ', $column_updates);
+ $query = sprintf('UPDATE %s SET %s WHERE %s;', $this->quoteIdentifier($table), $column_updates, $where);
+
+ return (bool) $this->preparedQuery($query, $args);
+ }
+
+ /**
+ * Deletes rows from a table
+ * @param string $table Table name
+ * @param string $where WHERE clause
+ * @return boolean
+ *
+ * Accepts one or more arguments as bindings for the WHERE clause.
+ * Warning! If run without a $where argument, will delete all rows from a table!
+ */
+ public function delete(string $table, string $where, ...$args)
+ {
+ $query = sprintf('DELETE FROM %s WHERE %s;', $table, $where);
+ return (bool) $this->preparedQuery($query, ...$args);
+ }
+
+ /**
+ * Returns true if the condition from the WHERE clause is valid and a row exists
+ * @param string $table Table name
+ * @param string $where WHERE clause
+ * @return boolean
+ */
+ public function test(string $table, string $where, ...$args): bool
+ {
+ $query = sprintf('SELECT 1 FROM %s WHERE %s LIMIT 1;', $this->quoteIdentifier($table), $where);
+ return (bool) $this->firstColumn($query, ...$args);
+ }
+
+ /**
+ * Returns the number of rows in a table according to a WHERE clause
+ * @param string $table Table name
+ * @param string $where WHERE clause
+ * @return integer
+ */
+ public function count(string $table, string $where = '1', ...$args): int
+ {
+ $query = sprintf('SELECT COUNT(*) FROM %s WHERE %s LIMIT 1;', $this->quoteIdentifier($table), $where);
+ return (int) $this->firstColumn($query, ...$args);
+ }
+
+ /**
+ * Generate a WHERE clause, can be called as a short notation:
+ * where('id', '42')
+ * or including the comparison operator:
+ * where('id', '>', '42')
+ * It accepts arrays or objects as the value. If no operator is specified, 'IN' is used.
+ * @param string $name Column name
+ * @return string
+ */
+ public function where(string $name): string
+ {
+ $num_args = func_num_args();
+
+ $value = func_get_arg($num_args - 1);
+
+ if (is_object($value) && $value instanceof \DateTimeInterface)
+ {
+ $value = $value->format('Y-m-d H:i:s');
+ }
+
+ if (is_object($value))
+ {
+ $value = (array) $value;
+ }
+
+ if ($num_args == 2)
+ {
+ if (is_array($value))
+ {
+ $operator = 'IN';
+ }
+ elseif (is_null($value))
+ {
+ $operator = 'IS';
+ }
+ else
+ {
+ $operator = '=';
+ }
+ }
+ elseif ($num_args == 3)
+ {
+ $operator = strtoupper(func_get_arg(1));
+
+ if (is_array($value))
+ {
+ if ($operator == 'IN' || $operator == '=')
+ {
+ $operator = 'IN';
+ }
+ elseif ($operator == 'NOT IN' || $operator == '!=')
+ {
+ $operator = 'NOT IN';
+ }
+ else
+ {
+ throw new \InvalidArgumentException(sprintf('Invalid operator \'%s\' for value of type array or object (only IN and NOT IN are accepted)', $operator));
+ }
+ }
+ elseif (is_null($value))
+ {
+ if ($operator != '=' && $operator != '!=')
+ {
+ throw new \InvalidArgumentException(sprintf('Invalid operator \'%s\' for value of type null (only = and != are accepted)', $operator));
+ }
+
+ $operator = ($operator == '=') ? 'IS' : 'IS NOT';
+ }
+ }
+ else
+ {
+ throw new \BadMethodCallException('Method ::where requires 2 or 3 parameters');
+ }
+
+ if (is_array($value))
+ {
+ $value = array_values($value);
+
+ array_walk($value, function (&$row) {
+ $row = is_int($row) || is_float($row) ? $row : $this->quote($row);
+ });
+
+ $value = sprintf('(%s)', implode(', ', $value));
+ }
+ elseif (is_null($value))
+ {
+ $value = 'NULL';
+ }
+ elseif (is_bool($value))
+ {
+ $value = $value ? 'TRUE' : 'FALSE';
+ }
+ elseif (is_string($value))
+ {
+ $value = $this->quote($value);
+ }
+
+ return sprintf('%s %s %s', $this->quoteIdentifier($name), $operator, $value);
+ }
+
+ /**
+ * SQLite search ranking user defined function
+ * Converted from C from SQLite manual: https://www.sqlite.org/fts3.html#appendix_a
+ * @param string $aMatchInfo
+ * @return double Score
+ */
+ static public function sqlite_rank($aMatchInfo)
+ {
+ $iSize = 4; // byte size
+ $iPhrase = (int) 0; // Current phrase //
+ $score = (double)0.0; // Value to return //
+
+ /* Check that the number of arguments passed to this function is correct.
+ ** If not, jump to wrong_number_args. Set aMatchinfo to point to the array
+ ** of unsigned integer values returned by FTS function matchinfo. Set
+ ** nPhrase to contain the number of reportable phrases in the users full-text
+ ** query, and nCol to the number of columns in the table.
+ */
+ $aMatchInfo = (string) func_get_arg(0);
+ $nPhrase = ord(substr($aMatchInfo, 0, $iSize));
+ $nCol = ord(substr($aMatchInfo, $iSize, $iSize));
+
+ if (func_num_args() > (1 + $nCol))
+ {
+ throw new \Exception("Invalid number of arguments : ".$nCol);
+ }
+
+ // Iterate through each phrase in the users query. //
+ for ($iPhrase = 0; $iPhrase < $nPhrase; $iPhrase++)
+ {
+ $iCol = (int) 0; // Current column //
+
+ /* Now iterate through each column in the users query. For each column,
+ ** increment the relevancy score by:
+ **
+ ** ( / ) *
+ **
+ ** aPhraseinfo[] points to the start of the data for phrase iPhrase. So
+ ** the hit count and global hit counts for each column are found in
+ ** aPhraseinfo[iCol*3] and aPhraseinfo[iCol*3+1], respectively.
+ */
+ $aPhraseinfo = substr($aMatchInfo, (2 + $iPhrase * $nCol * 3) * $iSize);
+
+ for ($iCol = 0; $iCol < $nCol; $iCol++)
+ {
+ $nHitCount = ord(substr($aPhraseinfo, 3 * $iCol * $iSize, $iSize));
+ $nGlobalHitCount = ord(substr($aPhraseinfo, (3 * $iCol + 1) * $iSize, $iSize));
+ $weight = ($iCol < func_num_args() - 1) ? (double) func_get_arg($iCol + 1) : 0;
+
+ if ($nHitCount > 0 && $nGlobalHitCount != 0)
+ {
+ $score += ((double)$nHitCount / (double)$nGlobalHitCount) * $weight;
+ }
+ }
+ }
+
+ return $score;
+ }
+
+ /**
+ * Haversine distance between two points
+ * @return double Distance in kilometres
+ */
+ static public function sqlite_haversine()
+ {
+ if (count($geo = array_map('deg2rad', array_filter(func_get_args(), 'is_numeric'))) != 4)
+ {
+ throw new \InvalidArgumentException('4 arguments expected for haversine_distance');
+ }
+
+ return round(acos(sin($geo[0]) * sin($geo[2]) + cos($geo[0]) * cos($geo[2]) * cos($geo[1] - $geo[3])) * 6372.8, 3);
+ }
+}
diff --git a/src/include/lib/KD2/DB/Date.php b/src/include/lib/KD2/DB/Date.php
new file mode 100644
index 0000000..a239376
--- /dev/null
+++ b/src/include/lib/KD2/DB/Date.php
@@ -0,0 +1,33 @@
+setTimestamp($object->getTimestamp());
+ $n->setTimezone($object->getTimeZone());
+ return $n;
+ }
+
+ #[\ReturnTypeWillChange]
+ static public function createFromFormat($format, $datetime, DateTimeZone $object = null)
+ {
+ $v = parent::createFromFormat($format, $datetime, $object);
+
+ if (!$v) {
+ return $v;
+ }
+
+ return self::createFromInterface($v);
+ }
+}
diff --git a/src/include/lib/KD2/DB/EntityManager.php b/src/include/lib/KD2/DB/EntityManager.php
new file mode 100644
index 0000000..a86e975
--- /dev/null
+++ b/src/include/lib/KD2/DB/EntityManager.php
@@ -0,0 +1,255 @@
+db = $db;
+ }
+
+ /**
+ * Returns the correct database object for this entity manager
+ */
+ public function DB(): DB
+ {
+ if (null !== $this->db) {
+ $db = $this->db;
+ }
+ else {
+ $db = self::$_global_db;
+ }
+
+ if (null === $db) {
+ throw new \LogicException('No DB object has been set');
+ }
+
+ return $db;
+ }
+
+ protected function __construct(string $class)
+ {
+ $this->class = $class;
+ }
+
+ /**
+ * Returns an Entity according to a query
+ * @param string $class Entity class name
+ * @param string $query SQL query
+ * @param mixed ...$params Optional parameters to be used in the query
+ * @return null|AbstractEntity
+ */
+ static public function findOne(string $class, string $query, ...$params)
+ {
+ return self::getInstance($class)->one($query, ...$params);
+ }
+
+ /**
+ * Returns an Entity from its ID
+ * @param string $class Entity class name
+ * @param int $id Entity ID
+ * @return null|AbstractEntity
+ */
+ static public function findOneById(string $class, int $id, ?string $table = null)
+ {
+ $query = sprintf('SELECT * FROM %s WHERE id = ?;', $table ?? $class::TABLE);
+ return self::findOne($class, $query, $id);
+ }
+
+ /**
+ * Formats a SQL query by replacing the table name with the entity table name
+ * @param string $query SQL query
+ * @return string
+ */
+ public function formatQuery(string $query): string
+ {
+ $class = $this->class;
+ $query = str_replace('@TABLE', $class::TABLE, $query);
+ return $query;
+ }
+
+ public function all(string $query, ...$params): array
+ {
+ $res = $this->iterate($query, ...$params);
+ $out = [];
+
+ foreach ($res as $row) {
+ $out[] = $row;
+ }
+
+ return $out;
+ }
+
+ public function allAssoc(string $query, string $key, ...$params): array
+ {
+ $res = $this->iterate($query, ...$params);
+ $out = [];
+
+ foreach ($res as $row) {
+ $out[$row->$key] = $row;
+ }
+
+ return $out;
+ }
+
+ public function iterate(string $query, ...$params): iterable
+ {
+ $db = $this->DB();
+ $query = $this->formatQuery($query);
+ $res = $db->preparedQuery($query, $params);
+
+ if ($db instanceof SQLite3) {
+ while ($row = $res->fetchArray(\SQLITE3_ASSOC)) {
+ // If you are getting a row containing only NULL values
+ // it probably means you are deleting rows before the iteration
+ // has a chance to fetch it!
+ $obj = new $this->class;
+ $obj->exists(true);
+ $obj->load($row);
+ yield $obj;
+ }
+
+ $res->finalize();
+ }
+ else {
+ while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $obj = new $this->class;
+ $obj->exists(true);
+ $obj->load($row);
+ yield $obj;
+ }
+ }
+ }
+
+ public function one(string $query, ...$params)
+ {
+ $db = $this->DB();
+
+ $query = $this->formatQuery($query);
+ $res = $db->preparedQuery($query, $params);
+
+ if ($db instanceof SQLite3) {
+ $row = $res->fetchArray(\SQLITE3_ASSOC);
+ $res->finalize();
+ }
+ else {
+ $row = $res->fetch(PDO::FETCH_ASSOC);
+ }
+
+ if (false === $row) {
+ return null;
+ }
+
+ $obj = new $this->class;
+ $obj->exists(true);
+ $obj->load($row);
+ return $obj;
+ }
+
+ public function col(string $query, ...$params)
+ {
+ $query = $this->formatQuery($query);
+ $db = $this->DB();
+ return $db->firstColumn($query, ...$params);
+ }
+
+ public function save(AbstractEntity $entity, bool $selfcheck = true): bool
+ {
+ if ($selfcheck) {
+ $entity->selfCheck();
+ }
+
+ $db = $this->DB();
+
+ if ($entity->exists()) {
+ if ($entity->isModified()) {
+ $data = array_intersect_key($entity->asArray(true), $entity->getModifiedProperties());
+ $return = $db->update($entity::TABLE, $data, $db->where('id', $entity->id()));
+ }
+ else {
+ $return = true;
+ }
+ }
+ else {
+ $data = $entity->asArray(true);
+ $data = array_filter($data, static function($v) { return $v !== null; });
+ $return = $db->insert($entity::TABLE, $data);
+
+ if ($return) {
+ $id = (int) $db->lastInsertId();
+
+ if ($id < 1) {
+ throw new \LogicException('Error inserting entity in DB: invalid ID = ' . $id);
+ }
+
+ $entity->exists(true);
+ $entity->id($id);
+ }
+ }
+
+ $entity->clearModifiedProperties();
+ return $return;
+ }
+
+ public function delete(AbstractEntity $entity): bool
+ {
+ $db = $this->DB();
+ $return = $db->delete($entity::TABLE, $db->where('id', $entity->id()));
+
+ if ($return) {
+ $entity->exists(false);
+ }
+
+ return $return;
+ }
+}
diff --git a/src/include/lib/KD2/DB/SQLite3.php b/src/include/lib/KD2/DB/SQLite3.php
new file mode 100644
index 0000000..65a85f3
--- /dev/null
+++ b/src/include/lib/KD2/DB/SQLite3.php
@@ -0,0 +1,1168 @@
+
+
+ Copyright (c) 2001-2020 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 KD2FW. If not, see .
+*/
+
+/**
+ * DB_SQLite3: a generic wrapper around SQLite3, adding easier access functions
+ * Compatible API with DB, but instead of using PDO, uses SQLite3
+ *
+ * @author bohwaz http://bohwaz.net/
+ * @license AGPLv3
+ */
+
+namespace KD2\DB;
+
+use KD2\DB\DB_Exception;
+
+use PDO;
+
+class SQLite3 extends DB
+{
+ /**
+ * @var \SQLite3
+ */
+ protected $db;
+
+ /**
+ * @var int
+ */
+ protected $transaction = 0;
+
+ /**
+ * @var integer|null
+ */
+ protected $flags = null;
+
+ const DATE_FORMAT = 'Y-m-d';
+ const DATETIME_FORMAT = 'Y-m-d H:i:s';
+
+ static protected array $_compile_options;
+
+ /**
+ * List of SQLite features and which version added support for it
+ * The key is the name of the feature, and the value is the version number
+ * if a compile option is required for that feature, it is mentioned after a plus sign
+ */
+ const FEATURES = [
+ // UPSERT
+ // https://www.sqlite.org/lang_upsert.html
+ 'upsert' => '3.25.0',
+
+ // UPDATE FROM
+ // https://www.sqlite.org/lang_update.html#upfrom
+ 'update_from' => '3.33.0',
+
+ // Generated columns
+ // https://www.sqlite.org/gencol.html
+ 'generated_columns' => '3.31.0',
+
+ // math functions
+ // https://www.sqlite.org/lang_mathfunc.html
+ 'math' => '3.35.0+ENABLE_MATH_FUNCTIONS',
+
+ // ALTER TABLE ... DROP COLUMN
+ // https://www.sqlite.org/lang_altertable.html#altertabdropcol
+ 'drop_column' => '3.35.0',
+
+ // ALTER TABLE ... RENAME COLUMN
+ 'rename_column' => '3.25.0',
+
+ // FULL and RIGHT OUTER JOIN
+ // https://www.sqlite.org/lang_select.html#rjoin
+ 'right_outer_join' => '3.39.0',
+ 'full_outer_join' => '3.39.0',
+
+ // Window functions
+ // Consider 3.28.0 instead of 3.25.0 as support is more extensive
+ // https://www.sqlite.org/windowfunctions.html
+ 'window_functions' => '3.28.0',
+
+ // FILTER for aggregates (eg. AVG(amount) FILTER (WHERE amount > 0))
+ // https://www.sqlite.org/lang_aggfunc.html#aggfilter
+ 'aggregate_filter' => '3.30.1',
+
+ // ORDER BY name DESC NULLS FIRST
+ // https://www.sqlite.org/lang_select.html#nullslast
+ 'nulls_first_last' => '3.30.0',
+
+ // VACUUM INTO
+ // https://www.sqlite.org/lang_vacuum.html#vacuuminto
+ 'vacuum_into' => '3.27.0',
+
+ // Common Table Expressions (WITH...)
+ // https://www.sqlite.org/lang_with.html
+ 'cte' => '3.8.3',
+
+ // PRAGMA table_list
+ // https://www.sqlite.org/pragma.html#pragma_table_list
+ 'pragma_table_list' => '3.37.0',
+
+ // unixepoch() date function
+ // https://www.sqlite.org/lang_datefunc.html#uepch
+ 'function_unixepoch' => '3.38.0',
+
+ // Use of date functions in CHECK constraints and in indexes on expressions
+ // https://www.sqlite.org/deterministic.html#dtexception
+ 'date_functions_in_constraints' => '3.20.0',
+
+ // INDEX on expressions
+ // https://www.sqlite.org/expridx.html
+ 'index_expressions' => '3.9.0',
+
+ // Basic json features
+ // https://www.sqlite.org/json1.html#jquote
+ 'json' => '3.9.0+ENABLE_JSON1|3.38.0-OMIT_JSON',
+ 'json_quote' => '3.14.0+ENABLE_JSON1|3.38.0-OMIT_JSON',
+
+ // json_patch function
+ // https://www.sqlite.org/json1.html#jpatch
+ 'json_patch' => '3.18.0+ENABLE_JSON1|3.38.0-OMIT_JSON',
+
+ // Support for -> and ->> operators
+ // https://www.sqlite.org/json1.html#jptr
+ 'json2' => '3.38.0-OMIT_JSON',
+
+ // Support for json_each AND read-only authorizer
+ // See https://sqlite.org/forum/forumpost/d28110be11
+ 'json_each_readonly' => '3.41.0',
+
+ 'fts3' => '3.5.0+ENABLE_FTS3',
+ 'fts4' => '3.7.4+ENABLE_FTS3|3.7.4+ENABLE_FTS4',
+ 'fts5' => '3.9.0+ENABLE_FTS5',
+
+ 'dbstat' => '3.0.0+ENABLE_DBSTAT_VTAB',
+ ];
+
+ public function close(): void
+ {
+ $this->__destruct();
+
+ if (null !== $this->db) {
+ $this->db->close();
+ }
+
+ $this->db = null;
+ }
+
+ public function __construct(string $driver, array $params)
+ {
+ if (!defined('\SQLITE3_OPEN_READWRITE'))
+ {
+ throw new \Exception('SQLite3 PHP module is not installed.');
+ }
+
+ if (isset($params['flags'])) {
+ $this->flags = $params['flags'];
+ }
+
+ parent::__construct($driver, $params);
+ }
+
+ public function __destruct()
+ {
+ if ($this->db) {
+ try {
+ foreach ($this->statements as $st) {
+ $st->close();
+ }
+ }
+ catch (\Exception $e) {
+ // Ignore errors
+ }
+ }
+
+ if ($this->db && ($this->flags & \SQLITE3_OPEN_READWRITE) && (time() % 20) == 0) {
+ // https://www.sqlite.org/pragma.html#pragma_optimize
+ // To achieve the best long-term query performance without the need to do
+ // a detailed engineering analysis of the application schema and SQL,
+ // it is recommended that applications run "PRAGMA optimize" (with no arguments)
+ // just before closing each database connection.
+ $this->db->exec('PRAGMA optimize;');
+ }
+
+ parent::__destruct();
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this);
+ }
+ }
+
+ public function connect(): void
+ {
+ if (null !== $this->db) {
+ return;
+ }
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this);
+ }
+
+ $file = str_replace('sqlite:', '', $this->driver->url);
+
+ if (null !== $this->flags) {
+ $flags = $this->flags;
+ }
+ else {
+ $flags = \SQLITE3_OPEN_READWRITE | \SQLITE3_OPEN_CREATE;
+ }
+
+ $this->db = new \SQLite3($file, $flags);
+
+ $this->db->enableExceptions(true);
+
+ $this->db->busyTimeout($this->pdo_attributes[PDO::ATTR_TIMEOUT] * 1000);
+
+ foreach ($this->sqlite_functions as $name => $callback)
+ {
+ if (is_array($callback) && $callback[0] === '$this') {
+ $callback = [$this, $callback[1]];
+ }
+
+ $this->db->createFunction($name, $callback);
+ }
+
+ // Force to rollback any outstanding transaction
+ register_shutdown_function(function () {
+ if ($this->db && $this->inTransaction())
+ {
+ $this->rollback();
+ }
+ });
+ }
+
+ public function createFunction(string $name, callable $callback): bool
+ {
+ if ($this->db)
+ {
+ return $this->db->createFunction($name, $callback);
+ }
+ else
+ {
+ $this->sqlite_functions[$name] = $callback;
+ return true;
+ }
+ }
+
+ public function createCollation(string $name, callable $callback): bool
+ {
+ if ($this->db)
+ {
+ return $this->db->createCollation($name, $callback);
+ }
+ else
+ {
+ $this->sqlite_collations[$name] = $callback;
+ return true;
+ }
+ }
+
+ public function escapeString(string $str): string
+ {
+ // escapeString is not binary safe: https://bugs.php.net/bug.php?id=62361
+ $str = str_replace("\0", "\\0", $str);
+
+ return \SQLite3::escapeString($str);
+ }
+
+ public function quote($str, int $parameter_type = 0): string
+ {
+ if (is_int($str)) {
+ return $str;
+ }
+
+ return '\'' . $this->escapeString($str) . '\'';
+ }
+
+ public function begin()
+ {
+ $this->transaction++;
+
+ if ($this->transaction == 1) {
+ $this->connect();
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this, ... func_get_args());
+ }
+
+ return $this->db->exec('BEGIN;');
+ }
+
+ return true;
+ }
+
+ public function inTransaction()
+ {
+ return $this->transaction > 0;
+ }
+
+ public function commit()
+ {
+ if ($this->transaction == 0) {
+ throw new \LogicException('Cannot commit a transaction: no transaction is running');
+ }
+
+ $this->transaction--;
+
+ if ($this->transaction == 0) {
+ $this->connect();
+
+ $return = $this->db->exec('END;');
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this, ... func_get_args());
+ }
+
+ return $return;
+ }
+
+ return true;
+ }
+
+ public function rollback()
+ {
+ if ($this->transaction == 0) {
+ throw new \LogicException('Cannot rollback a transaction: no transaction is running');
+ }
+
+ $this->transaction = 0;
+ $this->connect();
+ $this->db->exec('ROLLBACK;');
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, null, $this, ... func_get_args());
+ }
+
+ return true;
+ }
+
+ public function getArgType(&$arg, string $name = ''): int
+ {
+ switch (gettype($arg))
+ {
+ case 'double':
+ return \SQLITE3_FLOAT;
+ case 'integer':
+ case 'boolean':
+ return \SQLITE3_INTEGER;
+ case 'NULL':
+ return \SQLITE3_NULL;
+ case 'string':
+ return \SQLITE3_TEXT;
+ case 'array':
+ if (count($arg) == 2
+ && in_array($arg[0], [\SQLITE3_FLOAT, \SQLITE3_INTEGER, \SQLITE3_NULL, \SQLITE3_TEXT, \SQLITE3_BLOB]))
+ {
+ $type = $arg[0];
+ $arg = $arg[1];
+
+ return $type;
+ }
+ case 'object':
+ if ($arg instanceof \DateTime)
+ {
+ if ($arg->format('His') === '000000') {
+ $arg = $arg->format(self::DATE_FORMAT);
+ }
+ else {
+ $arg = $arg->format(self::DATETIME_FORMAT);
+ }
+
+ return \SQLITE3_TEXT;
+ }
+ default:
+ throw new \InvalidArgumentException('Argument '.$name.' is of invalid type '.gettype($arg));
+ }
+ }
+
+ /**
+ * Returns a statement after having checked a query is a SELECT,
+ * doesn't seem to contain anything that could help an attacker,
+ * and if $allowed is not NULL, will try to restrict the query to tables
+ * specified as array keys, and to columns (PHP8+ only) of these tables.
+ *
+ * Note that before PHP8+ this is less secure and doesn't restrict columns.
+ *
+ * @param array $allowed List of allowed tables and columns
+ * @param string $query SQL query
+ * @return \SQLite3Stmt
+ */
+ public function protectSelect(?array $allowed, string $query)
+ {
+ $query = trim($query, "\n\t\r ;");
+
+ if (preg_match('/;\s*(.+?)$/', $query, $match))
+ {
+ throw new DB_Exception('Only one single statement can be executed at the same time: ' . $match[0]);
+ }
+
+ // Forbid use of some strings that could give hints to an attacker:
+ // PRAGMA, sqlite_version(), sqlite_master table, comments
+ if (preg_match('/PRAGMA\s+|sqlite_version|sqlite_master|load_extension|ATTACH\s+|randomblob|sqlite_compileoption_|sqlite_offset|sqlite_source_|zeroblob|X\'\w|0x\w|sqlite_dbpage|fts3_tokenizer/i', $query, $match))
+ {
+ throw new DB_Exception('Invalid SQL query.');
+ }
+
+ if (null !== $allowed) {
+ // PHP 8+
+ if (method_exists($this->db, 'setAuthorizer')) {
+ $this->setAuthorizer(function (int $action, ...$args) use ($allowed) {
+ if ($action === \SQLite3::SELECT || $action === \SQLite3::FUNCTION) {
+ return \SQLite3::OK;
+ }
+
+ if ($action !== \SQLite3::READ) {
+ return \SQLite3::DENY;
+ }
+
+ list($table, $column) = $args;
+
+ if (!array_key_exists($table, $allowed) && !array_key_exists('*', $allowed)) {
+ return \SQLite3::DENY;
+ }
+
+ if (array_key_exists('!' . $table, $allowed)) {
+ return \SQLite3::DENY;
+ }
+
+ if (isset($allowed[$table]) && in_array('~' . $column, $allowed[$table])) {
+ return \SQLite3::IGNORE;
+ }
+
+ if (isset($allowed[$table]) && in_array('-' . $column, $allowed[$table])) {
+ return \SQLite3::DENY;
+ }
+
+ return \SQLite3::OK;
+ });
+ }
+ else {
+ static $forbidden = ['ALTER', 'ADD', 'ATTACH', 'CREATE', 'COMMIT', 'CREATE', 'DELETE', 'DETACH', 'DROP', 'INSERT', 'PRAGMA', 'REINDEX', 'RENAME', 'REPLACE', 'ROLLBACK', 'SAVEPOINT', 'SET', 'TRIGGER', 'UPDATE', 'VACUUM', 'WITH'];
+
+ $parsed = $this->parseQuery($query);
+
+ foreach ($parsed as $keyword) {
+ if (in_array($keyword, $forbidden)) {
+ throw new DB_Exception('Unauthorized keyword: ' . $keyword);
+ }
+
+ foreach ($keyword->tables as $table) {
+ if (!array_key_exists($table, $allowed) && !array_key_exists('*', $allowed)) {
+ throw new DB_Exception('Unauthorized table: ' . $table);
+ }
+
+ if (array_key_exists('!' . $table, $allowed)) {
+ throw new DB_Exception('Unauthorized table: ' . $table);
+ }
+
+ //if (null !== $allowed[$table]) {
+ // throw new \InvalidArgumentException('Cannot protect columns without PHP 8+');
+ //}
+ }
+ }
+ }
+ }
+
+ try {
+ $st = $this->prepare($query);
+ }
+ finally {
+ $this->setAuthorizer(null);
+ }
+
+ if (!$st->readOnly())
+ {
+ throw new DB_Exception('Only read-only queries are accepted.');
+ }
+
+ return $st;
+ }
+
+ public function setAuthorizer(?callable $fn): bool
+ {
+ if (method_exists(\SQLite3::class, 'setAuthorizer')) {
+ $this->connect();
+ $this->db->setAuthorizer($fn);
+ return true;
+ }
+
+ return false;
+ }
+
+ public function setReadOnly(bool $enable): void
+ {
+ // Make sure the database is always read-only
+ // @see https://www.sqlite.org/pragma.html#pragma_query_only
+ $this->exec(sprintf('PRAGMA query_only = %d;', $enable));
+ }
+
+ public function parseQuery(string $query): array
+ {
+ static $keywords_string = 'ABORT ACTION ADD AFTER ALL ALTER ALWAYS ANALYZE AND AS ASC ATTACH AUTOINCREMENT BEFORE BEGIN BETWEEN BY CASCADE CASE CAST CHECK COLLATE COLUMN COMMIT CONFLICT CONSTRAINT CREATE CROSS CURRENT CURRENT_DATE CURRENT_TIME CURRENT_TIMESTAMP DATABASE DEFAULT DEFERRABLE DEFERRED DELETE DESC DETACH DISTINCT DO DROP EACH ELSE END ESCAPE EXCEPT EXCLUDE EXCLUSIVE EXISTS EXPLAIN FAIL FILTER FIRST FOLLOWING FOR FOREIGN FROM FULL GENERATED GLOB GROUP GROUPS HAVING IF IGNORE IMMEDIATE IN INDEX INDEXED INITIALLY INNER INSERT INSTEAD INTERSECT INTO IS ISNULL JOIN KEY LAST LEFT LIKE LIMIT MATCH NATURAL NO NOT NOTHING NOTNULL NULL NULLS OF OFFSET ON OR ORDER OTHERS OUTER OVER PARTITION PLAN PRAGMA PRECEDING PRIMARY QUERY RAISE RANGE RECURSIVE REFERENCES REGEXP REINDEX RELEASE RENAME REPLACE RESTRICT RIGHT ROLLBACK ROW ROWS SAVEPOINT SELECT SET TABLE TEMP TEMPORARY THEN TIES TO TRANSACTION TRIGGER UNBOUNDED UNION UNIQUE UPDATE USING VACUUM VALUES VIEW VIRTUAL WHEN WHERE WINDOW WITH WITHOUT';
+
+ $keywords = explode(' ', $keywords_string);
+ $keywords = str_replace(' ', '|', $keywords);
+
+ $query = rtrim($query, ';');
+
+ preg_match_all('/((["\'])(?:\\\2|.)*?\2|\b(?:' . implode('|', $keywords) . ')\b|[\w]+(?:\s*\.\s*[\w]+)*)/ims', $query, $match);
+
+ $current = null;
+ $query = [];
+
+ foreach ($match[0] as $v) {
+ $kw = strtoupper($v);
+
+ if (in_array($kw, $keywords)) {
+ $query[$kw] = (object) ['tables' => [], 'content' => []];
+ $current = $kw;
+ }
+ elseif (null !== $current) {
+ if ($current == 'FROM' || $current == 'JOIN') {
+ $query[$current]->tables[] = $v;
+ }
+ else {
+ $query[$current]->content[] = $v;
+ }
+ }
+ }
+
+ return $query;
+ }
+
+ /**
+ * Executes a prepared query using $args array
+ * @return \SQLite3Stmt|boolean Returns a boolean if the query is writing
+ * to the database, or a statement if it's a read-only query.
+ *
+ * The fact that this method returns a boolean is voluntary, to avoid a bug
+ * in SQLite3/PHP where you can re-run a query by calling fetchResult
+ * on a statement. This could cause double writing.
+ */
+ public function preparedQuery(string $query, ...$args)
+ {
+ return parent::preparedQuery($query, ...$args);
+ }
+
+ public function execute($statement, ...$args)
+ {
+ if (!($statement instanceof \SQLite3Stmt)) {
+ throw new \InvalidArgumentException('Statement must be of type SQLite3Stmt');
+ }
+
+ // Forcer en tableau
+ $args = (array) $args;
+
+ $this->connect();
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ $statement->reset();
+ $nb = $statement->paramCount();
+
+ if (!empty($args))
+ {
+ if (is_array($args) && count($args) == 1 && is_array(current($args)))
+ {
+ $args = current($args);
+ }
+
+ if (count($args) != $nb)
+ {
+ throw new \LengthException(sprintf('Arguments error: %d supplied, but %d are required by query.',
+ count($args), $nb));
+ }
+
+ reset($args);
+
+ if (is_int(key($args)))
+ {
+ foreach ($args as $i=>$arg)
+ {
+ if (is_string($i))
+ {
+ throw new \InvalidArgumentException(sprintf('%s requires argument to be a keyed array, but key %s is a string.', __FUNCTION__, $i));
+ }
+
+ $type = $this->getArgType($arg, $i+1);
+ $statement->bindValue((int)$i+1, $arg, $type);
+ }
+ }
+ else
+ {
+ foreach ($args as $key=>$value)
+ {
+ if (is_int($key))
+ {
+ throw new \InvalidArgumentException(sprintf('%s requires argument to be a named-associative array, but key %s is an integer.', __FUNCTION__, $key));
+ }
+
+ $type = $this->getArgType($value, $key);
+ $statement->bindValue(':' . $key, $value, $type);
+ }
+ }
+ }
+
+ try {
+ $result = $statement->execute();
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ $is_readonly = $statement->readOnly();
+
+ // Make sure the statement is actually not readonly and not an EXPLAIN statement
+ // see https://sqlite.org/forum/forumpost/8f8453aa37
+ if (!$is_readonly
+ && ($sql = trim($statement->getSQL()))
+ && stristr(substr($sql, 0, 7), 'EXPLAIN')
+ && preg_match('/^EXPLAIN\s+QUERY\s+PLAN\s+[^;]+;?$/', $sql)) {
+ $is_readonly = true;
+ }
+
+ // Return a boolean for write queries to avoid accidental duplicate execution
+ // see https://bugs.php.net/bug.php?id=64531
+ return $is_readonly ? $result : (bool) $result;
+ }
+ catch (\Exception $e)
+ {
+ throw new DB_Exception($e->getMessage() . "\n" . json_encode($args, true), 0, $e);
+ }
+ }
+
+ public function query(string $statement)
+ {
+ $this->connect();
+ $statement = $this->applyTablePrefix($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ $return = $this->db->query($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ return $return;
+ }
+
+ public function iterate(string $statement, ...$args): iterable
+ {
+ $res = $this->preparedQuery($statement, ...$args);
+
+ while ($row = $res->fetchArray(\SQLITE3_ASSOC))
+ {
+ yield (object) $row;
+ }
+
+ $res->finalize();
+
+ return;
+ }
+
+ public function get(string $statement, ...$args): array
+ {
+ $res = $this->preparedQuery($statement, ...$args);
+ $out = [];
+
+ while ($row = $res->fetchArray(\SQLITE3_ASSOC))
+ {
+ $out[] = (object) $row;
+ }
+
+ $res->finalize();
+
+ return $out;
+ }
+
+ public function getAssoc(string $statement, ...$args): array
+ {
+ $res = $this->preparedQuery($statement, ...$args);
+ $out = [];
+
+ while ($row = $res->fetchArray(\SQLITE3_NUM))
+ {
+ $out[$row[0]] = $row[1];
+ }
+
+ $res->finalize();
+
+ return $out;
+ }
+
+ public function getGrouped(string $statement, ...$args): array
+ {
+ $res = $this->preparedQuery($statement, ...$args);
+ $out = [];
+
+ while ($row = $res->fetchArray(\SQLITE3_ASSOC))
+ {
+ $out[current($row)] = (object) $row;
+ }
+
+ $res->finalize();
+
+ return $out;
+ }
+
+ /**
+ * Executes multiple queries in a transaction
+ */
+ public function execMultiple(string $statement)
+ {
+ $this->begin();
+
+ try {
+ $statement = $this->applyTablePrefix($statement);
+ $this->db->exec($statement);
+ }
+ catch (\Exception $e)
+ {
+ $this->rollback();
+
+ if ($this->db->lastErrorCode()) {
+ throw new DB_Exception($this->db->lastErrorMsg(), $this->db->lastErrorCode(), $e);
+ }
+
+ throw $e;
+ }
+
+ return $this->commit();
+ }
+
+ public function exec(string $statement)
+ {
+ $this->connect();
+ $query = $this->applyTablePrefix($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ try {
+ $return = $this->db->exec($statement);
+ }
+ catch (\Exception $e) {
+ if ($this->db->lastErrorCode()) {
+ throw new DB_Exception($this->db->lastErrorMsg(), $this->db->lastErrorCode(), $e);
+ }
+
+ throw $e;
+ }
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ return $return;
+ }
+
+ /**
+ * Runs a query and returns the first row from the result
+ * @param string $query
+ * @return object|bool
+ *
+ * Accepts one or more arguments for the prepared query
+ */
+ public function first(string $query, ...$args)
+ {
+ $res = $this->preparedQuery($query, ...$args);
+
+ $row = $res->fetchArray(\SQLITE3_ASSOC);
+ $res->finalize();
+
+ return is_array($row) ? (object) $row : false;
+ }
+
+ /**
+ * Runs a query and returns the first column of the first row of the result
+ * @param string $query
+ * @return object
+ *
+ * Accepts one or more arguments for the prepared query
+ */
+ public function firstColumn(string $query, ...$args)
+ {
+ $res = $this->preparedQuery($query, ...$args);
+
+ $row = $res->fetchArray(\SQLITE3_NUM);
+ $res->finalize();
+
+ return (is_array($row) && count($row) > 0) ? $row[0] : false;
+ }
+
+ public function countRows(\SQLite3Result $result): int
+ {
+ $i = 0;
+
+ while ($result->fetchArray(\SQLITE3_NUM))
+ {
+ $i++;
+ }
+
+ $result->reset();
+
+ return $i;
+ }
+
+ public function lastInsertId($name = null): string
+ {
+ return $this->db->lastInsertRowId();
+ }
+
+ public function lastInsertRowId(): string
+ {
+ return $this->db->lastInsertRowId();
+ }
+
+ public function prepare(string $statement, array $driver_options = [])
+ {
+ $this->connect();
+ $query = $this->applyTablePrefix($statement);
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'before', $this, ... func_get_args());
+ }
+
+ try {
+ $return = $this->db->prepare($statement);
+ }
+ catch (\Exception $e) {
+ if ($this->db->lastErrorCode()) {
+ throw new DB_Exception($this->db->lastErrorMsg(), $this->db->lastErrorCode(), $e);
+ }
+
+ throw $e;
+ }
+
+ if ($this->callback) {
+ call_user_func($this->callback, __FUNCTION__, 'after', $this, ... func_get_args());
+ }
+
+ return $return;
+ }
+
+ public function openBlob(string $table, string $column, int $rowid, string $dbname = 'main', int $flags = \SQLITE3_OPEN_READONLY)
+ {
+ if (\PHP_VERSION_ID >= 70200)
+ {
+ return $this->db->openBlob($table, $column, $rowid, $dbname, $flags);
+ }
+ else
+ {
+ if ($flags != \SQLITE3_OPEN_READONLY)
+ {
+ throw new \Exception('Cannot open blob with read/write. Only available from PHP 7.2.0');
+ }
+
+ return $this->db->openBlob($table, $column, $rowid, $dbname);
+ }
+ }
+
+ /**
+ * Import a file containing SQL commands
+ * Allows to use the statement ".read other_file.sql" to load other files
+ * Also supported is the ".import file.csv table"
+ * @param string $file Path to file containing SQL commands
+ * @return boolean
+ */
+ public function import(string $file)
+ {
+ $sql = file_get_contents($file);
+ $sql = str_replace("\r\n", "\n", $sql);
+ $sql = preg_split("/\n{2,}/", $sql, -1, PREG_SPLIT_NO_EMPTY);
+
+ $statement = '';
+ $i = 0;
+
+ $dir = realpath(dirname($file));
+
+ foreach ($sql as $line) {
+ $line = trim($line);
+
+ // Sub-import statements
+ if (preg_match('/^\.read (.+\.sql)$/', $line, $match)) {
+ $this->import($dir . DIRECTORY_SEPARATOR . $match[1]);
+ $statement = '';
+ continue;
+ }
+ elseif (preg_match('/^\.import (.+\.csv) (\w+)$/', $line, $match)) {
+ $fp = fopen($dir . DIRECTORY_SEPARATOR . $match[1], 'r');
+ $st = null;
+
+ while ($row = fgetcsv($fp)) {
+ if (null === $st) {
+ $columns = substr(str_repeat('?, ', count($row)), 0, -2);
+ $st = $this->db->prepare(sprintf('INSERT INTO %s VALUES (%s);', $this->quoteIdentifier($match[2]), $columns));
+ }
+
+ foreach ($row as $i => $value) {
+ $st->bindValue($i + 1, $value);
+ }
+
+ $st->execute();
+ $st->reset();
+ $st->clear();
+ }
+
+ $statement = '';
+ continue;
+ }
+
+ $statement .= $line . "\n";
+
+ if (substr($line, -1) !== ';') {
+ continue;
+ }
+
+ try {
+ $this->exec($statement);
+ }
+ catch (\Exception $e) {
+ throw new \Exception(sprintf("Error in '%s': %s\n%s", basename($file), $e->getMessage(), $statement), 0, $e);
+ }
+
+ $statement = '';
+ }
+
+ return true;
+ }
+
+ /**
+ * Performs a foreign key check and throws an exception if any error is found
+ * @return void
+ * @throws \LogicException
+ * @see https://www.sqlite.org/pragma.html#pragma_foreign_key_check
+ */
+ public function foreignKeyCheck(): void
+ {
+ $result = $this->get('PRAGMA foreign_key_check;');
+
+ // No error
+ if (!count($result)) {
+ return;
+ }
+
+ $errors = [];
+ $tables = [];
+ $ref = null;
+
+ foreach ($result as $row) {
+ if (!array_key_exists($row->table, $tables)) {
+ $tables[$row->table] = $this->get(sprintf('PRAGMA foreign_key_list(%s);', $row->table));
+ }
+
+ // Findinf the referenced foreign key
+ foreach ($tables[$row->table] as $fk) {
+ if ($fk->id == $row->fkid) {
+ $ref = $fk;
+ break;
+ }
+ }
+
+ $data = $this->first(sprintf('SELECT * FROM %s WHERE rowid = ?;', $row->table), $row->rowid);
+ $errors[] = sprintf("%s (%s): row %d has an invalid reference to %s (%s)\n%s", $row->table, $ref->from, $row->rowid, $row->parent, $ref ? $ref->to : null, json_encode($data));
+ }
+
+ throw new \LogicException(sprintf("Foreign key check: %d errors found\n", count($errors)) . implode("\n", $errors));
+ }
+
+ public function backup($destination, string $sourceDatabase = 'main' , string $destinationDatabase = 'main'): bool
+ {
+ if (is_a($destination, self::class)) {
+ $destination = $destination->db;
+ }
+
+ return $this->db->backup($destination, $sourceDatabase, $destinationDatabase);
+ }
+
+ static public function getDatabaseDetailsFromString(string $source_string): array
+ {
+ if (substr($source_string, 0, 16) !== "SQLite format 3\0" || strlen($source_string) < 100) {
+ return null;
+ }
+
+ $user_version = bin2hex(substr($source_string, 60, 4));
+ $application_id = bin2hex(substr($source_string, 68, 4));
+
+ return compact('user_version', 'application_id');
+ }
+
+ /**
+ * Returns compile options
+ */
+ public function getCompileOptions(): array
+ {
+ if (!isset(self::$_compile_options)) {
+ self::$_compile_options = [];
+ $db = new \SQLite3(':memory:');
+ $res = $db->query('PRAGMA compile_options;');
+
+ while ($row = $res->fetchArray(\SQLITE3_NUM)) {
+ self::$_compile_options[] = $row[0];
+ }
+
+ $db->close();
+ }
+
+ return self::$_compile_options;
+ }
+
+ /**
+ * Returns a list of supported features
+ */
+ public function getFeatures(): array
+ {
+ $version = \SQLite3::version()['versionString'];
+ $compile_options = $this->getCompileOptions();
+
+ foreach (self::FEATURES as $feature => $test_string) {
+ $tests = explode('|', $test_string);
+
+ foreach ($tests as $test) {
+ if (!preg_match('/^([\d\.]+)(?:\+([A-Z0-9_]+))?(?:-([A-Z0-9_]+))?$/', $test, $match)) {
+ throw new \LogicException('Invalid test string: ' . $test);
+ }
+
+ if (!version_compare($version, $match[1], '>=')) {
+ continue;
+ }
+
+ if (!empty($match[2]) && !in_array($match[2], $compile_options)) {
+ continue;
+ }
+
+ // if this option is present, then the feature is disabled
+ if (!empty($match[3]) && in_array($match[3], $compile_options)) {
+ continue;
+ }
+
+ $out[] = $feature;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Check for features
+ * ->hasFeatures('json', 'update_from')
+ */
+ public function hasFeatures(...$features): bool
+ {
+ $all = $this->getFeatures();
+ $found = array_intersect($all, $features);
+ return count($found) == count($features);
+ }
+
+ public function requireFeatures(...$features): void
+ {
+ $missing_features = array_diff($features, $this->getFeatures());
+
+ if (count($missing_features)) {
+ $version = \SQLite3::version()['versionString'];
+ throw new DB_Exception(sprintf('The required SQLite features (%s) are not available in the installed SQLite version (%s).', implode(', ', $missing_features), $version));
+ }
+ }
+
+ public function getTableSchema(string $name): array
+ {
+ $fk = [];
+
+ $r = $this->db->query(sprintf('PRAGMA foreign_key_list(%s);', $name));
+
+ while ($row = $r->fetchArray(\SQLITE3_ASSOC)) {
+ $columns = explode(',', $row['from']);
+ $columns = array_map('trim', $columns);
+
+ foreach ($columns as $c) {
+ $fk[$c] = $row;
+ }
+ }
+
+ $r->finalize();
+
+ $table = ['name' => $name, 'comment' => null, 'columns' => []];
+
+ $name = $this->quote($name);
+ $schema = $this->db->querySingle(sprintf('SELECT sql FROM sqlite_master WHERE name = %s AND type = \'table\';', $name));
+
+ if (preg_match('/CREATE\s+TABLE\s+(?s:(?!\(|--).*?)--[ ]*(.+)$\s*\(/m', $schema, $match)) {
+ $table['comment'] = trim($match[1]);
+ }
+
+ $r = $this->db->query(sprintf('PRAGMA table_info(%s);', $name));
+
+ while ($row = $r->fetchArray(\SQLITE3_ASSOC)) {
+ $row['fk'] = $fk[$row['name']] ?? null;
+ $row['comment'] = null;
+
+ $regexp = sprintf('/\b%s\s+.*?--(.*?)$/m', preg_quote($row['name'], '/'));
+
+ if (preg_match($regexp, $schema, $match)) {
+ $row['comment'] = trim($match[1]);
+ }
+
+ $table['columns'][$row['name']] = $row;
+ }
+
+ $table['schema'] = $schema;
+
+ $r->finalize();
+
+ return $table;
+ }
+
+ public function getTableIndexes(string $name): array
+ {
+ $columns = [];
+
+ $r = $this->db->query(sprintf('PRAGMA index_list(%s);', $name));
+
+ while ($row = $r->fetchArray(\SQLITE3_ASSOC)) {
+ $r2 = $this->db->query(sprintf('PRAGMA index_xinfo(%s);', $row['name']));
+ $row['columns'] = [];
+
+ while ($row2 = $r2->fetchArray(\SQLITE3_ASSOC)) {
+ $row['columns'][$row2['name']] = $row2;
+ }
+
+ $r2->finalize();
+ $columns[] = $row;
+ }
+
+ $r->finalize();
+
+ return $columns;
+ }
+
+ public function getTableSize(string $name): ?int
+ {
+ if (!$this->hasFeatures('dbstat')) {
+ return null;
+ }
+
+ return (int) $this->db->querySingle(sprintf('SELECT SUM(pgsize) FROM dbstat WHERE name = %s;', $this->quote($name)), false);
+ }
+}
diff --git a/src/include/lib/KD2/DB/SQLite3_Undo.php b/src/include/lib/KD2/DB/SQLite3_Undo.php
new file mode 100644
index 0000000..21e61ff
--- /dev/null
+++ b/src/include/lib/KD2/DB/SQLite3_Undo.php
@@ -0,0 +1,105 @@
+
+
+ Copyright (c) 2001-2020 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 KD2FW. If not, see .
+*/
+
+/**
+ * DB_SQLite3_Undo: adds the ability to undo/redo any SQL statement to a SQLite3 database
+ *
+ * @author bohwaz http://bohwaz.net/
+ * @license AGPLv3
+ */
+
+namespace KD2\DB;
+
+use PDO;
+
+class SQLite3_Undo
+{
+ protected $db;
+
+ public function __construct(DB $db)
+ {
+ $this->db = $db;
+ }
+
+ public function disable(array $tables)
+ {
+ $db = $this->db;
+
+ foreach ($tables as $name) {
+ $sql = 'SELECT name, name FROM sqlite_master WHERE type = \'trigger\' AND name LIKE \'!_%s_log!__t\' ESCAPE \'!\';';
+ $sql = sprintf($sql, $name);
+ $triggers = $db->getAssoc($sql);
+
+ foreach ($triggers as $trigger)
+ {
+ $db->exec(sprintf('DROP TRIGGER %s;', $db->quoteIdentifier($trigger)));
+ }
+ }
+ }
+
+ public function enable(array $tables)
+ {
+ $db = $this->db;
+
+ $db->exec('CREATE TABLE IF NOT EXISTS undolog (
+ seq INTEGER PRIMARY KEY,
+ table TEXT NOT NULL,
+ action TEXT NOT NULL
+ sql TEXT NOT NULL,
+ date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ );');
+
+ $query = 'CREATE TRIGGER _%table_log_it AFTER INSERT ON %table BEGIN
+ DELETE FROM undolog WHERE rowid IN (SELECT rowid FROM undolog LIMIT 500,1000);
+ INSERT INTO undolog (table, action, sql) VALUES (\'%table\', \'I\', \'DELETE FROM %table WHERE rowid=\'||new.rowid);
+ END;
+ CREATE TRIGGER _%table_log_ut AFTER UPDATE ON %table BEGIN
+ DELETE FROM undolog WHERE rowid IN (SELECT rowid FROM undolog LIMIT 500,1000);
+ INSERT INTO undolog (table, action, sql) VALUES (\'%table\', \'U\', \'UPDATE %table SET %columns_update WHERE rowid = \'||old.rowid);
+ END;
+ CREATE TRIGGER _%table_log_dt BEFORE DELETE ON %table BEGIN
+ DELETE FROM undolog WHERE rowid IN (SELECT rowid FROM undolog LIMIT 500,1000);
+ INSERT INTO undolog (table, action, sql) VALUES (\'%table\', \'D\', \'INSERT INTO %table (rowid, %columns_list) VALUES(\'||old.rowid||\', %columns_insert)\');
+ END;';
+
+ foreach ($tables as $table)
+ {
+ $columns = $db->getAssoc(sprintf('PRAGMA table_info(%s);', $this->quoteIdentifier($table)));
+ $columns_insert = [];
+ $columns_update = [];
+
+ foreach ($columns as &$name)
+ {
+ $columns_update[] = sprintf('%s = \'||quote(old.%1$s)||\'', $name);
+ $columns_insert[] = sprintf('\'||quote(old.%s)||\'', $name);
+ }
+
+ $sql = strtr($query, [
+ '%table' => $table,
+ '%columns_list' => implode(', ', $columns),
+ '%columns_update' => implode(', ', $columns_update),
+ '%columns_insert' => implode(', ', $columns_insert),
+ ]);
+
+ $db->exec($sql);
+ }
+ }
+}
diff --git a/src/include/lib/KD2/DNS.php b/src/include/lib/KD2/DNS.php
new file mode 100644
index 0000000..2760218
--- /dev/null
+++ b/src/include/lib/KD2/DNS.php
@@ -0,0 +1,205 @@
+ ['42.42.42.42']
+ * @author bohwaz
+ * @see https://github.com/DaveRandom/LibDNS
+ * @see https://cabulous.medium.com/dns-message-how-to-read-query-and-response-message-cfebcb4fe817
+ */
+ static public function getRecordsFrom(string $server, string $type, string $record, string $protocol = 'udp'): array
+ {
+ // Source: https://github.com/metaregistrar/php-dns-client/blob/master/DNS/dnsData/dnsTypes.php
+ static $types = [
+ 1 => 'A',
+ 2 => 'NS',
+ 5 => 'CNAME',
+ 6 => 'SOA',
+ 12 => 'PTR',
+ 15 => 'MX',
+ 16 => 'TXT',
+ 28 => 'AAAA',
+ 255 => 'ANY',
+ ];
+
+ $typeid = array_search($type, $types, true);
+
+ if (!$typeid) {
+ throw new \InvalidArgumentException('Invalid type');
+ }
+
+ $host = $protocol . '://' . $server;
+
+ if (!$socket = @fsockopen($host, 53, $errno, $errstr, 10)) {
+ throw new \RuntimeException('Failed to open socket to ' . $host);
+ }
+
+ //stream_set_chunk_size($socket, 0xffff);
+ //stream_set_blocking($socket, false);
+
+ $labels = explode('.', $record);
+ $question_binary = '';
+
+ foreach ($labels as $label) {
+ $question_binary .= pack("C", strlen($label)); // size byte first
+ $question_binary .= $label; // then the label
+ }
+
+ $question_binary .= pack("C", 0); // end it off
+
+ $id = rand(1,255)|(rand(0,255)<<8); // generate the ID
+
+ // Set standard codes and flags
+ $flags = (0x0100 & 0x0300) | 0x0020; // recursion & queryspecmask | authenticated data
+
+ $opcode = 0x0000; // opcode
+
+ // Build the header
+ $header = "";
+ $header .= pack("n", $id);
+ $header .= pack("n", $opcode | $flags);
+ $header .= pack("nnnn", 1, 0, 0, 0);
+ $header .= $question_binary;
+ $header .= pack("n", $typeid);
+ $header .= pack("n", 0x0001); // internet class
+ $headersize = strlen($header);
+ $headersizebin = pack("n", $headersize);
+ $header = $headersizebin . $header;
+
+ $request_size = fwrite($socket, $header, $headersize);
+ $rawbuffer = fread($socket, 1);
+ fclose($socket);
+
+ if (strlen($rawbuffer) < 12) {
+ throw new \UnderflowException("DNS query return buffer too small");
+ }
+
+ $pos = 0;
+
+ $read = function ($len) use (&$pos, $rawbuffer) {
+ $out = substr($rawbuffer, $pos, $len);
+ $pos += $len;
+ return $out;
+ };
+
+ $read_name_pos = function ($offset) use ($rawbuffer) {
+ $out = [];
+
+ while (($len = ord(substr($rawbuffer, $offset, 1))) && $len > 0) {
+ $out[] = substr($rawbuffer, $offset + 1, $len);
+ $offset += $len + 1;
+ }
+
+ return $out;
+ };
+
+ $read_name = function() use (&$read, $read_name_pos) {
+ $out = [];
+
+ while (($len = ord($read(1))) && $len > 0) {
+ if ($len >= 64) {
+ $offset = (($len & 0x3f) << 8) + ord($read(1));
+ $out = array_merge($out, $read_name_pos($offset));
+ break;
+ }
+ else {
+ $out[] = $read($len);
+ }
+ }
+
+ return implode('.', $out);
+ };
+
+ $header = unpack("nid/nfields/nqdcount/nancount/nnscount/narcount", $read(12));
+ $fields = $header['fields'];
+
+ $flags = new \stdClass;
+ $flags->rcode = $fields & 0xf;
+ $flags->ra = (($fields >> 7) & 1) === 1;
+ $flags->rd = (($fields >> 8) & 1) === 1;
+ $flags->tc = (($fields >> 9) & 1) === 1;
+ $flags->aa = (($fields >> 10) & 1) === 1;
+ $flags->opcode = ($fields >> 11) & 0xf;
+ $flags->qr = (($fields >> 15) & 1) === 1;
+
+
+ if ($flags->tc) {
+ throw new \OverflowException('The DNS server returned a truncated result for a UDP query');
+ }
+
+ // No answers
+ if (!$header['ancount']) {
+ return [];
+ }
+
+ $is_authorative = $flags->aa;
+
+ // Question section
+ if ($header['qdcount']) {
+ // Skip name
+ $read_name();
+
+ // skip question part
+ $pos += 4; // 4 => QTYPE + QCLASS
+ }
+
+ $responses = [];
+
+ for ($a = 0; $a < $header['ancount']; $a++) {
+ $read_name(); // Skip name
+ $ans_header = unpack("ntype/nclass/Nttl/nlength", $read(10));
+
+ $t = $types[$ans_header['type']] ?? null;
+
+ if ($type != 'ANY' && $t != $type) {
+ // Skip type that was not requested
+ $t = null;
+ }
+
+ switch ($t) {
+ case 'A':
+ $responses[] = implode(".", unpack("Ca/Cb/Cc/Cd", $read(4)));
+ break;
+ case 'AAAA':
+ $responses[] = implode(':', unpack("H4a/H4b/H4c/H4d/H4e/H4f/H4g/H4h", $read(16)));
+ break;
+ case 'MX':
+ $prio = unpack('nprio', $read(2)); // priority
+ $responses[$prio['prio']] = $read_name();
+ break;
+ case 'NS':
+ case 'CNAME':
+ case 'PTR':
+ $responses[] = $read_name();
+ break;
+ case 'TXT':
+ $responses[] = $read($ans_header['length']);
+ break;
+ default:
+ // Skip
+ $read($ans_header['length']);
+ break;
+ }
+ }
+
+ return $responses;
+ }
+}
diff --git a/src/include/lib/KD2/Delta.php b/src/include/lib/KD2/Delta.php
new file mode 100644
index 0000000..4a96420
--- /dev/null
+++ b/src/include/lib/KD2/Delta.php
@@ -0,0 +1,693 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+/**
+ * Delta algorithm
+ * ported from the C code of Fossil SCM
+ * http://www.fossil-scm.org/xfer/doc/trunk/www/delta_format.wiki
+ * http://www.fossil-scm.org/xfer/doc/trunk/www/delta_encoder_algorithm.wiki
+ */
+
+/*
+** Copyright (c) 2006 D. Richard Hipp
+** Copyright (c) 2013 BohwaZ (PHP port)
+**
+** Authors contact information:
+** drh@hwaci.com
+** http://www.hwaci.com/drh/
+**
+** http://bohwaz.net/
+**
+*******************************************************************************
+**/
+
+class Delta_Hash
+{
+ public $a; /* Hash values */
+ public $b;
+ public $i; /* Start of the hash window */
+ public $z; /* The values that have been hashed */
+
+ public function __set($key, $value)
+ {
+ if (($key == 'a' || $key == 'b' || $key == 'i') && (!is_int($value) || $value > 2^16)) {
+ throw new \OutOfBoundsException($key . ' value must be a 16 bits integer');
+ }
+
+ if ($key != 'z')
+ {
+ $this->{$key} = $value & 0xffff;
+ }
+ else
+ {
+ $this->{$key} = $value;
+ }
+ }
+}
+
+class Delta
+{
+ const NHASH = 16;
+ public $debug_enabled = false;
+
+ protected function debug($msg)
+ {
+ echo str_replace("\n", '.', $msg) . "\n";
+ return;
+ }
+
+ /**
+ * Emulates C-like 32-bits integer
+ * @param mixed $number
+ * @return integer Unsigned integer
+ */
+ protected function u32($number)
+ {
+ return $number & 0xffffffff;
+ }
+
+ /**
+ * Emulates C-like 16-bit integer
+ * @param mixed $number
+ * @return mixed Unsigned integer
+ */
+ protected function u16($number)
+ {
+ return $number & 0xffff;
+ }
+
+ /*
+ ** Initialize the rolling hash using the first NHASH characters of z[]
+ */
+ protected function hash_init(Delta_Hash &$pHash, $z)
+ {
+ $a = $b = 0;
+
+ for ($i=0; $i < self::NHASH; $i++) {
+ $a += ord($z[$i]);
+ $b += (self::NHASH - $i) * ord($z[$i]);
+ $pHash->z[$i] = ord($z[$i]);
+ }
+
+ $pHash->a = $a & 0xffff;
+ $pHash->b = $b & 0xffff;
+ $pHash->i = 0;
+ }
+
+ /*
+ ** Advance the rolling hash by a single character "c"
+ */
+ protected function hash_next(Delta_Hash &$pHash, $c)
+ {
+ $old = $this->u16($pHash->z[$pHash->i]);
+ $pHash->z[$pHash->i] = $c;
+ $pHash->i = ($pHash->i+1) & (self::NHASH - 1);
+ $pHash->a = $pHash->a - $old + $c;
+ $pHash->b = $pHash->b - self::NHASH * $old + $pHash->a;
+ }
+
+ /*
+ ** Return a 32-bit hash value
+ */
+ protected function hash_32bit(Delta_Hash $pHash)
+ {
+ return ($pHash->a & 0xffff) | sprintf('%u', $this->u32(($pHash->b & 0xffff)<<16));
+ }
+
+ /*
+ ** Write an base-64 integer into the given buffer.
+ */
+ protected function putInt($v)
+ {
+ static $zDigits = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~';
+ /* 123456789 123456789 123456789 123456789 123456789 123456789 123 */
+
+ $zBuf = (string)'';
+ $pz = '';
+
+ if ( $v == 0 ) {
+ return $pz . '0';
+ }
+
+ for ($i=0; $v>0; $i++, $v>>=6) {
+ $zBuf[$i] = $zDigits[$v&0x3f];
+ }
+
+ for ($j = $i-1; $j>=0; $j--) {
+ $pz .= $zBuf[$j];
+ }
+
+ return $pz;
+ }
+
+ /*
+ ** Read bytes from *pz and convert them into a positive integer. When
+ ** finished, leave *pz pointing to the first character past the end of
+ ** the integer. The *pLen parameter holds the length of the string
+ ** in *pz and is decremented once for each character in the integer.
+ */
+ protected function getInt(&$pz, &$pLen)
+ {
+ $zValue = array(
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1,
+ -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
+ 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36,
+ -1, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
+ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
+ );
+
+ foreach ($zValue as &$row)
+ {
+ $row = $this->u32($row);
+ }
+
+ $v = 0;
+ $z = 0;
+
+ while ( ($c = $zValue[0x7f&ord($pz[$z++])]) != $this->u32(-1) ) {
+ $v = ($v<<6) + $c;
+ }
+
+ $z--;
+ $pz = substr($pz, $z);
+ $pLen = strlen($pz);
+ return $v;
+ }
+
+ /*
+ ** Return the number digits in the base-64 representation of a positive integer
+ */
+ protected function digit_count($v)
+ {
+ for($i=1, $x=64; $v >= $x; $i++, $x <<= 6){}
+ return $i;
+ }
+
+ /*
+ ** Compute a 32-bit checksum on the N-byte buffer. Return the result.
+ */
+ protected function checksum($z, $N)
+ {
+ $sum0 = $sum1 = $sum2 = $sum3 = (float)0.0;
+
+ while ($N >= 16)
+ {
+ $sum0 += (ord($z[0]) + ord($z[4]) + ord($z[8]) + ord($z[12]));
+ $sum1 += (ord($z[1]) + ord($z[5]) + ord($z[9]) + ord($z[13]));
+ $sum2 += (ord($z[2]) + ord($z[6]) + ord($z[10]) + ord($z[14]));
+ $sum3 += (ord($z[3]) + ord($z[7]) + ord($z[11]) + ord($z[15]));
+ $z = substr($z, 16);
+ $N -= 16;
+ }
+
+ while ($N >= 4) {
+ $sum0 += ord($z[0]);
+ $sum1 += ord($z[1]);
+ $sum2 += ord($z[2]);
+ $sum3 += ord($z[3]);
+ $z = substr($z, 4);
+ $N -= 4;
+ }
+
+ $sum3 += $this->u32($sum2 << 8) + $this->u32($sum1 << 16) + $this->u32($sum0 << 24);
+
+ switch ($N) {
+ case 3: $sum3 += $this->u32(ord($z[2]) << 8);
+ case 2: $sum3 += $this->u32(ord($z[1]) << 16);
+ case 1: $sum3 += $this->u32(ord($z[0]) << 24);
+ default: ;
+ }
+
+ $sum3 = $this->u32($sum3);
+ return sprintf('%u', $sum3);
+ }
+
+ /*
+ ** Create a new delta.
+ **
+ ** The delta is written into a preallocated buffer, zDelta, which
+ ** should be at least 60 bytes longer than the target file, zOut.
+ ** The delta string will be NUL-terminated, but it might also contain
+ ** embedded NUL characters if either the zSrc or zOut files are
+ ** binary. This function returns the length of the delta string
+ ** in bytes, excluding the final NUL terminator character.
+ **
+ ** Output Format:
+ **
+ ** The delta begins with a base64 number followed by a newline. This
+ ** number is the number of bytes in the TARGET file. Thus, given a
+ ** delta file z, a program can compute the size of the output file
+ ** simply by reading the first line and decoding the base-64 number
+ ** found there. The delta_output_size() routine does exactly this.
+ **
+ ** After the initial size number, the delta consists of a series of
+ ** literal text segments and commands to copy from the SOURCE file.
+ ** A copy command looks like this:
+ **
+ ** NNN@MMM,
+ **
+ ** where NNN is the number of bytes to be copied and MMM is the offset
+ ** into the source file of the first byte (both base-64). If NNN is 0
+ ** it means copy the rest of the input file. Literal text is like this:
+ **
+ ** NNN:TTTTT
+ **
+ ** where NNN is the number of bytes of text (base-64) and TTTTT is the text.
+ **
+ ** The last term is of the form
+ **
+ ** NNN;
+ **
+ ** In this case, NNN is a 32-bit bigendian checksum of the output file
+ ** that can be used to verify that the delta applied correctly. All
+ ** numbers are in base-64.
+ **
+ ** Pure text files generate a pure text delta. Binary files generate a
+ ** delta that may contain some binary data.
+ **
+ ** Algorithm:
+ **
+ ** The encoder first builds a hash table to help it find matching
+ ** patterns in the source file. 16-byte chunks of the source file
+ ** sampled at evenly spaced intervals are used to populate the hash
+ ** table.
+ **
+ ** Next we begin scanning the target file using a sliding 16-byte
+ ** window. The hash of the 16-byte window in the target is used to
+ ** search for a matching section in the source file. When a match
+ ** is found, a copy command is added to the delta. An effort is
+ ** made to extend the matching section to regions that come before
+ ** and after the 16-byte hash window. A copy command is only issued
+ ** if the result would use less space that just quoting the text
+ ** literally. Literal text is added to the delta for sections that
+ ** do not match or which can not be encoded efficiently using copy
+ ** commands.
+ */
+
+ /**
+ * Computes the difference between $zSrc and $zOut and returns the delta
+ * @param string $zSrc Binary content of source file
+ * @param string $zOut Binary content of target file
+ * @return string Delta
+ */
+ public function create($zSrc, $zOut)
+ {
+ $lenSrc = strlen($zSrc);
+ $lenOut = strlen($zOut);
+
+ $zDelta = '';
+
+ $h = new Delta_Hash;
+ $nHash = 0; /* Number of hash table entries */
+ $landmark = 0; /* Primary hash table */
+ $collide = 0; /* Collision chain */
+ $lastRead = 0xffffffff; /* Last byte of zSrc read by a COPY command */
+
+ /* Add the target file size to the beginning of the delta
+ */
+ $zDelta .= $this->putInt($lenOut);
+ $zDelta .= "\n";
+
+ /* If the source file is very small, it means that we have no
+ ** chance of ever doing a copy command. Just output a single
+ ** literal segment for the entire target and exit.
+ */
+ if ($lenSrc <= self::NHASH)
+ {
+ $zDelta .= $this->putInt($lenOut);
+ $zDelta .= ':';
+ $zDelta .= substr($zOut, 0, $lenOut);
+ $zDelta .= $this->putInt($this->checksum($zOut, $lenOut));
+ $zDelta .= ';';
+ return $zDelta;
+ }
+
+ /* Compute the hash table used to locate matching sections in the
+ ** source file.
+ */
+ $nHash = (int) ($lenSrc / self::NHASH);
+ $collide = array_fill(0, $nHash * 2 * PHP_INT_SIZE, $this->u32(-1));
+ $landmark = array_slice($collide, $nHash);
+
+ for ($i = 0; $i < $lenSrc - self::NHASH; $i += self::NHASH)
+ {
+ $this->hash_init($h, substr($zSrc, $i, self::NHASH));
+ $hv = $this->hash_32bit($h) % $nHash;
+ $collide[$i / self::NHASH] = $landmark[$hv];
+ $landmark[$hv] = $i / self::NHASH;
+ }
+
+ /* Begin scanning the target file and generating copy commands and
+ ** literal sections of the delta.
+ */
+ $base = 0; /* We have already generated everything before zOut[base] */
+ while ($base + self::NHASH < $lenOut)
+ {
+ $bestOfst = 0;
+ $bestLitsz = 0;
+
+ $this->hash_init($h, substr($zOut, $base, self::NHASH));
+
+ $i = 0; /* Trying to match a landmark against zOut[base+i] */
+ $bestCnt = 0;
+
+ while (1)
+ {
+ $limit = 250;
+
+ $hv = $this->hash_32bit($h) % $nHash;
+
+ if ($this->debug_enabled) {
+ $this->debug(sprintf("LOOKING: %4d [%s]", $base+$i, substr($zOut, $base + $i, 16)));
+ }
+
+ $iBlock = $landmark[$hv];
+
+ while ($iBlock != $this->u32(-1) && $iBlock >= 0 && ($limit--) > 0)
+ {
+ /*
+ ** The hash window has identified a potential match against
+ ** landmark block iBlock. But we need to investigate further.
+ **
+ ** Look for a region in zOut that matches zSrc. Anchor the search
+ ** at zSrc[iSrc] and zOut[base+i]. Do not include anything prior to
+ ** zOut[base] or after zOut[outLen] nor anything after zSrc[srcLen].
+ **
+ ** Set cnt equal to the length of the match and set ofst so that
+ ** zSrc[ofst] is the first element of the match. litsz is the number
+ ** of characters between zOut[base] and the beginning of the match.
+ ** sz will be the overhead (in bytes) needed to encode the copy
+ ** command. Only generate copy command if the overhead of the
+ ** copy command is less than the amount of literal text to be copied.
+ */
+
+ /* Beginning at iSrc, match forwards as far as we can. j counts
+ ** the number of characters that match */
+ $iSrc = $iBlock * self::NHASH;
+
+ for($j = 0, $x = $iSrc, $y = $base + $i; $x < $lenSrc && $y < $lenOut; $j++, $x++, $y++)
+ {
+ if ($zSrc[$x] != $zOut[$y])
+ {
+ break;
+ }
+ }
+
+ $j--;
+
+ /* Beginning at iSrc-1, match backwards as far as we can. k counts
+ ** the number of characters that match */
+ for ($k = 1; $k < $iSrc && $k <= $i; $k++)
+ {
+ if ($zSrc[$iSrc - $k] != $zOut[$base + $i - $k])
+ break;
+ }
+
+ $k--;
+
+ /* Compute the offset and size of the matching region */
+ $ofst = $iSrc - $k;
+ $cnt = $j + $k + 1;
+ $litsz = $i - $k; /* Number of bytes of literal text before the copy */
+
+ if ($this->debug_enabled) {
+ $this->debug(sprintf("MATCH %d bytes at %d: [%s] litsz=%d", $cnt, $ofst, substr($zSrc, $ofst, 16), $litsz));
+ }
+
+ /* sz will hold the number of bytes needed to encode the "insert"
+ ** command and the copy command, not counting the "insert" text */
+ $sz = $this->digit_count($i - $k) + $this->digit_count($cnt) + $this->digit_count($ofst) + 3;
+
+ if ($cnt >= $sz && $cnt > $bestCnt )
+ {
+ /* Remember this match only if it is the best so far and it
+ ** does not increase the file size */
+ $bestCnt = $cnt;
+ $bestOfst = $iSrc - $k;
+ $bestLitsz = $litsz;
+
+ if ($this->debug_enabled) {
+ $this->debug(sprintf("... BEST SO FAR"));
+ }
+ }
+
+ /* Check the next matching block */
+ $iBlock = $collide[$iBlock];
+ }
+
+ /* We have a copy command that does not cause the delta to be larger
+ ** than a literal insert. So add the copy command to the delta.
+ */
+ if ($bestCnt > 0)
+ {
+ if ($bestLitsz > 0)
+ {
+ /* Add an insert command before the copy */
+ $zDelta .= $this->putInt($bestLitsz);
+ $zDelta .= ':';
+ $zDelta .= substr($zOut, $base, $bestLitsz);
+ $base += $bestLitsz;
+
+ if ($this->debug_enabled) {
+ $this->debug(sprintf("insert %d", $bestLitsz));
+ }
+ }
+
+ $base += $bestCnt;
+ $zDelta .= $this->putInt($bestCnt);
+ $zDelta .= '@';
+ $zDelta .= $this->putInt($bestOfst);
+
+ if ($this->debug_enabled) {
+ $this->debug(sprintf("copy %d bytes from %d", $bestCnt, $bestOfst));
+ }
+
+ $zDelta .= ',';
+
+ if ($bestOfst + $bestCnt - 1 > $lastRead)
+ {
+ $lastRead = $bestOfst + $bestCnt - 1;
+
+ if ($this->debug_enabled) {
+ $this->debug(sprintf("lastRead becomes %d", $lastRead));
+ }
+ }
+
+ $bestCnt = 0;
+ break;
+ }
+
+ /* If we reach this point, it means no match is found so far */
+ if ($base + $i + self::NHASH >= $lenOut)
+ {
+ /* We have reached the end of the file and have not found any
+ ** matches. Do an "insert" for everything that does not match */
+ $zDelta .= $this->putInt($lenOut - $base);
+ $zDelta .= ':';
+ $zDelta .= substr($zOut, $base, $lenOut - $base);
+ $base = $lenOut;
+ break;
+ }
+
+ /* Advance the hash by one character. Keep looking for a match */
+ $this->hash_next($h, ord($zOut[$base + $i + self::NHASH]));
+ $i++;
+ }
+ }
+
+ /* Output a final "insert" record to get all the text at the end of
+ ** the file that does not match anything in the source file.
+ */
+ if ($base < $lenOut)
+ {
+ $zDelta .= $this->putInt($lenOut - $base);
+ $zDelta .= ':';
+ $zDelta .= substr($zOut, $base, $lenOut - $base);
+ }
+
+ /* Output the final checksum record. */
+ $zDelta .= $this->putInt($this->checksum($zOut, $lenOut));
+ $zDelta .= ';';
+ unset($collide);
+
+ return $zDelta;
+ }
+
+ /*
+ ** Return the size (in bytes) of the output from applying
+ ** a delta.
+ **
+ ** This routine is provided so that an procedure that is able
+ ** to call delta_apply() can learn how much space is required
+ ** for the output and hence allocate nor more space that is really
+ ** needed.
+ */
+ public function outputSize($zDelta)
+ {
+ $lenDelta = strlen($zDelta);
+ $size = $this->getInt($zDelta, $lenDelta);
+
+ if (substr($zDelta, 0, 1) != "\n")
+ {
+ /* ERROR: size integer not terminated by "\n" */
+ return -1;
+ }
+
+ return $size;
+ }
+
+
+ /*
+ ** Apply a delta.
+ **
+ ** The output buffer should be big enough to hold the whole output
+ ** file and a NUL terminator at the end. The delta_output_size()
+ ** routine will determine this size for you.
+ **
+ ** The delta string should be null-terminated. But the delta string
+ ** may contain embedded NUL characters (if the input and output are
+ ** binary files) so we also have to pass in the length of the delta in
+ ** the lenDelta parameter.
+ **
+ ** This function returns the size of the output file in bytes (excluding
+ ** the final NUL terminator character). Except, if the delta string is
+ ** malformed or intended for use with a source file other than zSrc,
+ ** then this routine returns -1.
+ **
+ ** Refer to the delta_create() documentation above for a description
+ ** of the delta file format.
+ */
+ public function apply($zSrc, $zDelta)
+ {
+ $zOut = '';
+ $lenSrc = strlen($zSrc);
+ $lenDelta = strlen($zDelta);
+
+ $total = 0;
+ $zOut;
+
+ $limit = $this->getInt($zDelta, $lenDelta);
+
+ if (substr($zDelta, 0, 1) != "\n")
+ {
+ throw new \UnexpectedValueException('size integer not terminated by "\n"');
+ }
+
+ $zDelta = substr($zDelta, 1);
+ $lenDelta--;
+
+ while ($zDelta != '' && $lenDelta > 0)
+ {
+ $cnt = $this->getInt($zDelta, $lenDelta);
+
+ switch ($zDelta[0])
+ {
+ case '@':
+ {
+ $zDelta = substr($zDelta, 1);
+ $lenDelta--;
+
+ $ofst = $this->getInt($zDelta, $lenDelta);
+
+ if ($lenDelta > 0 && $zDelta[0] != ',' )
+ {
+ throw new \RuntimeException("copy command not terminated by ','");
+ }
+
+ $zDelta = substr($zDelta, 1);
+ $lenDelta--;
+
+ if ($this->debug_enabled) {
+ $this->debug(sprintf("COPY %d from %d\n", $cnt, $ofst));
+ }
+
+ $total += $cnt;
+
+ if ($total > $limit)
+ {
+ throw new \RuntimeException('copy exceeds output file size');
+ }
+
+ if ($ofst + $cnt > $lenSrc)
+ {
+ throw new \RuntimeException('copy extends past end of input');
+ }
+
+ $zOut .= substr($zSrc, $ofst, $cnt);
+ break;
+ }
+ case ':':
+ {
+ $zDelta = substr($zDelta, 1);
+ $lenDelta--;
+ $total += $cnt;
+
+ if ($total > $limit)
+ {
+ throw new \RuntimeException('insert command gives an output larger than predicted');
+ }
+
+ if ($this->debug_enabled) {
+ $this->debug(sprintf("INSERT %d\n", $cnt));
+ }
+
+ if ($cnt > $lenDelta)
+ {
+ throw new \RuntimeException('insert count exceeds size of delta');
+ }
+
+ $zOut .= substr($zDelta, 0, $cnt);
+ $zDelta = substr($zDelta, $cnt);
+ $lenDelta -= $cnt;
+ break;
+ }
+ case ';':
+ {
+ $zDelta = substr($zDelta, 1);
+ $lenDelta--;
+
+ if ($cnt != ($ck = $this->checksum($zOut, $total)))
+ {
+ throw new \RuntimeException('bad checksum: '.sprintf("%u", $ck));
+ }
+
+ if ($total != $limit)
+ {
+ throw new \RuntimeException('generated size does not match predicted size');
+ }
+
+ return $zOut;
+ }
+ default:
+ {
+ throw new \RuntimeException('unknown delta operator: ' . sprintf("'%s'", $zDelta[0]));
+ }
+ }
+ }
+
+ throw new \RuntimeException('unterminated delta');
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/KD2/ErrorManager.php b/src/include/lib/KD2/ErrorManager.php
new file mode 100644
index 0000000..f1c9b52
--- /dev/null
+++ b/src/include/lib/KD2/ErrorManager.php
@@ -0,0 +1,1157 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+/**
+ * Simple error and exception handler
+ *
+ * When enabled (with ErrorManager::enable(ErrorManager::DEVELOPMENT)) it will
+ * catch any error, warning or exception and display it along with useful debug
+ * information. If enabled it will also log the errors to a file and/or send
+ * every error by email.
+ *
+ * In production mode no details are given, but a unique reference to the log
+ * or email is displayed.
+ *
+ * This is similar in a way to http://tracy.nette.org/
+ *
+ * @author bohwaz
+ */
+class ErrorManager
+{
+ /**
+ * Prod/dev modes
+ */
+ const PRODUCTION = 1;
+ const DEVELOPMENT = 2;
+ const CLI_DEVELOPMENT = 4;
+
+ /**
+ * Term colors
+ */
+ const RED = '[1;41m';
+ const RED_FAINT = '[1m';
+ const YELLOW = '[33m';
+
+ /**
+ * true = catch exceptions, false = do nothing
+ * @var null
+ */
+ static protected $enabled = null;
+
+ /**
+ * HTML template used for displaying production errors
+ * @var string
+ */
+ static protected $production_error_template = 'Internal server error
+ Server error Sorry but the server encountered an internal error and was unable
+ to complete your request. Please try again later.
+ The webmaster has been noticed and this will be fixed ASAP.
+ Error reference: {$ref}
+
+ ← Go back to the homepage
+ ';
+
+ /**
+ * E-Mail address where to send errors
+ * @var boolean
+ */
+ static protected $email_errors = false;
+
+ /**
+ * Reporting URL
+ */
+ static protected $report_url = null;
+
+ /**
+ * Reporting automatically?
+ */
+ static protected $report_auto = true;
+
+ /**
+ * Custom context
+ */
+ static protected $context = [];
+
+ /**
+ * Custom exception handlers
+ * @var array
+ */
+ static protected $custom_handlers = [];
+
+ /**
+ * Does the terminal support ANSI colors
+ * @var boolean
+ */
+ static protected $term_color = false;
+
+ /**
+ * Will be set to true when catching an exception to avoid double catching
+ * with the shutdown function
+ * @var boolean
+ */
+ static protected $catching = false;
+
+ /**
+ * Used to store timers and memory consumption
+ * @var array
+ */
+ static protected $run_trace = [];
+
+ /**
+ * Handles PHP shutdown on fatal error to be able to catch the error
+ * @return void
+ */
+ static public function shutdownHandler()
+ {
+ // Stop here if disabled or if the script ended with an exception
+ if (!self::$enabled || self::$catching) {
+ return false;
+ }
+
+ $error = error_get_last();
+
+ if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE, E_RECOVERABLE_ERROR, E_USER_ERROR], TRUE))
+ {
+ // Don't exit at the end, as there might be other shutdown handlers
+ // after this one
+ self::exceptionHandler(new \ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line']), false);
+ }
+ }
+
+ /**
+ * Internal error handler to throw them as exceptions
+ * (private use)
+ */
+ static public function errorHandler($severity, $message, $file, $line)
+ {
+ if (!(error_reporting() & $severity)) {
+ // Don't report this error (for example @unlink)
+ return;
+ }
+
+ $types = [
+ E_ERROR => 'Fatal error',
+ E_USER_ERROR => 'User error',
+ E_RECOVERABLE_ERROR => 'Recoverable error',
+ E_CORE_ERROR => 'Core error',
+ E_COMPILE_ERROR => 'Compile error',
+ E_PARSE => 'Parse error',
+ E_WARNING => 'Warning',
+ E_CORE_WARNING => 'Core warning',
+ E_COMPILE_WARNING => 'Compile warning',
+ E_USER_WARNING => 'User warning',
+ E_NOTICE => 'Notice',
+ E_USER_NOTICE => 'User notice',
+ E_STRICT => 'Strict standards',
+ E_DEPRECATED => 'Deprecated',
+ E_USER_DEPRECATED => 'User deprecated',
+ ];
+
+ $type = array_key_exists($severity, $types) ? $types[$severity] : 'Unknown error';
+ $message = $type . ': ' . $message;
+
+ // Catch ASSERT_BAIL errors differently because throwing an exception
+ // in this case results in an execution shutdown, and shutdown handler
+ // isn't even called. See https://bugs.php.net/bug.php?id=53619
+ if (assert_options(ASSERT_ACTIVE) && assert_options(ASSERT_BAIL) && substr($message, 0, 18) == 'Warning: assert():')
+ {
+ $message .= ' (ASSERT_BAIL detected)';
+ self::exceptionHandler(new \ErrorException($message, 0, $severity, $file, $line));
+ return true;
+ }
+
+ throw new \ErrorException($message, 0, $severity, $file, $line);
+ return true;
+ }
+
+ /**
+ * Main exception handler
+ * @param object $e Exception or Error (PHP 7) object
+ * @param boolean $exit Exit the script at the end
+ * @return void
+ */
+ static public function exceptionHandler($e, $exit = true)
+ {
+ self::$catching = true;
+
+ try {
+ self::reportException($e, $exit);
+ }
+ catch (\Throwable $e2) {
+ echo $e2;
+ echo PHP_EOL . PHP_EOL . $e;
+ exit(1);
+ }
+ catch (\Exception $e2) {
+ echo $e2;
+ echo PHP_EOL . PHP_EOL . $e;
+ exit(1);
+ }
+
+ return true;
+ }
+
+ /**
+ * Main exception handler
+ * @param object $e Exception or Error (PHP 7) object
+ * @param boolean $exit Exit the script at the end
+ * @return void
+ */
+ static public function reportException($e, $exit)
+ {
+ foreach (self::$custom_handlers as $class=>$callback)
+ {
+ if ($e instanceOf $class)
+ {
+ call_user_func($callback, $e);
+ $e = false;
+ break;
+ }
+ }
+
+ if ($e === false)
+ {
+ if ($exit)
+ {
+ exit(1);
+ }
+ else
+ {
+ return;
+ }
+ }
+
+ extract(self::buildExceptionReport($e, false));
+ unset($e);
+
+ // Log exception to file
+ if (ini_get('log_errors'))
+ {
+ error_log($log);
+ }
+
+ // Disable any output if it was buffering
+ if (ob_get_level())
+ {
+ ob_end_clean();
+ }
+
+ $is_curl = 0 === strpos($_SERVER['HTTP_USER_AGENT'] ?? '', 'curl/');
+ $is_cli = PHP_SAPI == 'cli';
+
+ if (!$is_cli) {
+ http_response_code(500);
+ }
+
+ if ($is_curl && !headers_sent()) {
+ header('Content-Type: text/plain; charset=utf-8', true);
+ }
+
+ if (($is_cli || $is_curl) && (self::$enabled & self::DEVELOPMENT || self::$enabled & self::CLI_DEVELOPMENT))
+ {
+ foreach ($report->errors as $e)
+ {
+ self::termPrint(sprintf(' /!\\ %s ', $e->type), self::RED);
+ self::termPrint($e->message, self::RED_FAINT);
+
+ if (isset($e->line))
+ {
+ self::termPrint(sprintf('Line %d in %s', $e->line, $e->file), self::
+ YELLOW);
+ }
+
+ // Ignore the error stack belonging to ErrorManager
+ foreach ($e->backtrace as $i=>$t)
+ {
+ $file = !empty($t->file) ? $t->file : '[internal function]';
+ $line = !empty($t->line) ? '(' . $t->line . ')' : '';
+
+ if (isset($t->args))
+ {
+ $args = $t->args;
+
+ foreach ($args as &$arg)
+ {
+ if (strlen($arg) > 20)
+ {
+ $arg = substr($arg, 0, 19) . '…';
+ }
+ }
+
+ unset($arg);
+
+ self::termPrint(sprintf('#%d %s%s: %s(%s)', $i, $file, $line, $t->function, implode(', ', $args)));
+ }
+ else
+ {
+ self::termPrint(sprintf('#%d %s%s', $i, $file, $line));
+ }
+ }
+ }
+ }
+ else if (($is_cli || $is_curl) && self::$enabled & self::PRODUCTION) {
+ self::termPrint(' /!\\ An internal server error occurred ', self::RED);
+ self::termPrint(' Error reference was: ' . $report->context->id, self::YELLOW);
+ }
+ else if (self::$enabled & self::PRODUCTION)
+ {
+ @header_remove('Content-Disposition');
+ @header('Content-Type: text/html; charset=utf-8', true);
+ self::htmlProduction($report);
+ }
+ else
+ {
+ if (!headers_sent()) {
+ header_remove();
+ header('Content-Type: text/html; charset=UTF-8', true);
+ header('HTTP/1.1 500 Internal Server Error', true);
+ }
+
+ echo $html_report;
+ }
+
+ // Log exception to email
+ if (self::$email_errors) {
+ self::sendEmail($title, $report, $log, $html_report);
+ }
+
+ // Send report to URL
+ if (self::$report_auto && self::$report_url) {
+ self::sendReport($report, self::$report_url);
+ }
+
+ if ($exit)
+ {
+ exit(1);
+ }
+ }
+
+ static public function reportExceptionSilent(\Throwable $e): void
+ {
+ extract(self::buildExceptionReport($e));
+
+ // Log exception to file
+ if (ini_get('log_errors'))
+ {
+ error_log($log);
+ }
+
+ if (self::$email_errors) {
+ self::sendEmail($title, $report, $log, $html_report);
+ }
+ }
+
+ static protected function sendEmail(string $title, \stdClass $report, string $log, string $html): void
+ {
+ // From: sender
+ $from = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : basename($report->context->root_directory ?? __FILE__);
+ $msgid = $report->context->id . '@' . $from;
+
+ $boundary = sprintf('-----=%s', md5(uniqid(rand())));
+
+ $header = sprintf("MIME-Version: 1.0\r\nFrom: \"%s\" <%s>\r\nIn-Reply-To: <%s>\r\nMessage-Id: <%s>\r\n", $from, self::$email_errors, $msgid, $msgid);
+ $header.= sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", $boundary);
+ $header.= "\r\n";
+
+ $msg = "This message contains multiple MIME parts.\r\n\r\n";
+ $msg.= sprintf("--%s\r\n", $boundary);
+ $msg.= "Content-Type: text/plain; charset=\"utf-8\"\r\n";
+ $msg.= "Content-Transfer-Encoding: 8bit\r\n\r\n";
+ $msg.= wordwrap($log, 990) . "\r\n\r\n";
+ $msg.= sprintf("--%s\r\n", $boundary);
+ $msg.= "Content-Type: text/html; charset=\"utf-8\"\r\n";
+ $msg.= "Content-Transfer-Encoding: 8bit\r\n\r\n";
+ $msg.= wordwrap($html, 990) . "\r\n\r\n";
+ $msg.= sprintf("--%s--", $boundary);
+
+ mail(self::$email_errors, sprintf('Error #%s: %s', $report->context->id, $title), $msg, $header);
+ }
+
+ /**
+ * Prints a line to STDERR, eventually using a color
+ */
+ static public function termPrint($message, $color = null)
+ {
+ if (!defined('\STDERR')) {
+ echo $message . PHP_EOL;
+ return;
+ }
+
+ if ($color && self::$term_color)
+ {
+ $message = chr(27) . $color . $message . chr(27) . "[0m";
+ }
+
+ fwrite(\STDERR, $message . PHP_EOL);
+ }
+
+ /**
+ * Return file location without the document root
+ */
+ static protected function getFileLocation($file)
+ {
+ if (!empty(self::$context['root_directory']) && ($pos = strpos($file, self::$context['root_directory'])) === 0)
+ {
+ return '...' . substr($file, strlen(self::$context['root_directory']));
+ }
+
+ return $file;
+ }
+
+ static public function buildExceptionReport(\Throwable $e, bool $force_html = false): array
+ {
+ $report = self::makeReport($e);
+ $log = sprintf('=========== Error ref. %s ===========', $report->context->id)
+ . PHP_EOL . PHP_EOL . (string) $e . PHP_EOL . PHP_EOL
+ . '' . PHP_EOL . json_encode($report, \JSON_PRETTY_PRINT)
+ . PHP_EOL . ' ' . PHP_EOL;
+
+ $html_report = null;
+
+ if ($force_html || self::$enabled & self::DEVELOPMENT || self::$email_errors) {
+ $html_report = self::htmlReport($report);
+ }
+
+ $title = $e->getMessage();
+
+ return compact('report', 'log', 'html_report', 'title');
+ }
+
+ /**
+ * Generates a report from an exception
+ */
+ static public function makeReport($e)
+ {
+ $report = (object) [
+ 'errors' => [],
+ ];
+
+ while ($e !== null)
+ {
+ $class = get_class($e);
+
+ $error = (object) [
+ 'message' => $e->getMessage(),
+ 'errorCode' => $e->getCode(),
+ 'type' => in_array($class, ['ErrorException', 'Error']) ? 'PHP error' : $class,
+ 'backtrace' => [
+ (object) [
+ 'file' => $e->getFile() ? self::getFileLocation($e->getFile()) : null,
+ 'line' => $e->getLine(),
+ 'code' => $e->getFile() && $e->getLine() ? self::getSource($e->getFile(), $e->getLine()) : null,
+ ],
+ ],
+ ];
+
+ foreach ($e->getTrace() as $i=>$t)
+ {
+ // Ignore the error stack from ErrorManager
+ if (isset($t['class']) && $t['class'] === __CLASS__
+ && ($t['function'] === 'shutdownHandler' || $t['function'] === 'errorHandler'))
+ {
+ continue;
+ }
+
+ $args = [];
+
+ // Display call arguments
+ if (!empty($t['args']))
+ {
+ // Find arguments variables names via reflection
+ try {
+ if (isset($t['class']))
+ {
+ $r = new \ReflectionMethod($t['class'], $t['function']);
+ }
+ else
+ {
+ $r = new \ReflectionFunction($t['function']);
+ }
+
+ $params = $r->getParameters();
+ }
+ catch (\Exception $_ignore) {
+ $params = [];
+ }
+
+ foreach ($t['args'] as $name => $value)
+ {
+ if (array_key_exists($name, $params))
+ {
+ $name = '$' . $params[$name]->name;
+ }
+
+ if (is_string($value))
+ {
+ $value = self::getFileLocation($value);
+ }
+
+ $args[$name] = self::dump($value);
+
+ if (strlen($args[$name]) > 2000)
+ {
+ $args[$name] = substr($args[$name], 0, 1999) . '…';
+ }
+ }
+ }
+
+ $trace = (object) [
+ // Add class name to function
+ 'function' => isset($t['class']) ? $t['class'] . $t['type'] . $t['function'] : $t['function'],
+ ];
+
+ if (isset($t['file']))
+ {
+ $trace->file = self::getFileLocation($t['file']);
+ }
+
+ if (isset($t['line']))
+ {
+ $trace->line = (int) $t['line'];
+ }
+
+ if (count($args))
+ {
+ $trace->args = $args;
+ }
+
+ if (isset($trace->file) && isset($trace->line))
+ {
+ $trace->code = self::getSource($t['file'], $t['line']);
+ }
+
+ $error->backtrace[] = $trace;
+ }
+
+ $report->errors[] = $error;
+ $e = $e->getPrevious();
+ }
+
+ unset($error, $e, $params, $t);
+
+ $context = array_merge([
+ 'id' => base_convert(substr(sha1(json_encode($report->errors)), 0, 10), 16, 36),
+ 'date' => date(DATE_ATOM),
+ 'os' => PHP_OS,
+ 'language' => 'PHP ' . PHP_VERSION,
+ 'environment' => self::$enabled & self::DEVELOPMENT ? 'development' : 'production:' . self::$enabled,
+ 'php_sapi' => PHP_SAPI,
+ 'remote_ip' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null,
+ 'http_method' => isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : null,
+ 'http_files' => self::dump($_FILES),
+ 'http_post' => self::dump($_POST, true),
+ 'duration' => isset(self::$context['request_started']) ? (microtime(true) - self::$context['request_started'])*1000 : null,
+ 'memory_peak' => memory_get_peak_usage(true),
+ 'memory_used' => memory_get_usage(true),
+ ], self::$context);
+
+ ksort($context);
+
+ unset($context['request_started']);
+
+ $report->context = (object) $context;
+
+ if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI']))
+ {
+ $proto = empty($_SERVER['HTTPS']) ? 'http' : 'https';
+ $report->context->url = $proto . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
+ }
+
+ return $report;
+ }
+
+ /**
+ * Displays an exception as HTML debug page
+ */
+ static public function htmlException(\stdClass $e): string
+ {
+ $out = sprintf('',
+ $e->type, nl2br(htmlspecialchars($e->message)));
+
+ foreach ($e->backtrace as $i=>$t)
+ {
+ $out .= '';
+
+ if (isset($t->file) && isset($t->line))
+ {
+ $dir = dirname($t->file);
+ $dir = $dir == '/' ? $dir : $dir . '/';
+
+ $out .= sprintf('in %s%s :%d ', htmlspecialchars($dir), htmlspecialchars(basename($t->file)), $t->line);
+ }
+
+ if (isset($t->function))
+ {
+ $out .= sprintf('→ %s (%d arg.) ', htmlspecialchars($t->function), isset($t->args) ? count($t->args) : 0);
+
+ // Display call arguments
+ if (!empty($t->args))
+ {
+ $out .= '';
+
+ foreach ($t->args as $name => $value)
+ {
+ $out .= sprintf('%s %s ', htmlspecialchars($name), htmlspecialchars($value));
+ }
+
+ $out .= '
';
+ }
+ }
+
+ // Display source code
+ if (isset($t->code) && isset($t->line))
+ {
+ $out .= self::htmlSource($t->code, $t->line);
+ }
+
+ $out .= ' ';
+ }
+
+ $out .= ' ';
+
+ return $out;
+ }
+
+
+ static public function htmlSource(array $source, $line)
+ {
+ $out = '';
+
+ foreach ($source as $i => $code)
+ {
+ $html = '' . ($i) . ' ' . htmlspecialchars($code, ENT_QUOTES);
+
+ if ($i == $line)
+ {
+ $html = '' . $html . ' ';
+ }
+
+ $out .= $html . PHP_EOL;
+ }
+
+ return '' . $out . '
';
+ }
+
+ /**
+ * Get source code
+ * @param string $file File location
+ * @param integer $line Line to highlight
+ * @return array
+ */
+ static public function getSource($file, $line)
+ {
+ $out = [];
+ $start = max(0, $line - 5);
+
+ if (!file_exists($file)) {
+ return [$line => 'Source file not found'];
+ }
+
+ $file = new \SplFileObject($file);
+ $file->seek($start);
+
+ for ($i = $start + 1; $i < $start+10; $i++)
+ {
+ if ($file->eof())
+ {
+ break;
+ }
+
+ $out[$i] = trim($file->current(), "\r\n");
+ $file->next();
+ }
+
+ unset($file);
+
+ return $out;
+ }
+
+ static public function htmlProduction(\stdClass $report)
+ {
+ if (!headers_sent()) {
+ header_remove();
+ header('HTTP/1.1 500 Internal Server Error', true, 500);
+ header('Content-Type: text/html; charset=UTF-8', true);
+ }
+
+ echo self::htmlTemplate(self::$production_error_template, $report);
+ }
+
+ static public function htmlTemplate($str, \stdClass $report)
+ {
+ $str = strtr($str, [
+ '{$ref}' => $report->context->id,
+ '{$report_json}' => htmlspecialchars(base64_encode(json_encode($report)), ENT_QUOTES),
+ '{$report_url}' => htmlspecialchars((string) self::$report_url),
+ ]);
+
+ $str = preg_replace_callback('!(.*?) !is', function ($match) {
+ switch ($match[1]) {
+ case 'sent':
+ case 'email':
+ return self::$email_errors || (self::$report_auto && self::$report_url) ? $match[2] : '';
+ case 'logged':
+ case 'log':
+ return ini_get('error_log') ? $match[2] : '';
+ case 'report':
+ return (!self::$report_auto && self::$report_url) ? $match[2] : '';
+ }
+ }, $str);
+
+ return $str;
+ }
+
+ static public function htmlReport(\stdClass $report): string
+ {
+ $out = '';
+
+ // Display debug
+ $out .= self::htmlTemplate(ini_get('error_prepend_string'), $report);
+
+ foreach ($report->errors as $e)
+ {
+ $out .= self::htmlException($e);
+ }
+
+ $out .= 'Context ';
+
+ foreach ($report->context as $name => $value)
+ {
+ $out .= sprintf('%s %s ',
+ htmlspecialchars($name),
+ htmlspecialchars($value ?? ''));
+ }
+
+ $out .= '
';
+
+ $out .= self::htmlTemplate(ini_get('error_append_string'), $report);
+
+ return $out;
+ }
+
+ static public function setEnvironment(int $environment): void
+ {
+ self::$enabled = $environment;
+ error_reporting($environment & self::DEVELOPMENT ? -1 : E_ALL & ~E_DEPRECATED & ~E_STRICT);
+
+ if ($environment & self::DEVELOPMENT && PHP_SAPI != 'cli') {
+ self::setHtmlHeader('
+ \__/ (xx) //||\\\\ ');
+ }
+ }
+
+ /**
+ * Enable error manager
+ * @param integer $environment Type of error management (ErrorManager::PRODUCTION or ErrorManager::DEVELOPMENT)
+ * You can also use ErrorManager::PRODUCTION | ErrorManager::CLI_DEVELOPMENT to get error messages in CLI but still hide errors
+ * on web front-end.
+ * @return void
+ */
+ static public function enable(int $environment = self::DEVELOPMENT): void
+ {
+ if (self::$enabled) {
+ return;
+ }
+
+ self::$context['request_started'] = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true);
+
+ self::$term_color = function_exists('posix_isatty') && defined('\STDOUT') && @posix_isatty(\STDOUT);
+
+ ini_set('display_errors', false);
+ ini_set('log_errors', false);
+ ini_set('html_errors', false);
+ ini_set('zend.exception_ignore_args', false); // We want to get the args in exceptions (since PHP 7.4)
+
+ self::setEnvironment($environment);
+
+ register_shutdown_function([self::class, 'shutdownHandler']);
+ set_exception_handler([__CLASS__, 'exceptionHandler']);
+ set_error_handler([__CLASS__, 'errorHandler']);
+
+ if ($environment & self::DEVELOPMENT) {
+ self::startTimer('_global');
+ }
+
+ // Assign default context
+ static $defaults = [
+ 'hostname' => 'SERVER_NAME',
+ 'http_user_agent' => 'HTTP_USER_AGENT',
+ 'http_referrer' => 'HTTP_REFERER',
+ 'user_addr' => 'REMOTE_ADDR',
+ 'server_addr' => 'SERVER_ADDR',
+ 'root_directory' => 'DOCUMENT_ROOT',
+ ];
+
+ foreach ($defaults as $a => $b) {
+ if (isset($_SERVER[$b]) && !isset(self::$context[$a])) {
+ self::$context[$a] = $_SERVER[$b];
+ }
+ }
+ }
+
+ /**
+ * Reset error management to PHP defaults
+ * @return boolean
+ */
+ static public function disable()
+ {
+ self::$enabled = false;
+
+ ini_set('error_prepend_string', null);
+ ini_set('error_append_string', null);
+ ini_set('log_errors', false);
+ ini_set('display_errors', false);
+ ini_set('error_reporting', E_ALL & ~E_DEPRECATED & ~E_STRICT);
+
+ restore_error_handler();
+ return restore_exception_handler();
+ }
+
+ /**
+ * Sets a microsecond timer to track time and memory usage
+ * @param string $name Timer name
+ */
+ static public function startTimer($name)
+ {
+ self::$run_trace[$name] = [microtime(true), memory_get_usage()];
+ }
+
+ /**
+ * Stops a timer and return time spent and memory used
+ * @param string $name Timer name
+ */
+ static public function stopTimer($name)
+ {
+ self::$run_trace[$name] = [
+ microtime(true) - self::$run_trace[$name][0],
+ memory_get_usage() - self::$run_trace[$name][1],
+ ];
+ return self::$run_trace[$name];
+ }
+
+ /**
+ * Sets a log file to record errors
+ * @param string $file Error log file
+ */
+ static public function setLogFile($file)
+ {
+ ini_set('log_errors', true);
+ return ini_set('error_log', $file);
+ }
+
+ /**
+ * Sets an email address that should receive the logs
+ * Set to FALSE to disable email sending (default)
+ * @param string $email Email address
+ */
+ static public function setEmail($email)
+ {
+ self::$email_errors = $email;
+ }
+
+ /**
+ * @deprecated
+ */
+ static public function setExtraDebugEnv($env)
+ {
+ self::setContext($env);
+ }
+
+ /**
+ * Set the report context
+ * @param mixed $env Variable content, could be application version, or an array of information...
+ */
+ static public function setContext(array $context)
+ {
+ self::$context = array_merge(self::$context, $context);
+ }
+
+ /**
+ * Enable or disable reporting of errors to a remote URL
+ * @param null|string $url Reporting URL
+ * @param boolean $auto Automatic reporting? If not users will be able to report the error by clicking a button on the error page
+ */
+ static public function setRemoteReporting($url, $auto)
+ {
+ self::$report_url = empty($url) ? null : $url;
+ self::$report_auto = (bool) $auto;
+ }
+
+ /**
+ * Set the HTML header used by the debug error page
+ * @param string $html HTML header
+ */
+ static public function setHtmlHeader($html)
+ {
+ ini_set('error_prepend_string', $html);
+ }
+
+ /**
+ * Set the HTML footer used by the debug error page
+ * @param string $html HTML footer
+ */
+ static public function setHtmlFooter($html)
+ {
+ ini_set('error_append_string', $html);
+ }
+
+ /**
+ * Set the content of the HTML template used to display an error in production
+ * {$ref} will be replaced by the error reference if log or email is enabled
+ * ... block will be removed if email reporting is disabled
+ * ... block will be removed if log reporting is disabled
+ * @param string $html HTML template
+ */
+ static public function setProductionErrorTemplate($html)
+ {
+ self::$production_error_template = $html;
+ }
+
+ static public function setCustomExceptionHandler($class, Callable $callback)
+ {
+ self::$custom_handlers[$class] = $callback;
+ }
+
+ static public function debug(...$vars)
+ {
+ echo '';
+ foreach ($vars as $var) {
+ echo self::dump($var);
+ echo ' ';
+ }
+ echo ' ';
+ }
+
+ /**
+ * Copy of var_dump but returns a string instead of a variable
+ * @param mixed $var variable to dump
+ * @param bool $hide_values Do not return values if set to TRUE
+ * @param integer $level Indentation level (internal use)
+ * @return string
+ */
+ static public function dump($var, $hide_values = false, $level = 0)
+ {
+ if ($level > 20)
+ {
+ return '*RECURSION*';
+ }
+
+ switch (gettype($var))
+ {
+ case 'boolean':
+ return 'bool(' . ($var ? 'true' : 'false') . ')';
+ case 'integer':
+ return 'int(' . $var . ')';
+ case 'double':
+ return 'float(' . $var . ')';
+ case 'string':
+ return 'string(' . strlen($var) . ') "' . ($hide_values ? '***HIDDEN***' : $var) . '"';
+ case 'NULL':
+ return 'NULL';
+ case 'resource':
+ return 'resource(' . (int)$var . ') of type (' . get_resource_type($var) . ')';
+ case 'array':
+ case 'object':
+ if (is_object($var))
+ {
+ $out = 'object(' . get_class($var) . ') (' . count((array) $var) . ') {' . PHP_EOL;
+ }
+ else
+ {
+ $out = 'array(' . count((array) $var) . ') {' . PHP_EOL;
+ }
+
+ $level++;
+
+ if ($var instanceof \Traversable) {
+ $var2 = [];
+
+ try {
+ // Iterate as long as we can
+ while (@$var->valid()) {
+ $var2[] = $var->current();
+ $var->next();
+ }
+ }
+ catch (\Exception $e) {
+ $var2[] = '**' . $e->getMessage() . '**';
+ }
+
+ $var = $var2;
+ }
+
+ foreach ((array)$var as $key=>$value)
+ {
+ $out .= str_repeat(' ', $level * 2);
+ $out .= is_string($key) ? '["' . $key . '"]' : '[' . $key . ']';
+
+ if ($value === $var) {
+ $out .= '=> *RECURSION*' . PHP_EOL;
+ }
+ else {
+ $out .= '=> ' . self::dump($value, $hide_values, $level + 1) . PHP_EOL;
+ }
+ }
+
+ $out .= str_repeat(' ', $level * 2) . '}';
+ return $out;
+ default:
+ return gettype($var);
+ }
+ }
+
+ /**
+ * Upload a report to a remote errbit-compatible API
+ * @see https://airbrake.io/docs/api/#create-notice-v3
+ */
+ static public function sendReport(\stdClass $report, $url)
+ {
+ $data = json_encode($report);
+
+ $headers = [
+ 'Content-Type: application/json',
+ 'Content-Lenth: ' . strlen($data),
+ ];
+
+ if (function_exists('curl_init'))
+ {
+ $ch = curl_init($url);
+
+ curl_setopt_array($ch, [
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => false,
+ CURLOPT_MAXREDIRS => 3,
+ CURLOPT_CUSTOMREQUEST => 'POST',
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_POSTFIELDS => $data,
+ ]);
+
+ $body = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ }
+ else
+ {
+ $opts = ['http' => [
+ 'method' => 'POST',
+ 'header' => $headers,
+ 'content' => $data,
+ 'max_redirects' => 3,
+ 'timeout' => 10,
+ 'ignore_errors' => true,
+ ]];
+
+ $body = file_get_contents($url, false, stream_context_create($opts));
+ $code = null;
+
+ foreach ($http_response_header as $header)
+ {
+ $a = substr($header, 0, 7);
+
+ if ($a == 'HTTP/1.')
+ {
+ $code = substr($header, 11, 3);
+ }
+ }
+
+ unset($http_response_header);
+ }
+
+ return [
+ 'code' => (int) $code,
+ 'body' => $body,
+ 'data' => json_decode($body),
+ ];
+ }
+
+ /**
+ * Returns list of reports from error log
+ *
+ * @param string|null $log_file Log file to use, if NULL then the log file set in error_log will be used
+ * @param string|null $filter_id Only return errors matching with this ID
+ */
+ static public function getReportsFromLog($log_file = null, $filter_id = null)
+ {
+ if (!$log_file)
+ {
+ $log_file = ini_get('error_log');
+ }
+
+ if (!file_exists($log_file))
+ {
+ return [];
+ }
+
+ $reports = [];
+ $report = null;
+
+ foreach (file($log_file) as $line)
+ {
+ $line = trim($line);
+
+ if ($line == '')
+ {
+ $report = '';
+ }
+ elseif ($line == ' ')
+ {
+ $report = json_decode($report);
+
+ if (!is_null($report) && isset($report->context->id) && (!$filter_id || $filter_id == $report->context->id))
+ {
+ $reports[] = $report;
+ }
+
+ $report = null;
+ }
+ elseif ($report !== null)
+ {
+ $report .= $line;
+ }
+ }
+
+ unset($line, $report, $log_file);
+
+ return $reports;
+ }
+}
diff --git a/src/include/lib/KD2/FeedParser.php b/src/include/lib/KD2/FeedParser.php
new file mode 100644
index 0000000..aba4d0a
--- /dev/null
+++ b/src/include/lib/KD2/FeedParser.php
@@ -0,0 +1,707 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+/**
+ * Simple and loosy feed parser
+ *
+ * This parser is not using any XML or DOM parsing library
+ * as a result of this it can parse any kind of feed, even if it
+ * is invalid or broken.
+ *
+ * It will parse and provide most common properties for every item
+ * and also an array of all the tags found for the item, allowing
+ * easy extension on specific features (like medias for example).
+ *
+ * Copyleft (C) 2012-2013 BohwaZ
+ */
+
+class FeedParser
+{
+ /**
+ * Possible feed mime types
+ * @var array
+ */
+ protected static $mime_types = array(
+ 'application/atom+xml',
+ 'application/rss+xml',
+ 'application/rdf+xml'
+ );
+
+ /**
+ * Feed format (rss/atom)
+ * @var string
+ */
+ public $format = null;
+
+ /**
+ * Feed vendor (netscape/w3c/userland/rss-dev-wg/imc)
+ * @var string
+ */
+ public $vendor = null;
+
+ /**
+ * Feed spec version (1.0/0.9x/2.0/0.3)
+ * @var string
+ */
+ public $version = null;
+
+ /**
+ * Items contained in the feed
+ * @var array
+ */
+ protected $items = array();
+
+ /**
+ * Channel data
+ * @var string
+ */
+ protected $channel = null;
+
+ /**
+ * Current item, to iterate over items
+ * @var integer
+ */
+ protected $current_item = 0;
+
+ /**
+ * Returns discovered feeds from web URL
+ * @param string $url HTML page URL
+ * @return array List of discovered feeds
+ */
+ static public function discoverFeedsFromURL($url)
+ {
+ $feeds = self::discoverFeeds(file_get_contents($url));
+
+ if (empty($feeds))
+ return $feeds;
+
+ foreach ($feeds as &$feed)
+ {
+ $feed['url'] = self::getRealURL($feed['href'], $url);
+ }
+
+ return $feeds;
+ }
+
+ /**
+ * Returns a complete URL with scheme and host from an unknown or incomplete URI
+ * @param string $href Incomplete URI
+ * @param string $base_url Base URL used to build a complete URL
+ * @return string Complete URL
+ */
+ static public function getRealURL($href, $base_url)
+ {
+ $_href = parse_url($href);
+
+ // already an absolute URL
+ if (!empty($_href['scheme']))
+ return $href;
+
+ $_base = parse_url($base_url);
+
+ // protocol-relative URL ie. //bits.wikimedia.org/static/elements/rss.xml
+ if (substr($href, 0, 2) == '//')
+ return $_base['scheme'] . ':' . $href;
+
+ $url = $_base['scheme'] . '://';
+
+ if (!empty($_base['user']))
+ {
+ $url .= $_base['user'];
+
+ if (!empty($_base['pass']))
+ $url .= ':' . $_base['pass'];
+
+ $url .= '@';
+ }
+
+ $url .= $_base['host'];
+
+ // absolute URI
+ if (preg_match('!^/!', $href))
+ return $url . $href;
+
+ $url .= preg_replace('!/[^/]*$!', '/', $_base['path']);
+
+ // query-based URI, eg. ?feed
+ if (!empty($_href['path']))
+ return $url . $href;
+
+ $url .= preg_replace('!^.*/!', '', $_base['path']);
+
+ // relative URI
+ return $url . $href;
+ }
+
+ /**
+ * Discover feeds from an HTML string
+ * @param string $content HTML content
+ * @param boolean $fallback_discover_content If set to TRUE, it will try to find feeds in /is', $content, $links, PREG_SET_ORDER))
+ {
+ foreach ($links as $link)
+ {
+ $params = self::parseAttributes($link[1]);
+
+ if (empty($params['rel']) || empty($params['type']) || empty($params['href']))
+ continue;
+
+ $rel = strtolower($params['rel']);
+
+ if (!in_array($rel, $possible_rels))
+ continue;
+
+ $type = strtolower($params['type']);
+
+ if (!in_array($type, self::$mime_types))
+ continue;
+
+ $feeds[] = array(
+ 'type' => $type,
+ 'href' => $params['href'],
+ 'title' => isset($params['title']) ? trim(html_entity_decode($params['title'], ENT_COMPAT, '')) : null,
+ );
+ }
+ }
+ // Discover feed links from page links
+ elseif ($fallback_discover_content && preg_match_all('/<\s*a\s+(.*?)>(.*?)<\/a>/is', $content, $links, PREG_SET_ORDER))
+ {
+ foreach ($links as $link)
+ {
+ $params = self::parseAttributes($link[1]);
+
+ if (empty($params['href']))
+ continue;
+
+ if (!preg_match('/[^\w\d](?:atom|rss|rdf)[^\w\d]/i', $params['href']))
+ continue;
+
+ $feeds[] = array(
+ 'type' => null,
+ 'href' => $params['href'],
+ 'title' => html_entity_decode(strip_tags($link[2]), ENT_COMPAT, ''),
+ );
+ }
+ }
+
+ return $feeds;
+ }
+
+ /**
+ * Returns a valid UNIX timestamp from a RSS/ATOM date, even broken ones
+ * From https://github.com/fguillot/picoFeed/blob/master/lib/PicoFeed/Parser.php
+ * @param string $value Input date, any format
+ * @return int Unix Timestamp
+ */
+ static public function parseDate($value)
+ {
+ // Format => truncate to this length if not null
+ static $formats = array(
+ DATE_ATOM => null,
+ DATE_RSS => null,
+ DATE_COOKIE => null,
+ DATE_ISO8601 => null,
+ DATE_RFC822 => null,
+ DATE_RFC850 => null,
+ DATE_RFC1036 => null,
+ DATE_RFC1123 => null,
+ DATE_RFC2822 => null,
+ DATE_RFC3339 => null,
+ 'D, d M Y H:i:s' => 25,
+ 'D, d M Y h:i:s' => 25,
+ 'D M d Y H:i:s' => 24,
+ 'Y-m-d H:i:s' => 19,
+ 'Y-m-d\TH:i:s' => 19,
+ 'd/m/Y H:i:s' => 19,
+ 'D, d M Y' => 16,
+ 'Y-m-d' => 10,
+ 'd-m-Y' => 10,
+ 'm-d-Y' => 10,
+ 'd.m.Y' => 10,
+ 'm.d.Y' => 10,
+ 'd/m/Y' => 10,
+ 'm/d/Y' => 10,
+ );
+
+ if (!$value) {
+ return time();
+ }
+
+ $value = trim($value);
+
+ foreach ($formats as $format => $length) {
+ $timestamp = self::getValidDate($format, substr($value, 0, $length));
+ if ($timestamp > 0) return $timestamp;
+ }
+
+ return time();
+ }
+
+ /**
+ * Creates a valid timestamp from a given date format
+ * @param string $format Date format
+ * @param string $value Date string
+ * @return integer Timestamp
+ */
+ static protected function getValidDate($format, $value)
+ {
+ $date = \DateTime::createFromFormat($format, $value);
+
+ if ($date !== false) {
+ $errors = \DateTime::getLastErrors();
+ if (empty($errors['error_count']) && empty($errors['warning_count'])) return $date->getTimestamp();
+ }
+
+ return 0;
+ }
+
+ /**
+ * Returns the content of an XML tag, without entities
+ * @param string $string Raw XML string
+ * @return string Decoded string
+ */
+ static protected function getXmlContent($string)
+ {
+ $string = trim($string);
+ $string = preg_replace('/^.*.*$/s', '', $string);
+ $string = str_replace(''', ''', $string);
+ $string = html_entity_decode(self::utf8_encode($string), ENT_QUOTES, 'UTF-8');
+ return $string;
+ }
+
+ static protected function utf8_encode($str)
+ {
+ // Check if string is already UTF-8 encoded or not
+ if (!preg_match('//u', $str))
+ {
+ return utf8_encode($str);
+ }
+
+ return $str;
+ }
+
+ /**
+ * Parses attributes from a XML/HTML tag
+ * @param string $str String containing all attributes
+ * @return array List of attributes
+ */
+ static protected function parseAttributes($str)
+ {
+ $params = array();
+ preg_match_all('/(\w[\w\d]*(?::\w[\w\d]*)*)(?:\s*=\s*(?:([\'"])(.*?)\2|([^>\s\'"]+)))?/i', $str, $_params, PREG_SET_ORDER);
+
+ if (empty($_params))
+ {
+ return $params;
+ }
+
+ foreach ($_params as $_p)
+ {
+ $value = isset($_p[4]) ? trim($_p[4]) : (isset($_p[3]) ? trim($_p[3]) : null);
+ $params[strtolower($_p[1])] = $value ? self::utf8_encode($value) : $value;
+ }
+
+ return $params;
+ }
+
+ /**
+ * Constructor
+ */
+ public function __construct()
+ {
+ }
+
+ /**
+ * Load and parse a feed from an URL
+ * @param string $url Feed URL
+ * @return boolean false if feed parsing or loading failed
+ */
+ public function load($url)
+ {
+ if (!$this->parse(file_get_contents($url)))
+ return false;
+
+ if (!empty($this->items))
+ {
+ foreach ($this->items as &$item)
+ {
+ $item->url = self::getRealURL($item->link, $url);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Parse a feed from a string
+ * @param string $content Feed as string
+ * @return boolean false if parsing failed (or string is empty)
+ */
+ public function parse($content)
+ {
+ $this->format = $this->version = $this->vendor = false;
+ $this->items = array();
+ $this->channel = null;
+
+ if (preg_match('!]*xmlns\s*=\s*["\']?http://www\.w3\.org/2005/Atom["\']?!i', $content))
+ {
+ $this->format = 'atom';
+ $this->version = '1.0';
+ $this->vendor = 'w3c';
+ }
+ elseif (preg_match('!]*version\s*=\s*["\']?0\.3["\']?!i', $content))
+ {
+ $this->format = 'atom';
+ $this->version = '0.3';
+ $this->vendor = 'imc';
+ }
+ // Source: http://web.archive.org/web/20100315092011/http://diveintomark.org/archives/2004/02/04/incompatible-rss
+ elseif (preg_match('!]*http://my\.netscape\.com/rdf/simple/0\.9/!i', $content))
+ {
+ $this->format = 'rss';
+ $this->version = '0.90';
+ $this->vendor = 'netscape';
+ }
+ elseif (preg_match('!http://my\.netscape\.com/publish/formats/rss-0\.91\.dtd!i', $content))
+ {
+ $this->format = 'rss';
+ $this->version = '0.91';
+ $this->vendor = 'netscape';
+ }
+ elseif (preg_match('!]*version\s*=\s*["\']?(0\.9[1234]|2\.0)["\']?!i', $content))
+ {
+ $this->format = 'rss';
+ $this->version = '0.91';
+ $this->vendor = 'userland';
+ }
+ elseif (preg_match('!]*http://purl\.org/rss/1\.0/!i', $content))
+ {
+ $this->format = 'rss';
+ $this->version = '1.0';
+ $this->vendor = 'rss-dev-wg';
+ }
+
+ // Separate items from channel
+ $pos = $end = false;
+
+ if (($items = preg_split('/<\/?(item|entry)(\s+.*?)?>/is', $content, -1, PREG_SPLIT_OFFSET_CAPTURE | PREG_SPLIT_NO_EMPTY))
+ && !empty($items[1]))
+ {
+ $pos = $items[1][1];
+ $end = $items[count($items) - 2][1] + strlen($items[count($items) - 2][0]);
+
+ unset($items[count($items)-1]);
+ unset($items[0]);
+
+ foreach ($items as $item)
+ {
+ if (trim($item[0]) == '')
+ continue;
+
+ $this->items[] = $item[0];
+ }
+
+ unset($items);
+ }
+
+ if ($pos)
+ {
+ $start = stripos($content, 'channel = substr($content, ($start === false ? 0 : $start), $pos - $start);
+
+ // Get the start of the channel
+ if (($start = strpos($this->channel, '>')) !== false)
+ {
+ $this->channel = substr($this->channel, $start);
+ }
+
+ $channel_end = strpos($this->channel, ' channel = substr($this->channel, 0, $channel_end);
+ }
+ // If there is still somethin from the channel after the items
+ elseif ($end)
+ {
+ $channel_end = strpos($content, '')) !== false)
+ {
+ $last = substr($last, $start);
+ }
+
+ $this->channel .= $last;
+ }
+
+ $this->parseChannel();
+ }
+
+ if ($this->items)
+ {
+ $this->parseItems();
+ }
+
+ // Obviously something went wrong
+ if (empty($this->items) && empty($this->channel))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Parse channel tags into something useful
+ * @return void
+ */
+ protected function parseChannel()
+ {
+ $channel = new \stdClass;
+ $channel->title = null;
+ $channel->description = null;
+ $channel->link = null;
+ $channel->date = null;
+ $channel->raw = $this->channel;
+ $channel->xml = array();
+
+ preg_match_all('!<\s*([\w\d-]+(?::[\w\d-]+)*)\s*(.*?)/?>(?:(.*?)\1>)?!is', $this->channel, $tags, PREG_SET_ORDER);
+
+ foreach ($tags as $_tag)
+ {
+ $tag = new \stdClass;
+ $tag->name = $_tag[1];
+ $tag->content = isset($_tag[3]) ? self::getXmlContent($_tag[3]) : null;
+ $tag->attributes = !empty($_tag[2]) ? self::parseAttributes($_tag[2]) : array();
+ $channel->xml[] = $tag;
+
+ $_tag = strtolower($tag->name);
+
+ switch ($_tag)
+ {
+ case 'title':
+ case 'description':
+ $channel->{$_tag} = $tag->content;
+ break;
+ case 'dc:date':
+ case 'pubdate':
+ case 'modified':
+ case 'published':
+ case 'updated':
+ $channel->date = $tag->content;
+ break;
+ case 'link':
+ if (!empty($channel->link))
+ break;
+
+ if (!empty($tag->attributes['href']))
+ {
+ $channel->link = $tag->attributes['href'];
+ }
+ else
+ {
+ $channel->link = $tag->content;
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ // Convert the date string to a timestamp
+ $channel->date = self::parseDate($channel->date);
+
+ $this->channel = $channel;
+ }
+
+ /**
+ * Parse feed items into an array of objects
+ * @return void
+ */
+ protected function parseItems()
+ {
+ foreach ($this->items as $key=>$_item)
+ {
+ $item = new \stdClass;
+ $item->title = null;
+ $item->description = null;
+ $item->link = null;
+ $item->date = null;
+ $item->content = null;
+ $item->raw = $_item;
+ $item->xml = array();
+
+ preg_match_all('!<\s*([\w\d-]+(?::[\w\d-]+)*)\s*(.*?)/?>(?:(.*?)\1>)?!is', $_item, $tags, PREG_SET_ORDER);
+
+ foreach ($tags as $_tag)
+ {
+ $tag = new \stdClass;
+ $tag->name = $_tag[1];
+ $tag->content = isset($_tag[3]) ? self::getXmlContent($_tag[3]) : null;
+ $tag->attributes = !empty($_tag[2]) ? self::parseAttributes($_tag[2]) : array();
+ $item->xml[] = $tag;
+
+ $_tag = strtolower($tag->name);
+
+ switch ($_tag)
+ {
+ case 'title':
+ case 'description':
+ case 'content':
+ $item->{$_tag} = $tag->content;
+ break;
+ case 'dc:date':
+ case 'pubdate':
+ case 'modified':
+ case 'published':
+ case 'updated':
+ case 'issued':
+ if (!empty($tag->content))
+ $item->date = $tag->content;
+ break;
+ case 'link':
+ if (!empty($item->link))
+ break;
+
+ if (!empty($tag->attributes['href']))
+ {
+ $item->link = $tag->attributes['href'];
+ }
+ else
+ {
+ $item->link = $tag->content;
+ }
+ break;
+ case 'summary':
+ $item->description = $tag->content;
+ case 'content:encoded':
+ $item->content = $tag->content;
+ default:
+ break;
+ }
+ }
+
+ // Convert the date string to a timestamp
+ $item->date = self::parseDate($item->date);
+
+ if (is_null($item->description) && !is_null($item->content))
+ $item->description = $item->content;
+ elseif (!is_null($item->description) && is_null($item->content))
+ $item->content = $item->description;
+
+ $this->items[$key] = $item;
+ }
+ }
+
+ /**
+ * Get the channel object
+ * @return object stdClass object containg channel informations
+ */
+ public function getChannel()
+ {
+ if (!$this->channel)
+ return false;
+
+ return $this->channel;
+ }
+
+ /**
+ * Get the array of items
+ * @return array An array of stdClass objects
+ */
+ public function getItems()
+ {
+ return $this->items;
+ }
+
+ /**
+ * Calculates the average publication interval between items.
+ * Useful to know when to check the feed for new stuff.
+ * @return integer Average publication time (in seconds)
+ */
+ public function getAveragePublicationInterval()
+ {
+ $start = null;
+
+ if (count($this->items) < 1)
+ {
+ return 3600 * 24;
+ }
+
+ $start = $this->items[0]->date;
+ $end = $this->items[count($this->items) - 1]->date;
+
+ $diff = abs($end - $start);
+ return (int) round($diff / count($this->items));
+ }
+
+ /**
+ * Get a feed item
+ * @param integer $key Item number, if omitted it will return the next item in the list
+ * @return object stdClass object with informations and raw content of the item
+ */
+ public function getItem($key = false)
+ {
+ if (!$this->items)
+ {
+ return false;
+ }
+
+ if ($key === false)
+ {
+ $key = $this->current_item++;
+
+ if ($key > count($this->items))
+ {
+ $this->reset();
+ return false;
+ }
+ }
+
+ if (!array_key_exists($key, $this->items))
+ return null;
+
+ return $this->items[$key];
+ }
+
+ /**
+ * Reset the iteration counter for getItem()
+ * @return void
+ */
+ public function reset()
+ {
+ $this->current_item = 0;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/KD2/FileInfo.php b/src/include/lib/KD2/FileInfo.php
new file mode 100644
index 0000000..8cd7dca
--- /dev/null
+++ b/src/include/lib/KD2/FileInfo.php
@@ -0,0 +1,312 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+/**
+ * Various tools to get informations on files
+ */
+
+class FileInfo
+{
+ /**
+ * Magic numbers, taken from fileinfo package
+ * Every key contains the mimetype, and the value is an array
+ * containing magic numbers
+ * 'image/gif' => ['GIF'] // will match only if the string 'GIF' is found at position 0
+ * 'application/epub+zip' => ["PK\003\004", 30 => 'mimetypeapplication/epub+zip']
+ * // will only match if "PK\003\004" is found at position 0 and
+ * // 'mimetypeapplication/epub+zip' is found at position 30
+ * @var array
+ */
+ static public $magic_numbers = [
+ // Images
+ 'image/gif' => [['GIF87a'], ['GIF89a']],
+ 'image/png' => ["\x89PNG"],
+ 'image/jpeg'=> ["\xff\xd8\xff"],
+ 'image/tiff'=> [["\x49\x49\x2A\x00"], ["\x4D\x4D\x00\x2A"]],
+ 'image/bmp' => ['BM'],
+ 'image/vnd.adobe.photoshop' => ['8BPS'],
+ 'image/x-icon' => ["\000\000\001\000"],
+ 'image/xcf' => ['gimp xcf'],
+ 'image/svg+xml' => '/^\s*<\?xml.*?\?>\s* [
+ ["\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", 546 => 'jbjb'],
+ ["\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", 546 => 'bjbj']
+ ],
+ 'application/x-msoffice' => ["\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
+ 'application/vnd.openxmlformats-officedocument' => ["PK\x03\x04\x14\x00\x06\x00"],
+ 'application/pdf' => ['%PDF-'],
+ 'application/postscript' => [["\004%"], ['%!PS-Adobe-']],
+ 'application/epub+zip' => ["PK\003\004", 30 => 'mimetypeapplication/epub+zip'],
+
+ // Open Office 1.x
+ 'application/vnd.sun.xml.writer'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.sun.xml.writer'],
+ 'application/vnd.sun.xml.calc'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.sun.xml.calc'],
+ 'application/vnd.sun.xml.draw'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.sun.xml.draw'],
+ 'application/vnd.sun.xml.impress'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.sun.xml.impress'],
+ 'application/vnd.sun.xml.math'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.sun.xml.math'],
+ 'application/vnd.sun.xml.base'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.sun.xml.base'],
+
+ // Open Office 2.x
+ 'application/vnd.oasis.opendocument.text'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.oasis.opendocument.text'],
+ 'application/vnd.oasis.opendocument.graphics'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.oasis.opendocument.graphics'],
+ 'application/vnd.oasis.opendocument.presentation'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.oasis.opendocument.presentation'],
+ 'application/vnd.oasis.opendocument.spreadsheet'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.oasis.opendocument.spreadsheet'],
+ 'application/vnd.oasis.opendocument.chart'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.oasis.opendocument.chart'],
+ 'application/vnd.oasis.opendocument.formula'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.oasis.opendocument.formula'],
+ 'application/vnd.oasis.opendocument.image'
+ => ["PK\003\004", 26 => "\x8\0\0\0mimetypeapplication/", 50 => 'vnd.oasis.opendocument.image'],
+
+ // Video/audio
+ 'audio/x-ms-asf' => ["\x30\x26\xb2\x75"],
+ 'audio/x-wav' => ['RIFF', 8 => 'WAVE'],
+ 'video/x-msvideo' => [['RIFF', 8 => 'AVI'], ['RIFX']],
+ 'video/quicktime' => [['mdat'], ['moov']],
+ 'video/mpeg' => [["\x1B\x3"], ["\x1B\xA"], ["\x1E\x0"]],
+ 'audio/mpeg' => [["\xff\xfb"], ['ID3']],
+ 'audio/x-pn-realaudio' => ["\x2e\x72\x61\xfd"],
+ 'audio/vnd.rn-realaudio'=> ['.RMF'],
+ 'audio/x-flac' => ['fLaC'],
+ 'application/ogg' => ['OggS'],
+ 'audio/midi' => ['MThd'],
+
+ // Text
+ 'text/xml' => [' ['{\\rtf'],
+ 'text/x-php'=> '/<\?php|<\?=/',
+ 'text/html' => '/ ["PK\003\004"],
+ 'application/x-tar' => [257 => "ustar\0\x06"],
+ 'application/x-xz' => ["\xFD7zXZ\x00"],
+ 'application/x-7z-compressed' => ["7z\xBC\xAF\x27\x1C"],
+
+ 'application/x-gzip' => ["\x1f\x8b"],
+ 'application/x-bzip' => ['BZ0'],
+ 'application/x-bzip2' => ['BZh'],
+ 'application/x-rar' => ['Rar!'],
+
+ 'application/x-shockwave-flash' => [['FWS'], ['CWS']],
+
+ 'application/x-sqlite3' => ['SQLite format 3'],
+ 'text/x-shellscript' => ['#!/'],
+ 'application/octet-stream'=>["\177ELF"],
+ ];
+
+ /**
+ * List of extensions for recognized MIME-types
+ * @var array
+ */
+ static public $mime_extensions = [
+ // Images
+ 'image/gif' => 'gif',
+ 'image/png' => 'png',
+ 'image/jpeg'=> 'jpg',
+ 'image/tiff'=> 'tif',
+ 'image/bmp' => 'bmp',
+ 'image/vnd.adobe.photoshop' => 'psd',
+ 'image/x-icon' => 'ico',
+ 'image/xcf' => 'xcf',
+ 'image/svg+xml' => 'svg',
+
+ // Office documents
+ 'application/msword' => 'doc',
+ 'application/vnd.openxmlformats-officedocument' => 'docx',
+ 'application/pdf' => 'pdf',
+ 'application/postscript' => 'ps',
+ 'application/epub+zip' => 'epub',
+
+ // Open Office 1.x
+ 'application/vnd.sun.xml.writer' => 'sxw',
+ 'application/vnd.sun.xml.calc' => 'sxc',
+ 'application/vnd.sun.xml.draw' => 'sxd',
+ 'application/vnd.sun.xml.impress' => 'sxi',
+ 'application/vnd.sun.xml.math' => 'sxf',
+
+ // Open Office 2.x
+ 'application/vnd.oasis.opendocument.text' => 'odt',
+ 'application/vnd.oasis.opendocument.graphics' => 'odg',
+ 'application/vnd.oasis.opendocument.presentation'=> 'odp',
+ 'application/vnd.oasis.opendocument.spreadsheet'=> 'ods',
+ 'application/vnd.oasis.opendocument.chart' => 'odc',
+ 'application/vnd.oasis.opendocument.formula' => 'odf',
+
+ // Video/audio
+ 'audio/x-ms-asf' => 'asx',
+ 'audio/x-wav' => 'wav',
+ 'video/x-msvideo' => 'avi',
+ 'video/quicktime' => 'mov',
+ 'video/mpeg' => 'mpeg',
+ 'audio/mpeg' => 'mp3',
+ 'audio/x-pn-realaudio' => 'ra',
+ 'audio/vnd.rn-realaudio'=> 'ram',
+ 'audio/x-flac' => 'flac',
+ 'application/ogg' => 'ogg',
+ 'audio/midi' => 'mid',
+
+ // Text
+ 'text/xml' => 'xml',
+ 'text/rtf' => 'rtf',
+ 'text/x-php'=> 'php',
+ 'text/html' => 'html',
+
+ // Others
+ 'application/zip' => 'zip',
+ 'application/x-tar' => 'tar',
+ 'application/x-xz' => 'xz',
+ 'application/x-7z-compressed' => '7z',
+ 'application/x-gzip' => 'gz',
+ 'application/x-bzip' => 'bz',
+ 'application/x-bzip2' => 'bz2',
+ 'application/x-rar' => 'rar',
+
+ 'application/x-shockwave-flash' => 'swf',
+
+ 'application/x-sqlite3' => 'sqlite',
+ 'text/x-shellscript' => 'sh',
+ 'application/octet-stream' => 'exe',
+ ];
+
+ /**
+ * Guesses the MIME type of a file from its content
+ * @param string $bytes First 1024 bytes (or more) of the file
+ * @return mixed Returns a string containing the matched MIME type or FALSE if no MIME type matched
+ */
+ static public function guessMimeType($bytes)
+ {
+ // try to match for every mimetype
+ foreach (self::$magic_numbers as $type=>$match)
+ {
+ // Regexp match
+ if (is_string($match))
+ {
+ if (self::matchRegexp($match, $bytes))
+ return $type;
+
+ continue;
+ }
+
+ // Multiple matches possible for one type
+ if (is_array(current($match)))
+ {
+ foreach ($match as $_submatch)
+ {
+ if (self::matchBytes($_submatch, $bytes))
+ return $type;
+ }
+
+ continue;
+ }
+
+ // Single match
+ if (self::matchBytes($match, $bytes))
+ return $type;
+ }
+
+ return false;
+ }
+
+ /**
+ * Tries to match a regular expression against the first bytes of the file
+ * @param string $regexp Regexp to test
+ * @param string $bytes Binary string of first bytes of the file
+ * @return boolean TRUE if regexp matches, or else FALSE
+ */
+ static protected function matchRegexp($regexp, $bytes)
+ {
+ return preg_match($regexp, $bytes);
+ }
+
+ /**
+ * Tries to match magic numbers at specified positions
+ * @param array $magic associative array: (byte position) => matching string
+ * @param string $bytes Binary string of first bytes
+ * @return boolean TRUE if magic numbers actually match, or else FALSE
+ */
+ static protected function matchBytes($magic, $bytes)
+ {
+ $match = 0;
+ $max = strlen($bytes);
+
+ // Try to match every magic number for this mimetype
+ foreach ($magic as $pos=>$v)
+ {
+ // Content is too short to try matching this mimetype
+ if ($pos > $max)
+ {
+ return false;
+ }
+
+ $len = strlen($v);
+
+ // No match: skip to next mimetype
+ if (substr($bytes, $pos, $len) !== $v)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a file extension from its MIME-type
+ * @param string $type MIME-type, eg. audio/flac
+ * @return string extension, eg. flac
+ */
+ static public function getFileExtensionFromMimeType($type)
+ {
+ foreach (self::$mime_extensions as $mime=>$ext)
+ {
+ if ($mime === $type)
+ return $ext;
+ }
+
+ return false;
+ }
+
+ static public function getMimeTypeFromFileExtension($extension)
+ {
+ foreach (self::$mime_extensions as $mime=>$ext)
+ {
+ if ($extension === $ext)
+ return $mime;
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/KD2/Form.php b/src/include/lib/KD2/Form.php
new file mode 100644
index 0000000..c432607
--- /dev/null
+++ b/src/include/lib/KD2/Form.php
@@ -0,0 +1,632 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+use KD2\Security;
+
+/**
+ * Form management helper
+ * - CSRF protection
+ * - validate form fields
+ * - return form fields
+ */
+class Form
+{
+ /**
+ * Custom validation rules
+ * @var array
+ */
+ static protected $custom_validation_rules = [];
+
+ /**
+ * Secret used for tokens
+ * @var string
+ */
+ static protected $token_secret;
+
+ /**
+ * Sets the secret key used to hash and check the CSRF tokens
+ * @param string $secret Whatever secret you may like, must be the same for all the user session
+ * @return boolean true
+ */
+ static public function tokenSetSecret($secret)
+ {
+ self::$token_secret = $secret;
+ return true;
+ }
+
+ /**
+ * Generate a single use token and return the value
+ * The token will be HMAC signed and you can use it directly in a HTML form
+ * @param string $action An action description, if NULL then REQUEST_URI will be used
+ * @param integer $expire Number of hours before the hash will expire
+ * @return string HMAC signed token
+ */
+ static public function tokenGenerate($action = null, $expire = 5)
+ {
+ if (is_null(self::$token_secret)) {
+ throw new \RuntimeException('No CSRF token secret has been set.');
+ }
+
+ $user_secret = $_COOKIE['__c'] ?? null;
+
+ if (null === $user_secret) {
+ if (headers_sent()) {
+ throw new \RuntimeException('Headers have already been sent, cannot generate a token');
+ }
+
+ $user_secret = bin2hex(random_bytes(10));
+
+ // Store user secret in a cookie
+ setcookie('__c', $user_secret, [
+ 'expires' => 0,
+ 'path' => '/',
+ 'secure' => !empty($_SERVER['HTTPS']) ? true : false,
+ 'httponly' => true,
+ 'samesite' => 'Strict',
+ ]);
+ }
+
+ $action = self::tokenAction($action);
+
+ $random = random_int(0, PHP_INT_MAX);
+ $expire = floor(time() / 3600) + $expire;
+ $value = $expire . $random . $action;
+
+
+ $hash = hash_hmac('sha256', $expire . $random . $action, self::$token_secret . sha1($user_secret));
+
+ return $hash . '/' . dechex($expire) . '/' . dechex($random);
+ }
+
+ /**
+ * Checks a CSRF token
+ * @param string $action An action description, if NULL then REQUEST_URI will be used
+ * @param string $value User supplied value, if NULL then $_POST[automatic name] will be used
+ * @return boolean
+ */
+ static public function tokenCheck($action = null, $value = null)
+ {
+ $user_secret = $_COOKIE['__c'] ?? null;
+
+ if (!$user_secret) {
+ return false;
+ }
+
+ $action = self::tokenAction($action);
+
+ if (is_null($value))
+ {
+ $name = self::tokenFieldName($action);
+
+ if (empty($_POST[$name]))
+ {
+ return false;
+ }
+
+ $value = $_POST[$name];
+ }
+
+ $value = explode('/', $value, 3);
+
+ if (count($value) != 3)
+ {
+ return false;
+ }
+
+ $user_hash = $value[0];
+ $expire = hexdec($value[1]);
+ $random = hexdec($value[2]);
+
+ // Expired token
+ if ($expire < ceil(time() / 3600))
+ {
+ return false;
+ }
+
+ $hash = hash_hmac('sha256', $expire . $random . $action, self::$token_secret . sha1($user_secret));
+
+ return hash_equals($hash, $user_hash);
+ }
+
+ /**
+ * Generates a random field name for the current token action
+ * @param string $action An action description, if NULL then REQUEST_URI will be used
+ * @return string
+ */
+ static public function tokenFieldName($action = null)
+ {
+ $action = self::tokenAction($action);
+ return 'ct_' . sha1($action . $_SERVER['DOCUMENT_ROOT'] . $_SERVER['SERVER_NAME']);
+ }
+
+ /**
+ * Returns the supplied action name or if it is NULL, then the REQUEST_URI
+ * @param string $action
+ * @return string
+ */
+ static protected function tokenAction($action = null)
+ {
+ // Default action, will work as long as the check is on the same URI as the generation
+ if (is_null($action) && !empty($_SERVER['REQUEST_URI']))
+ {
+ $url = parse_url($_SERVER['REQUEST_URI']);
+
+ if (!empty($url['path']))
+ {
+ $action = $url['path'];
+ }
+ }
+
+ return $action;
+ }
+
+ /**
+ * Returns HTML code to embed a CSRF token in a form
+ * @param string $action An action description, if NULL then REQUEST_URI will be used
+ * @return string HTML element
+ */
+ static public function tokenHTML($action = null)
+ {
+ return ' ';
+ }
+
+ /**
+ * Returns TRUE if the form has this key and it's not NULL
+ * @param string $key Key to find in the form
+ * @return boolean
+ */
+ static public function has($key)
+ {
+ return isset($_POST[$key]);
+ }
+
+ /**
+ * Parses rules for form validation
+ * @param string $str Rule description
+ * @return array List of rules with parameters
+ */
+ static protected function parseRules($str)
+ {
+ if (false !== strpos($str, '|')) {
+ $a = '\|';
+ $b = ',';
+ }
+ else {
+ $a = ',';
+ $b = ':';
+ }
+
+ $str = preg_split('/(? $value)
+ {
+ $name = is_int($key) ? $value : $key;
+ $out->$name = self::get($name);
+
+ if (!is_int($key))
+ {
+ $rules = self::parseRules($value);
+
+ foreach ($rules as $rule => $params)
+ {
+ $out->$name = self::filterField($out->$name, $rule, $params);
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ return isset($_POST[$field]) ? $_POST[$field] : null;
+ }
+
+ /**
+ * @deprecated
+ */
+ static public function filterField($value, $filter, array $params = [])
+ {
+ switch ($filter)
+ {
+ case 'date':
+ return new \DateTime($value);
+ case 'date_format':
+ return \DateTime::createFromFormat($params[0], $value);
+ case 'int':
+ case 'integer':
+ return (int) $value;
+ case 'bool':
+ case 'boolean':
+ return (bool) $value;
+ case 'string':
+ return trim($value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Register a custom validation rule
+ *
+ * @param string $name Rule name
+ * @param Callable $callback Callback (must return a boolean)
+ * @return void
+ * @deprecated
+ */
+ static public function registerValidationRule($name, Callable $callback)
+ {
+ self::$custom_validation_rules[$name] = $callback;
+ }
+
+ /**
+ * Check a form field against a rule
+ *
+ * @param string $key Field name
+ * @param string $rule_name Rule name
+ * @param Array $params Parameters of the rule
+ * @param Array $source Source of the field data
+ * @param Array $rules Complete list of rules
+ * @return boolean
+ * @deprecated
+ */
+ static public function validateRule($key, $rule_name, Array $params = [], Array $source = null, Array $rules = [])
+ {
+ $value = isset($source[$key]) ? $source[$key] : null;
+
+ switch ($rule_name)
+ {
+ case 'required':
+ if (isset($rules['file']))
+ {
+ // Checked in 'file' rule
+ return true;
+ }
+ elseif (is_array($value) || $value instanceof \Countable)
+ {
+ return count($value) > 0;
+ }
+ elseif (is_string($value))
+ {
+ return trim($value) !== '';
+ }
+ return !is_null($value);
+ case 'required_with':
+ $required = false;
+
+ foreach ($params as $condition)
+ {
+ if (isset($source[$condition]))
+ {
+ $required = true;
+ break;
+ }
+ }
+
+ return $required ? self::validateRule($key, 'required', $params, $source) : true;
+ case 'required_with_all':
+ $required = 0;
+
+ foreach ($params as $condition)
+ {
+ if (isset($source[$condition]))
+ {
+ $required++;
+ }
+ }
+
+ return $required == count($params) ? self::validateRule($key, 'required', $params, $source) : true;
+ case 'required_without':
+ $required = false;
+
+ foreach ($params as $condition)
+ {
+ if (!isset($source[$condition]))
+ {
+ $required = true;
+ break;
+ }
+ }
+
+ return $required ? self::validateRule($key, 'required', $params, $source) : true;
+ case 'required_without_all':
+ $required = 0;
+
+ foreach ($params as $condition)
+ {
+ if (!isset($source[$condition]))
+ {
+ $required++;
+ }
+ }
+
+ return $required == count($params) ? self::validateRule($key, 'required', $params, $source) : true;
+ case 'required_if':
+ $required = false;
+ $if_value = isset($source[$params[0]]) ? $source[$params[0]] : null;
+
+ for ($i = 1; $i < count($params); $i++)
+ {
+ if ($params[$i] == $if_value)
+ {
+ $required = true;
+ break;
+ }
+ }
+
+ return $required ? self::validateRule($key, 'required', $params, $source) : true;
+ case 'required_unless':
+ $required = true;
+ $if_value = isset($source[$params[0]]) ? $source[$params[0]] : null;
+
+ for ($i = 1; $i < count($params); $i++)
+ {
+ if ($params[$i] == $if_value)
+ {
+ $required = false;
+ break;
+ }
+ }
+
+ return $required ? self::validateRule($key, 'required', $params, $source) : true;
+ case 'absent':
+ return $value === null;
+ }
+
+ // Ignore rules for empty fields, except 'required*'
+ if ($rule_name != 'file' && ($value === null || (is_string($value) && trim($value) === '')))
+ {
+ return true;
+ }
+
+ switch ($rule_name)
+ {
+ case 'file':
+ if (!isset($_FILES[$key]) && isset($rules['required']))
+ {
+ return false;
+ }
+ elseif (!isset($_FILES[$key]))
+ {
+ return true;
+ }
+
+ return ($value = $_FILES[$key]) && !empty($value['size']) && !empty($value['tmp_name']) && empty($value['error']);
+ case 'active_url':
+ $url = parse_url($value);
+ return isset($url['host']) && strlen($url['host']) && (checkdnsrr($url['host'], 'A') || checkdnsrr($url['host'], 'AAAA'));
+ case 'alpha':
+ return preg_match('/^[\pL\pM]+$/u', $value);
+ case 'alpha_dash':
+ return preg_match('/^[\pL\pM\pN_-]+$/u', $value);
+ case 'alpha_num':
+ return preg_match('/^[\pL\pM\pN]+$/u', $value);
+ case 'array':
+ return is_array($value);
+ case 'between':
+ return isset($params[0]) && isset($params[1]) && $value >= $params[0] && $value <= $params[1];
+ case 'boolean':
+ case 'bool':
+ return ($value == 0 || $value == 1);
+ case 'color':
+ return preg_match('/^#?[a-f0-9]{6}$/', $value);
+ case 'confirmed':
+ $key_c = $key . '_confirmed';
+ return isset($source[$key_c]) && $value == $source[$key_c];
+ case 'date':
+ return is_object($value) ? $value instanceof \DateTimeInterface : (bool) strtotime($value);
+ case 'date_format':
+ $date = date_parse_from_format($params[0], $value);
+ return $date['warning_count'] === 0 && $date['error_count'] === 0;
+ case 'different':
+ return isset($params[0]) && isset($source[$params[0]]) && $value != $source[$params[0]];
+ case 'digits':
+ return is_numeric($value) && strlen((string) $value) == $params[0];
+ case 'digits_between':
+ $len = strlen((string) $value);
+ return is_numeric($value) && $len >= $params[0] && $len <= $params[0];
+ case 'email':
+ // Compatibility with IDN domains
+ if (function_exists('idn_to_ascii'))
+ {
+ $host = substr($value, strpos($value, '@') + 1);
+ $host = @idn_to_ascii($host); // Silence errors because of PHP 7.2 http://php.net/manual/en/function.idn-to-ascii.php
+ $value = substr($value, 0, strpos($value, '@')+1) . $host;
+ }
+
+ return filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
+ case 'gt':
+ return isset($params[0]) && isset($source[$params[0]]) && $value > $source[$params[0]];
+ case 'gte':
+ return isset($params[0]) && isset($source[$params[0]]) && $value >= $source[$params[0]];
+ case 'in':
+ return in_array($value, $params);
+ case 'in_array':
+ $field = isset($params[0]) && isset($source[$params[0]]) ? $source[$params[0]] : null;
+ return $field && is_array($field) && in_array($value, $field);
+ case 'integer':
+ case 'int':
+ return is_int($value);
+ case 'ip':
+ return filter_var($value, FILTER_VALIDATE_IP) !== false;
+ case 'json':
+ return json_decode($value) !== null;
+ case 'lt':
+ return isset($params[0]) && isset($source[$params[0]]) && $value < $source[$params[0]];
+ case 'lte':
+ return isset($params[0]) && isset($source[$params[0]]) && $value <= $source[$params[0]];
+ case 'max':
+ $size = is_array($value) ? count($value) : (isset($rules['string']) ? strlen($value) : $value);
+ return isset($params[0]) && $size <= $params[0];
+ case 'min':
+ $size = is_array($value) ? count($value) : (isset($rules['string']) ? strlen($value) : $value);
+ return isset($params[0]) && $size >= $params[0];
+ case 'money':
+ return preg_match('/^-?(\d+)(?:[.,](\d{1,2}))?$/', $value, $match) && ($match[1]*100 + $match[2]) >= 0;
+ case 'not_in':
+ return !in_array($value, $params);
+ case 'numeric':
+ return is_numeric($value);
+ case 'present':
+ return isset($source[$key]);
+ case 'regex':
+ return isset($params[0]) && preg_match($params[0], $value);
+ case 'same':
+ return isset($params[0]) && isset($source[$params[0]]) && $source[$params[0]] == $value;
+ case 'size':
+ $size = is_array($value) ? count($value) : (is_numeric($value) ? $value : strlen($value));
+ return isset($params[0]) && $size == (int) $params[0];
+ case 'string':
+ return is_string($value);
+ case 'timezone':
+ try {
+ new \DateTimeZone($value);
+ return true;
+ }
+ catch (\Exception $e) {
+ return false;
+ }
+ case 'url':
+ return filter_var($value, FILTER_VALIDATE_URL, FILTER_FLAG_SCHEME_REQUIRED | FILTER_FLAG_HOST_REQUIRED) !== false;
+ // Dates
+ case 'after':
+ return isset($params[0]) && ($date1 = strtotime($value)) && ($date2 = strtotime($params[0])) && $date1 > $date2;
+ case 'after_or_equal':
+ return isset($params[0]) && ($date1 = strtotime($value)) && ($date2 = strtotime($params[0])) && $date1 >= $date2;
+ case 'before':
+ return isset($params[0]) && ($date1 = strtotime($value)) && ($date2 = strtotime($params[0])) && $date1 < $date2;
+ case 'before_or_equal':
+ return isset($params[0]) && ($date1 = strtotime($value)) && ($date2 = strtotime($params[0])) && $date1 <= $date2;
+ default:
+ if (isset(self::$custom_validation_rules[$rule_name]))
+ {
+ return call_user_func_array(self::$custom_validation_rules[$rule_name], [$key, $params, $value, $source]);
+ }
+
+ throw new \UnexpectedValueException('Invalid rule name: ' . $rule_name);
+ }
+ }
+
+ /**
+ * Validate but add CSRF token check to that
+ *
+ * @param string $token_action CSRF token action name
+ * @param Array $all_rules List of rules, eg. 'login' => 'required|string'
+ * @param Array &$errors List of errors encountered
+ * @return boolean
+ * @deprecated
+ */
+ static public function check($token_action, Array $all_rules, Array &$errors = [])
+ {
+ if (!self::tokenCheck($token_action))
+ {
+ $errors[] = ['rule' => 'csrf'];
+ return false;
+ }
+
+ return self::validate($all_rules, $errors);
+ }
+
+ /**
+ * Validate the current form against a set of rules
+ *
+ * Most rules from Laravel are implemented.
+ *
+ * @link https://laravel.com/docs/5.4/validation#available-validation-rules
+ * @param Array $all_rules List of rules, eg. 'login' => 'required|string'
+ * @param Array &$errors Filled with list of errors encountered
+ * @param Array $source Source of form data, if left empty or NULL,
+ * $_POST will be used
+ * @return boolean
+ * @deprecated
+ */
+ static public function validate(Array $all_rules, Array &$errors = null, Array $source = null)
+ {
+ if (is_null($errors))
+ {
+ $errors = [];
+ }
+
+ if (is_null($source))
+ {
+ $source = $_POST;
+ }
+
+ foreach ($all_rules as $key => $rules)
+ {
+ if ($return = self::validateField($key, $rules, $source)) {
+ $errors[] = $return;
+ }
+ }
+
+ return count($errors) == 0 ? true : false;
+ }
+
+ /**
+ * Validate a field against a list of rules
+ * @param string $key Name of the field
+ * @param array|string $rules List of rules, either as an associative array of type rule_name => [...parameters] or a string
+ * @param array $source Source array of user data (eg. $_POST)
+ * @return array Array containing the first error encountered for the field (as an array), or NULL if no error was found
+ * @deprecated
+ */
+ static public function validateField(string $key, $rules, array $source): ?array
+ {
+ $rules = is_array($rules) ? $rules : self::parseRules($rules);
+
+ foreach ($rules as $name => $params)
+ {
+ if (!self::validateRule($key, $name, $params, $source, $rules))
+ {
+ return ['name' => $key, 'rule' => $name, 'params' => $params];
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/include/lib/KD2/Fossil.php b/src/include/lib/KD2/Fossil.php
new file mode 100644
index 0000000..e24fda2
--- /dev/null
+++ b/src/include/lib/KD2/Fossil.php
@@ -0,0 +1,137 @@
+db = new DB_SQLite3($repo_file, SQLITE3_OPEN_READONLY);
+ $this->db->createFunction('repo_url', [$this, 'getURL']);
+ $this->url = $url;
+ }
+
+ public function getURL($uri = '')
+ {
+ return $this->url . $uri;
+ }
+
+ public function listUnversioned()
+ {
+ return $this->db->getGrouped('SELECT uvid, name, sz AS size, mtime, hash, repo_url(\'uv/\' || name) AS url
+ FROM unversioned WHERE hash IS NOT NULL ORDER BY mtime DESC;');
+ }
+
+ public function getTicketsStatus()
+ {
+ return $this->db->get('SELECT DISTINCT status FROM ticket;');
+ }
+
+ public function getTicketsTypes()
+ {
+ return $this->db->get('SELECT DISTINCT type FROM ticket;');
+ }
+
+ public function listTickets(array $filters = [])
+ {
+ $query = 'SELECT *, repo_url(\'tktview?name=\' || substr(tkt_uuid, 1, 10)) AS url FROM ticket WHERE 1';
+ $args = [];
+
+ foreach ($filters as $field => $value)
+ {
+ $query .= ' AND ' . $field . ' = ?';
+ $args[] = $value;
+ }
+
+ $query .= ' ORDER BY tkt_mtime DESC;';
+
+ return $this->db->get($query, $args);
+ }
+
+ public function bundlePreview($file, $force = false)
+ {
+ if (!is_readable($file))
+ {
+ throw new \RuntimeException('Cannot read file: ' . $file);
+ }
+
+ try {
+ $this->db->preparedQuery('ATTACH ? AS b1;', [$file]);
+ $a = $this->db->prepare('SELECT bcname, bcvalue FROM b1.bconfig LIMIT 1;');
+ $b = $this->db->prepare('SELECT blobid, uuid, sz, delta, notes, data FROM b1.bblob;');
+
+ if (!$a || !$b)
+ {
+ return false;
+ }
+ } catch (\Exception $e) {
+ $this->db->exec('DETACH b1;');
+ throw new \RuntimeException('Not a valid bundle file: ' . $e->getMessage(), 0, $e);
+ }
+
+ /* Only import a bundle that was generated from a repo with the same
+ ** project code, unless the --force flag is true */
+
+ if (!$force)
+ {
+ $exists = $this->db->firstColumn('SELECT 1 FROM config, bconfig
+ WHERE config.name = \'project-code\'
+ AND bconfig.bcname = \'project-code\'
+ AND config.value = bconfig.bcvalue;');
+
+ if (!$exists)
+ {
+ $this->db->exec('DETACH b1;');
+ throw new \RuntimeException('project-code in the bundle does not match the repository project code.');
+ }
+ }
+
+ /* If the bundle contains deltas with a basis that is external to the
+ ** bundle and those external basis files are missing from the local
+ ** repo, then the delta encodings cannot be decoded and the bundle cannot
+ ** be extracted. */
+ $missing_deltas = $this->db->firstColumn('SELECT group_concat(substr(delta,1,10), \' \') FROM bblob WHERE typeof(delta) = \'text\' AND length(delta) >= ? AND NOT EXISTS(SELECT 1 FROM blob WHERE uuid = bblob.delta)', self::HNAME_MIN);
+
+ if ($missing_deltas)
+ {
+ $this->db->exec('DETACH b1;');
+ throw new \RuntimeException('delta basis artifacts not found in repository: ' . $missing_deltas);
+ }
+
+ $this->db->begin();
+
+ $this->db->exec('CREATE TEMP TABLE bix(
+ blobid INTEGER PRIMARY KEY,
+ delta INTEGER
+ );
+ CREATE INDEX bixdelta ON bix(delta);
+ INSERT INTO bix(blobid,delta)
+ SELECT blobid,
+ CASE WHEN typeof(delta)==\'integer\'
+ THEN delta ELSE 0 END
+ FROM bblob
+ WHERE NOT EXISTS(SELECT 1 FROM blob WHERE uuid=bblob.uuid AND size>=0);
+ CREATE TEMP TABLE got(rid INTEGER PRIMARY KEY ON CONFLICT IGNORE);');
+
+ /*
+ manifest_crosslink_begin();
+ bundle_import_elements(0, 0, isPriv);
+ manifest_crosslink_end(0);
+ describe_artifacts_to_stdout("IN got", "Imported content:");
+ */
+
+ $this->db->commit();
+
+ // gzuncompress on each delta
+
+ $this->db->exec('DETACH b1;');
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/KD2/FossilInstaller.php b/src/include/lib/KD2/FossilInstaller.php
new file mode 100644
index 0000000..3304d54
--- /dev/null
+++ b/src/include/lib/KD2/FossilInstaller.php
@@ -0,0 +1,444 @@
+
+ */
+
+class FossilInstaller
+{
+ const DEFAULT_REGEXP = '/app-(?P.*)\.tar\.gz/';
+
+ protected array $releases;
+ protected string $app_path;
+ protected string $tmp_path;
+ protected string $fossil_url;
+ protected string $release_name_regexp;
+ protected array $ignored_paths = [];
+ protected string $gpg_pubkey_file;
+
+ public function __construct(string $fossil_repo_url, string $app_path, string $tmp_path, ?string $release_name_regexp = null)
+ {
+ $this->fossil_url = $fossil_repo_url;
+ $this->app_path = $app_path;
+ $this->tmp_path = $tmp_path;
+ $this->release_name_regexp = $release_name_regexp;
+ }
+
+ public function __destruct()
+ {
+ $this->prune();
+ }
+
+ public function setPublicKeyFile(string $file)
+ {
+ $this->gpg_pubkey_file = $file;
+ }
+
+ /**
+ * Ignore some paths during upgrade
+ * @param string $path Paths are relative to the installation directory
+ */
+ public function addIgnoredPath(string $path)
+ {
+ $this->ignored_paths[] = $path;
+ }
+
+ public function listReleases(): array
+ {
+ if (isset($this->releases)) {
+ return $this->releases;
+ }
+
+ $list = (new HTTP)->GET($this->fossil_url . 'juvlist');
+
+ if (!$list) {
+ return [];
+ }
+
+ $list = json_decode($list);
+
+ if (!$list) {
+ return [];
+ }
+
+ $this->releases = [];
+
+ foreach ($list as $item) {
+ if (!isset($item->name, $item->hash, $item->size, $item->mtime)) {
+ continue;
+ }
+
+ if (!preg_match($this->release_name_regexp, $item->name, $match)) {
+ continue;
+ }
+
+ list(, $version) = $match;
+
+ $item->signed = false;
+ $item->stable = preg_match('/alpha|dev|rc|beta/', $version) ? false : true;
+ $this->releases[$version] = $item;
+ }
+
+ // Add signed information
+ foreach ($list as $item) {
+ if (substr($item->name, -4) !== '.asc') {
+ continue;
+ }
+
+ $name = substr($item->name, 0, -4);
+
+ foreach ($this->releases as &$r) {
+ if ($r->name == $name) {
+ $r->signed = true;
+ }
+ }
+ }
+
+ unset($r);
+
+ return $this->releases;
+ }
+
+ public function latest(bool $stable_only = true): ?string
+ {
+ $releases = $this->listReleases();
+
+ $latest = null;
+
+ foreach ($releases as $version => $r) {
+ if ($stable_only && !$r->stable) {
+ continue;
+ }
+
+ if (!$latest || version_compare($version, $latest, '>')) {
+ $latest = $version;
+ }
+ }
+
+ return $latest;
+ }
+
+ public function download(string $version): string
+ {
+ if (!isset($this->releases[$version])) {
+ throw new \InvalidArgumentException('Unknown release');
+ }
+
+ $release = $this->releases[$version];
+
+ $url = sprintf('%suv/%s', $this->fossil_url, $release->name);
+ $tmpfile = $this->_getTempFilePath($version);
+ $r = (new HTTP)->GET($url);
+
+ if (!$r->fail && $r->body) {
+ file_put_contents($tmpfile, $r->body);
+ touch($tmpfile);
+ }
+
+ if (!file_exists($tmpfile)) {
+ throw new \RuntimeException('Error while downloading file');
+ }
+
+ $can_check_hash = in_array('sha3-256', hash_algos());
+
+ if ($can_check_hash && !hash_equals(hash_file('sha3-256', $tmpfile), $release->hash)) {
+ @unlink($tmpfile);
+ throw new \RuntimeException('Error while downloading file: invalid hash');
+ }
+
+ return $tmpfile;
+ }
+
+ protected function _getTempFilePath(string $version): string
+ {
+ return $this->tmp_path . '/tmp-release-' . sha1($version) . '.tar.gz';
+ }
+
+ public function verify(string $version): ?bool
+ {
+ if (!isset($this->releases[$version])) {
+ throw new \InvalidArgumentException('Unknown release');
+ }
+
+ $tmpfile = $this->_getTempFilePath($version);
+
+ if (!file_exists($tmpfile)) {
+ throw new \LogicException('This release has not been downloaded yet');
+ }
+
+ $release = $this->releases[$version];
+
+ $can_check_hash = in_array('sha3-256', hash_algos());
+
+ if ($can_check_hash && !hash_equals(hash_file('sha3-256', $tmpfile), $release->hash)) {
+ @unlink($tmpfile);
+ throw new \RuntimeException('Error while downloading file: invalid hash');
+ }
+
+ if (!$release->signed) {
+ return null;
+ }
+
+ if (!Security::canUseEncryption()) {
+ return null;
+ }
+
+ $url = sprintf('%suv/%s.asc', $this->fossil_url, $release->name);
+ $r = (new HTTP)->GET($url);
+
+ if ($r->fail || !$r->body) {
+ return null;
+ }
+
+ $key = file_get_contents($this->gpg_pubkey_file);
+ $data = file_get_contents($tmpfile);
+
+ return Security::verifyWithPublicKey($key, $data, $r->body);
+ }
+
+ /**
+ * Remove old stale downloaded files
+ * @return void
+ */
+ public function prune(int $delay = 3600 * 24): void
+ {
+ $files = self::recursiveList($this->tmp_path, 'tmp-release-*');
+ $dirs = [];
+
+ foreach ($files as $file) {
+ if (is_dir($file)) {
+ $dirs[] = $file;
+ continue;
+ }
+
+ if (!$delay || filemtime($file) < (time() - $delay)) {
+ @unlink($file);
+ }
+ }
+
+ // Try to remove directories
+ foreach ($dirs as $dir) {
+ @rmdir($dir);
+ }
+ }
+
+ public function clean(string $version): void
+ {
+ $path = $this->_getTempFilePath($version);
+ self::recursiveDelete(dirname($path), basename($path) . '*');
+ }
+
+ static protected function recursiveDelete(string $path, string $pattern = '*') {
+ $files = self::recursiveList($path, $pattern);
+
+ $dirs = [];
+
+ foreach ($files as $file) {
+ if (is_dir($file)) {
+ $dirs[] = $file;
+ continue;
+ }
+
+ @unlink($file);
+ }
+
+ foreach ($dirs as $dir) {
+ @rmdir($dir);
+ }
+ }
+
+ public function diff(string $version): \stdClass
+ {
+ $this->listReleases();
+
+ if (!isset($this->releases[$version])) {
+ throw new \InvalidArgumentException('Unknown release');
+ }
+
+ $tmpfile = $this->_getTempFilePath($version);
+
+ if (!file_exists($tmpfile)) {
+ throw new \LogicException('This release has not been downloaded yet');
+ }
+
+ $release = $this->releases[$version];
+
+ $phar = new \PharData($tmpfile,
+ \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::KEY_AS_PATHNAME
+ | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS);
+
+
+ // List existing files
+ $existing_files = [];
+ $l = strlen($this->app_path);
+
+ foreach (self::recursiveList($this->app_path) as $path) {
+ if (is_dir($path)) {
+ continue;
+ }
+
+ $file = substr($path, $l + 1);
+
+ // Skip ignored paths
+ foreach ($this->ignored_paths as $ignored_path) {
+ if (0 === strpos($file, $ignored_path)) {
+ continue(2);
+ }
+ }
+
+ $existing_files[$file] = $path;
+ }
+
+ // List files
+ $release_files = [];
+ $update = [];
+
+ // We are always ignoring the first directory level
+ $parent = $phar->getPathName();
+ $parent_l = strlen($parent);
+
+ foreach (new \RecursiveIteratorIterator($phar) as $path => $file) {
+ if ($file->isDir()) {
+ // Skip directories
+ continue;
+ }
+
+ $relative_path = substr($path, $parent_l + 1);
+ $release_files[$relative_path] = $path;
+
+ $is_ignored = false;
+
+ // Skip ignored paths
+ foreach ($this->ignored_paths as $ignored_path) {
+ if (0 === strpos($relative_path, $ignored_path)) {
+ $is_ignored = true;
+ break;
+ }
+ }
+
+ $local_path = $this->app_path . DIRECTORY_SEPARATOR . $relative_path;
+
+ // Skip if file doesn't exist, it will be marked as to be created
+ if (!file_exists($local_path)) {
+ continue;
+ }
+
+ if ($file->getSize() != filesize($local_path)
+ || sha1_file($local_path) != sha1_file($path)) {
+ $update[$relative_path] = $path;
+ }
+ elseif ($is_ignored) {
+ unset($release_files[$relative_path]);
+ }
+ }
+
+ $create = array_diff_key($release_files, $existing_files);
+ $delete = array_diff_key($existing_files, $release_files);
+
+ ksort($create);
+ ksort($delete);
+ ksort($update);
+
+ return (object) compact('delete', 'create', 'update');
+ }
+
+ public function upgrade(string $version): void
+ {
+ $diff = $this->diff($version);
+
+ foreach ($diff->delete as $file => $path) {
+ @unlink($path);
+ }
+
+ // FIXME: Clean up empty directories
+
+ foreach ($diff->create as $file => $source) {
+ $this->_copy($source, $this->app_path . DIRECTORY_SEPARATOR . $file);
+ }
+
+ foreach ($diff->update as $file => $source) {
+ $this->_copy($source, $this->app_path . DIRECTORY_SEPARATOR . $file);
+
+ if (function_exists('opcache_invalidate')) {
+ @opcache_invalidate($this->app_path . DIRECTORY_SEPARATOR . $file, true);
+ }
+ }
+
+ $this->clean($version);
+ }
+
+ protected function _copy(string $source, string $target): bool
+ {
+ $dir = dirname($target);
+
+ if (!file_exists($dir)) {
+ mkdir($dir, 0777, true);
+ }
+
+ return copy($source, $target);
+ }
+
+ public function install(string $version)
+ {
+ if (!isset($this->releases[$version])) {
+ throw new \InvalidArgumentException('Unknown release');
+ }
+
+ $tmpfile = $this->_getTempFilePath($version);
+ $phar = new \PharData($tmpfile, \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::KEY_AS_PATHNAME
+ | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS);
+ // Ignore first level directory
+ $root_l = strlen($phar->getPathName());
+
+ foreach (new \RecursiveIteratorIterator($phar) as $source => $_file) {
+ $file = substr($source, $root_l + 1);
+ $this->_copy($source, $this->app_path . DIRECTORY_SEPARATOR . $file);
+ }
+ }
+
+ public function autoinstall(?string $version = null): void
+ {
+ $version ??= $this->latest();
+
+ if (!$version) {
+ return;
+ }
+
+ $this->download($version);
+
+ if (isset($this->gpg_pubkey_file)) {
+ $this->verify($version);
+ }
+
+ $this->install($version);
+ $this->clean($version);
+ }
+
+ static protected function recursiveList(string $path, string $pattern = '*')
+ {
+ $out = [];
+ $length = strlen($path);
+
+ foreach (glob($path . DIRECTORY_SEPARATOR . $pattern, \GLOB_NOSORT) as $subpath) {
+ $out[] = $subpath;
+
+ if (is_dir($subpath)) {
+ $out = array_merge($out, self::recursiveList($subpath));
+ }
+ }
+
+ return $out;
+ }
+}
diff --git a/src/include/lib/KD2/Garbage2xhtml.php b/src/include/lib/KD2/Garbage2xhtml.php
new file mode 100644
index 0000000..eb7e260
--- /dev/null
+++ b/src/include/lib/KD2/Garbage2xhtml.php
@@ -0,0 +1,878 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+/*
+ Garbage2xhtml lib
+ Takes a html text and returns something semantic and maybe valid
+
+ Copyleft (C) 2006-13 BohwaZ - http://bohwaz.net/
+*/
+
+class Garbage_Exception extends \Exception
+{
+}
+
+class Garbage2xhtml
+{
+ /**
+ * Secure attributes contents?
+ * Will check for url scheme and url content in href and src
+ * It's advised to disable
+
+
+EOF;
+ }
+
+ /**
+ * Returns a base64 string safe for URLs
+ * @param string $str
+ * @return string
+ */
+ static public function base64_encode_url_safe($str)
+ {
+ return rtrim(strtr(base64_encode($str), '+/', '-_'), '=');
+ }
+
+ /**
+ * Decodes a URL safe base64 string
+ * @param string $str
+ * @return string
+ */
+ static public function base64_decode_url_safe($str)
+ {
+ return base64_decode(str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT));
+ }
+}
diff --git a/src/include/lib/KD2/ZipReader.php b/src/include/lib/KD2/ZipReader.php
new file mode 100644
index 0000000..b660524
--- /dev/null
+++ b/src/include/lib/KD2/ZipReader.php
@@ -0,0 +1,494 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+use LogicException;
+use RuntimeException;
+
+/**
+ * Very simple ZIP Archive reader
+ *
+ * for specs see http://www.pkware.com/appnote
+ * @see https://github.com/splitbrain/php-archive/blob/460c20518033e8478d425c48e7bb0bd348b10486/src/Zip.php
+ */
+class ZipReader
+{
+ protected $fp = null;
+ protected ?array $entries;
+ protected bool $close = false;
+
+ /**
+ * Max. allowed uncompressed size: 5 GB
+ * @var int
+ */
+ protected int $max_size = 1024*1024*1024*5;
+
+ /**
+ * Max. allowed number of files
+ * @var integer
+ */
+ protected int $max_files = 50000;
+
+ /**
+ * Max. allowed levels of subdirectories
+ */
+ protected int $max_levels = 10;
+
+ protected bool $security_check = true;
+
+ public function setPointer($fp)
+ {
+ $this->fp = $fp;
+ $this->entries = null;
+
+ if ($this->security_check) {
+ $this->securityCheck();
+ }
+ }
+
+ public function open(string $file)
+ {
+ if (!is_readable($file)) {
+ throw new \InvalidArgumentException('Could not open ZIP file for reading: ' . $file);
+ }
+
+ $this->setPointer(fopen($file, 'rb'));
+ $this->close = true;
+ }
+
+ public function setMaxUncompressedSize(int $size): void
+ {
+ $this->max_size = $size;
+ }
+
+ public function setMaxFiles(int $files): void
+ {
+ $this->max_files = $files;
+ }
+
+ public function setMaxDirectoryLevels(int $levels): void
+ {
+ $this->max_levels = $levels;
+ }
+
+ public function enableSecurityCheck(bool $enable): void
+ {
+ $this->security_check = $enable;
+ }
+
+ public function securityCheck(): void
+ {
+ $size = 0;
+ $files = 0;
+ $levels = 0;
+
+ foreach ($this->iterate() as $file) {
+ $size += $file['size'];
+ $files++;
+ $levels = max($levels, substr_count($file['filename'], '/'));
+
+ if ($size > $this->max_size) {
+ throw new \OutOfBoundsException(sprintf('Uncompressed size is larger than max. allowed (%d bytes).', $this->max_size));
+ }
+
+ if ($files > $this->max_files) {
+ throw new \OutOfBoundsException(sprintf('The archive contains more files than allowed (max. %d files).', $this->max_files));
+ }
+
+ if ($levels > $this->max_levels) {
+ throw new \OutOfBoundsException(sprintf('The archive contains more levels of subdirectories than allowed (max. %d levels).', $this->max_levels));
+ }
+
+ if (false !== strpos($file['filename'], '..')) {
+ throw new \OutOfBoundsException('Invalid filename in archive: ' . $file['filename']);
+ }
+ }
+ }
+
+ public function iterate(): \Generator
+ {
+ if (isset($this->entries)) {
+ yield from $this->entries;
+ return;
+ }
+
+ $centd = $this->readCentralDir();
+
+ @rewind($this->fp);
+ @fseek($this->fp, $centd['offset']);
+
+ for ($i = 0; $i < $centd['entries']; $i++) {
+ $header = $this->readCentralFileHeader();
+
+ $prev_pos = ftell($this->fp);
+ fseek($this->fp, $header['offset']);
+
+ // Use local file header
+ $header = $this->readFileHeader($header);
+ $header['start'] = ftell($this->fp);
+
+ fseek($this->fp, $prev_pos);
+
+ $name = $header['extra']['utf8path'] ?? $header['filename'];
+ $name = str_replace('\\', '/', $name);
+
+ $this->entries[$name] = $header;
+ yield $name => $header;
+ }
+ }
+
+ public function has(string $file): bool
+ {
+ $this->_load();
+
+ return array_key_exists($file, $this->entries);
+ }
+
+ public function fetch(string $path): ?string
+ {
+ $this->_load();
+
+ if (!array_key_exists($path, $this->entries)) {
+ return null;
+ }
+
+ return $this->extract($this->entries[$path]);
+ }
+
+ /**
+ * Extract the whole archive to a a directory, or a specific file to a path
+ */
+ public function extractTo(string $destination, ?string $path = null): int
+ {
+ if (null !== $path) {
+ $this->_load();
+
+ if (!isset($this->entries[$path])) {
+ return 0;
+ }
+
+ $this->extract($this->entries[$path], $destination);
+
+ return 1;
+ }
+
+ $count = 0;
+
+ foreach ($this->iterate() as $file) {
+ $dest = $destination . str_replace('/', DIRECTORY_SEPARATOR, $file['filename']);
+ $this->extract($file, $dest);
+ $count++;
+ }
+
+ return $count;
+ }
+
+ /**
+ * Extract a file into a file pointer resource
+ */
+ public function extractToPointer($pointer, string $path): bool
+ {
+ $this->_load();
+
+ if (!isset($this->entries[$path])) {
+ return false;
+ }
+
+ $this->extract($this->entries[$path], $pointer);
+ return true;
+ }
+
+ /**
+ * Return the total uncompressed size of all files in the archive
+ */
+ public function uncompressedSize(): int
+ {
+ $size = 0;
+
+ foreach ($this->iterate() as $file) {
+ $size += $file->size;
+ }
+
+ return $size;
+ }
+
+ protected function _load(): void
+ {
+ if (isset($this->entries)) {
+ return;
+ }
+
+ foreach ($this->iterate() as $file) {
+ // Just load
+ }
+ }
+
+ public function extract(array $header, $destination = null): ?string
+ {
+ fseek($this->fp, $header['start']);
+
+ $is_file = false;
+
+ if (is_string($destination)) {
+ $is_file = true;
+ $destination = fopen($destination, 'wb');
+ }
+ elseif (null !== $destination && !is_resource($destination)) {
+ throw new \InvalidArgumentException('Only a file pointer or a string can be specified');
+ }
+ elseif (null === $destination) {
+ $str = '';
+ }
+
+ if ($header['compression'] != 0) {
+ // hack, see https://groups.google.com/forum/#!topic/alt.comp.lang.php/37_JZeW63uc
+ $pos = ftell($this->fp);
+ rewind($this->fp);
+ fseek($this->fp, $pos);
+
+ $filter = stream_filter_append($this->fp, 'zlib.inflate', \STREAM_FILTER_READ);
+ }
+
+ $limit = $header['size'];
+ $offset = 0;
+
+ while ($offset < $limit) {
+ $length = min(8192, $limit - $offset);
+
+ try {
+ $buffer = fread($this->fp, $length);
+ }
+ catch (\Throwable $e) {
+ if (false !== strpos($e->getMessage(), 'zlib: data error')) {
+ throw new \RuntimeException(sprintf('Invalid compressed data for entry "%s".', $header['filename']), 0, $e);
+ }
+
+ throw $e;
+ }
+
+ if ($buffer === false) {
+ throw new \RuntimeException(sprintf('Error reading the contents of entry "%s".', $header['filename']));
+ }
+
+ if ($destination) {
+ fwrite($destination, $buffer);
+ }
+ else {
+ $str .= $buffer;
+ }
+
+ $offset += $length;
+ }
+
+ if ($header['compression'] != 0) {
+ stream_filter_remove($filter);
+ }
+
+ if ($is_file) {
+ fclose($destination);
+ }
+
+ if ($destination) {
+ return null;
+ }
+ else {
+ return $str;
+ }
+ }
+
+ protected function readCentralFileHeader(): array
+ {
+ $binary_data = fread($this->fp, 46);
+ $header = unpack(
+ 'vchkid/vid/vversion/vversion_extracted/vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len/vcomment_len/vdisk/vinternal/Vexternal/Voffset',
+ $binary_data
+ );
+
+ if ($header['filename_len'] != 0) {
+ $header['filename'] = fread($this->fp, $header['filename_len']);
+ } else {
+ $header['filename'] = '';
+ }
+
+ if ($header['extra_len'] != 0) {
+ $header['extra'] = fread($this->fp, $header['extra_len']);
+ $header['extradata'] = $this->parseExtra($header['extra']);
+ } else {
+ $header['extra'] = '';
+ $header['extradata'] = [];
+ }
+
+ if ($header['comment_len'] != 0) {
+ $header['comment'] = fread($this->fp, $header['comment_len']);
+ } else {
+ $header['comment'] = '';
+ }
+
+ $header['mtime'] = $this->makeUnixTime($header['mdate'], $header['mtime']);
+
+ if (substr($header['filename'], -1) == '/') {
+ $header['external'] = 0x41FF0010;
+ }
+
+ $header['dir'] = ($header['external'] == 0x41FF0010 || $header['external'] == 16) ? 1 : 0;
+ return $header;
+ }
+
+ protected function readFileHeader(array $header)
+ {
+ $binary_data = fread($this->fp, 30);
+ $data = unpack(
+ 'vchk/vid/vversion/vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len',
+ $binary_data
+ );
+
+ $header['filename'] = fread($this->fp, $data['filename_len']);
+
+ if ($data['extra_len'] != 0) {
+ $header['extra'] = fread($this->fp, $data['extra_len']);
+ $header['extradata'] = array_merge($header['extradata'], $this->parseExtra($header['extra']));
+ } else {
+ $header['extra'] = '';
+ $header['extradata'] = array();
+ }
+
+ $header['compression'] = $data['compression'];
+
+ // On ODT files, these headers are 0. Keep the previous value.
+ foreach (['size', 'compressed_size', 'crc'] as $hd) {
+ if ($data[$hd] != 0) {
+ $header[$hd] = $data[$hd];
+ }
+ }
+
+ $header['flag'] = $data['flag'];
+ $header['mtime'] = $this->makeUnixTime($data['mdate'], $data['mtime']);
+ $header['folder'] = ($header['external'] == 0x41FF0010 || $header['external'] == 16) ? 1 : 0;
+ return $header;
+ }
+
+ protected function parseExtra($header)
+ {
+ $extra = [];
+
+ // parse all extra fields as raw values
+ while (strlen($header) !== 0) {
+ $set = unpack('vid/vlen', $header);
+ $header = substr($header, 4);
+ $value = substr($header, 0, $set['len']);
+ $header = substr($header, $set['len']);
+ $extra[$set['id']] = $value;
+ }
+
+ // handle known ones
+ if(isset($extra[0x6375])) {
+ $extra['utf8comment'] = substr($extra[0x6375], 5); // strip version and crc
+ unset($extra[0x6375]);
+ }
+
+ if(isset($extra[0x7075])) {
+ $extra['utf8path'] = substr($extra[0x7075], 5); // strip version and crc
+ unset($extra[0x7075]);
+ }
+
+ return $extra;
+ }
+
+ protected function cpToUtf8($string)
+ {
+ if (function_exists('iconv') && @iconv_strlen('', 'CP437') !== false) {
+ return iconv('CP437', 'UTF-8', $string);
+ } elseif (function_exists('mb_convert_encoding')) {
+ return mb_convert_encoding($string, 'UTF-8', 'CP850');
+ } else {
+ return $string;
+ }
+ }
+
+ protected function makeUnixTime($mdate = null, $mtime = null)
+ {
+ if ($mdate && $mtime) {
+ $year = (($mdate & 0xFE00) >> 9) + 1980;
+ $month = ($mdate & 0x01E0) >> 5;
+ $day = $mdate & 0x001F;
+
+ $hour = ($mtime & 0xF800) >> 11;
+ $minute = ($mtime & 0x07E0) >> 5;
+ $seconde = ($mtime & 0x001F) << 1;
+
+ return mktime($hour, $minute, $seconde, $month, $day, $year);
+ }
+ else {
+ return null;
+ }
+ }
+
+ protected function readCentralDir()
+ {
+ rewind($this->fp);
+
+ if (fread($this->fp, 4) !== "PK\x03\x04") {
+ throw new \InvalidArgumentException('Invalid archive: is not a zip file');
+ }
+
+ fseek($this->fp, 0, SEEK_END);
+ $size = ftell($this->fp);
+ rewind($this->fp);
+
+ if ($size < 277) {
+ $maximum_size = $size;
+ } else {
+ $maximum_size = 277;
+ }
+
+ @fseek($this->fp, $size - $maximum_size);
+ $pos = ftell($this->fp);
+ $bytes = 0x00000000;
+
+ while ($pos < $size) {
+ $byte = @fread($this->fp, 1);
+ $bytes = (($bytes << 8) & 0xFFFFFFFF) | ord($byte);
+ if ($bytes == 0x504b0506) {
+ break;
+ }
+ $pos++;
+ }
+
+ $data = unpack(
+ 'vdisk/vdisk_start/vdisk_entries/ventries/Vsize/Voffset/vcomment_size',
+ fread($this->fp, 18)
+ );
+
+ if ($data['comment_size'] != 0) {
+ $data['comment'] = fread($this->fp, $data['comment_size']);
+ }
+
+ return $data;
+ }
+
+ public function __destruct()
+ {
+ if ($this->fp && $this->close) {
+ @fclose($this->fp);
+ }
+ }
+}
diff --git a/src/include/lib/KD2/ZipWriter.php b/src/include/lib/KD2/ZipWriter.php
new file mode 100644
index 0000000..472d6f5
--- /dev/null
+++ b/src/include/lib/KD2/ZipWriter.php
@@ -0,0 +1,333 @@
+
+
+ Copyright (c) 2001-2019 BohwaZ
+ All rights reserved.
+
+ KD2FW 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.
+
+ Foobar 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 Foobar. If not, see .
+*/
+
+namespace KD2;
+
+use LogicException;
+use RuntimeException;
+
+/**
+ * Very simple ZIP Archive writer
+ *
+ * for specs see http://www.pkware.com/appnote
+ * Inspired by https://github.com/splitbrain/php-archive/blob/master/src/Zip.php
+ */
+class ZipWriter
+{
+ protected $compression = 0;
+ protected $pos = 0;
+ protected $handle;
+ protected $directory = [];
+ protected $closed = false;
+
+ /**
+ * Create a new ZIP file
+ *
+ * @param string $file
+ * @throws RuntimeException
+ */
+ public function __construct($file)
+ {
+ $this->handle = fopen($file, 'wb');
+
+ if (!$this->handle)
+ {
+ throw new RuntimeException('Could not open ZIP file for writing: ' . $file);
+ }
+ }
+
+ /**
+ * Sets compression rate (0 = no compression)
+ *
+ * @param integer $compression 0 to 9
+ * @return void
+ */
+ public function setCompression(int $compression): void
+ {
+ $compression = (int) $compression;
+ $this->compression = max(min($compression, 9), 0);
+ }
+
+ /**
+ * Write to the current ZIP file
+ * @param string $data
+ * @return void
+ */
+ protected function write(string $data): void
+ {
+ // We can't use fwrite and ftell directly as ftell doesn't work on some pointers
+ // (eg. php://output)
+ fwrite($this->handle, $data);
+ $this->pos += strlen($data);
+ }
+
+ /**
+ * Returns the content of the ZIP file
+ *
+ * @return string
+ */
+ public function get(): string
+ {
+ fseek($this->handle, 0);
+ return stream_get_contents($this->handle);
+ }
+
+ public function __destruct()
+ {
+ $this->close();
+ }
+
+ public function addFromPath(string $file, string $path): void
+ {
+ $this->add($file, null, $path);
+ }
+
+ public function addFromPointer(string $file, $pointer): void
+ {
+ $this->add($file, null, null, $pointer);
+ }
+
+
+ public function addFromString(string $file, string $data): void
+ {
+ $this->add($file, $data);
+ }
+
+
+ /**
+ * Add a file to the current Zip archive using the given $data as content
+ *
+ * @param string $file File name
+ * @param string|null $data binary content of the file to add
+ * @param string|null $source Source file to use if no data is supplied
+ * @throws LogicException
+ * @throws RuntimeException
+ */
+ public function add(string $file, ?string $data = null, ?string $source = null, $pointer = null): void
+ {
+ if ($this->closed)
+ {
+ throw new LogicException('Archive has been closed, files can no longer be added');
+ }
+
+ if (null !== $source) {
+ $pointer = fopen($source, 'rb');
+ }
+ elseif (null !== $data) {
+ $size = strlen($data);
+ $crc = crc32($data);
+
+ if ($this->compression)
+ {
+ // Compress data
+ $data = gzdeflate($data, $this->compression);
+ }
+
+ $csize = strlen($data);
+ }
+ elseif (null === $pointer) {
+ throw new LogicException('No source file, pointer or data has been supplied');
+ }
+
+ $tmp = null;
+
+ try {
+ if (null !== $pointer) {
+ $tmp = fopen('php://temp', 'wb');
+ $size = 0;
+ $csize = 0;
+ $crc = hash_init('crc32b');
+ $filter = null;
+
+ if ($this->compression) {
+ $filter = stream_filter_append($tmp,
+ 'zlib.deflate',
+ \STREAM_FILTER_WRITE,
+ ['level' => $this->compression]
+ );
+ }
+
+ while (!feof($pointer)) {
+ $data = fread($pointer, 8192);
+ hash_update($crc, $data);
+ $size += strlen($data);
+ fwrite($tmp, $data);
+ }
+
+ $crc = (int) hexdec(hash_final($crc));
+
+ if ($filter) {
+ stream_filter_remove($filter);
+ $csize = fstat($tmp)['size'];
+ }
+ else {
+ $csize = $size;
+ }
+
+ unset($data, $pointer, $gzip);
+
+ if (-1 === fseek($tmp, 0, SEEK_SET) || ftell($tmp) !== 0) {
+ throw new \RuntimeException('Cannot seek in temporary stream');
+ }
+ }
+
+ $offset = $this->pos;
+
+ // write local file header
+ $this->write($this->makeRecord(false, $file, $size, $csize, $crc, null));
+
+ // we store no encryption header
+
+ // Store external file
+ if ($tmp) {
+ $this->pos += stream_copy_to_stream($tmp, $this->handle);
+ }
+ // Store compressed or uncompressed data
+ // that was supplied
+ else {
+ // write data
+ $this->write($data);
+ }
+ }
+ finally {
+ if (null !== $tmp) {
+ // Always close and delete temporary file
+ fclose($tmp);
+ }
+ }
+
+ // we store no data descriptor
+
+ // add info to central file directory
+ $this->directory[] = $this->makeRecord(true, $file, $size, $csize, $crc, $offset);
+ }
+
+ /**
+ * Add the closing footer to the archive
+ * @throws LogicException
+ */
+ public function finalize(): void
+ {
+ if ($this->closed)
+ {
+ throw new LogicException('The ZIP archive has been closed. Files can no longer be added.');
+ }
+
+ // write central directory
+ $offset = $this->pos;
+ $directory = implode('', $this->directory);
+ $this->write($directory);
+
+ $end_record = "\x50\x4b\x05\x06" // end of central dir signature
+ . "\x00\x00" // number of this disk
+ . "\x00\x00" // number of the disk with the start of the central directory
+ . pack('v', count($this->directory)) // total number of entries in the central directory on this disk
+ . pack('v', count($this->directory)) // total number of entries in the central directory
+ . pack('V', strlen($directory)) // size of the central directory
+ . pack('V', $offset) // offset of start of central directory with respect to the starting disk number
+ . "\x00\x00"; // .ZIP file comment length
+ $this->write($end_record);
+
+ $this->directory = [];
+ $this->closed = true;
+ }
+
+ /**
+ * Close the file handle
+ * @return void
+ */
+ public function close(): void
+ {
+ if (!$this->closed)
+ {
+ $this->finalize();
+ }
+
+ if ($this->handle)
+ {
+ fclose($this->handle);
+ }
+
+ $this->handle = null;
+ }
+
+ /**
+ * Creates a record, local or central
+ * @param boolean $central TRUE for a central file record, FALSE for a local file header
+ * @param string $filename File name
+ * @param integer $size File size
+ * @param integer $compressed_size
+ * @param string $crc CRC32 of the file contents
+ * @param integer|null $offset
+ * @return string
+ */
+ protected function makeRecord(bool $central, string $filename, int $size, int $compressed_size, string $crc, ?int $offset): string
+ {
+ $header = ($central ? "\x50\x4b\x01\x02\x0e\x00" : "\x50\x4b\x03\x04");
+
+ list($filename, $extra) = $this->encodeFilename($filename);
+
+ $header .=
+ "\x14\x00" // version needed to extract - 2.0
+ . "\x00\x08" // general purpose flag - bit 11 set = enable UTF-8 support
+ . ($this->compression ? "\x08\x00" : "\x00\x00") // compression method - none
+ . "\x01\x80\xe7\x4c" // last mod file time and date
+ . pack('V', $crc) // crc-32
+ . pack('V', $compressed_size) // compressed size
+ . pack('V', $size) // uncompressed size
+ . pack('v', strlen($filename)) // file name length
+ . pack('v', strlen($extra)); // extra field length
+
+ if ($central)
+ {
+ $header .=
+ "\x00\x00" // file comment length
+ . "\x00\x00" // disk number start
+ . "\x00\x00" // internal file attributes
+ . "\x00\x00\x00\x00" // external file attributes @todo was 0x32!?
+ . pack('V', $offset); // relative offset of local header
+ }
+
+ $header .= $filename;
+ $header .= $extra;
+
+ return $header;
+ }
+
+ protected function encodeFilename(string $original): array
+ {
+ // For epub/opendocument files
+ if ($original == 'mimetype') {
+ return [$original, ''];
+ }
+
+ $extra = pack(
+ 'vvCV',
+ 0x7075, // tag
+ strlen($original) + 5, // length of file + version + crc
+ 1, // version
+ crc32($original) // crc
+ );
+ $extra .= $original;
+
+ return array($original, $extra);
+ }
+}
diff --git a/src/include/lib/KD2/data/countries.en.json b/src/include/lib/KD2/data/countries.en.json
new file mode 100644
index 0000000..0fa9671
--- /dev/null
+++ b/src/include/lib/KD2/data/countries.en.json
@@ -0,0 +1,251 @@
+{
+ "AF": "Afghanistan",
+ "AX": "\u00c5land Islands",
+ "AL": "Albania",
+ "DZ": "Algeria",
+ "AS": "American Samoa",
+ "AD": "Andorra",
+ "AO": "Angola",
+ "AI": "Anguilla",
+ "AQ": "Antarctica",
+ "AG": "Antigua and Barbuda",
+ "AR": "Argentina",
+ "AM": "Armenia",
+ "AW": "Aruba",
+ "AU": "Australia",
+ "AT": "Austria",
+ "AZ": "Azerbaijan",
+ "BS": "Bahamas",
+ "BH": "Bahrain",
+ "BD": "Bangladesh",
+ "BB": "Barbados",
+ "BY": "Belarus",
+ "BE": "Belgium",
+ "BZ": "Belize",
+ "BJ": "Benin",
+ "BM": "Bermuda",
+ "BT": "Bhutan",
+ "BO": "Bolivia",
+ "BA": "Bosnia and Herzegovina",
+ "BW": "Botswana",
+ "BV": "Bouvet Island",
+ "BR": "Brazil",
+ "IO": "British Indian Ocean Territory",
+ "BN": "Brunei Darussalam",
+ "BG": "Bulgaria",
+ "BF": "Burkina Faso",
+ "BI": "Burundi",
+ "KH": "Cambodia",
+ "CM": "Cameroon",
+ "CA": "Canada",
+ "CV": "Cape Verde",
+ "BQ": "Caribbean Netherlands",
+ "KY": "Cayman Islands",
+ "CF": "Central African Republic",
+ "TD": "Chad",
+ "CL": "Chile",
+ "CN": "China",
+ "CX": "Christmas Island",
+ "CC": "Cocos (Keeling) Islands",
+ "CO": "Colombia",
+ "KM": "Comoros",
+ "CG": "Congo",
+ "CD": "Congo, Democratic Republic of",
+ "CK": "Cook Islands",
+ "CR": "Costa Rica",
+ "CI": "C\u00f4te d'Ivoire",
+ "HR": "Croatia",
+ "CU": "Cuba",
+ "CW": "Cura\u00e7ao",
+ "CY": "Cyprus",
+ "CZ": "Czech Republic",
+ "DK": "Denmark",
+ "DJ": "Djibouti",
+ "DM": "Dominica",
+ "DO": "Dominican Republic",
+ "EC": "Ecuador",
+ "EG": "Egypt",
+ "SV": "El Salvador",
+ "GQ": "Equatorial Guinea",
+ "ER": "Eritrea",
+ "EE": "Estonia",
+ "ET": "Ethiopia",
+ "FK": "Falkland Islands",
+ "FO": "Faroe Islands",
+ "FJ": "Fiji",
+ "FI": "Finland",
+ "FR": "France",
+ "GF": "French Guiana",
+ "PF": "French Polynesia",
+ "TF": "French Southern Territories",
+ "GA": "Gabon",
+ "GM": "Gambia",
+ "GE": "Georgia",
+ "DE": "Germany",
+ "GH": "Ghana",
+ "GI": "Gibraltar",
+ "GR": "Greece",
+ "GL": "Greenland",
+ "GD": "Grenada",
+ "GP": "Guadeloupe",
+ "GU": "Guam",
+ "GT": "Guatemala",
+ "GG": "Guernsey",
+ "GN": "Guinea",
+ "GW": "Guinea-Bissau",
+ "GY": "Guyana",
+ "HT": "Haiti",
+ "HM": "Heard and McDonald Islands",
+ "HN": "Honduras",
+ "HK": "Hong Kong",
+ "HU": "Hungary",
+ "IS": "Iceland",
+ "IN": "India",
+ "ID": "Indonesia",
+ "IR": "Iran",
+ "IQ": "Iraq",
+ "IE": "Ireland",
+ "IM": "Isle of Man",
+ "IL": "Israel",
+ "IT": "Italy",
+ "JM": "Jamaica",
+ "JP": "Japan",
+ "JE": "Jersey",
+ "JO": "Jordan",
+ "KZ": "Kazakhstan",
+ "KE": "Kenya",
+ "KI": "Kiribati",
+ "KW": "Kuwait",
+ "KG": "Kyrgyzstan",
+ "LA": "Lao People's Democratic Republic",
+ "LV": "Latvia",
+ "LB": "Lebanon",
+ "LS": "Lesotho",
+ "LR": "Liberia",
+ "LY": "Libya",
+ "LI": "Liechtenstein",
+ "LT": "Lithuania",
+ "LU": "Luxembourg",
+ "MO": "Macau",
+ "MK": "Macedonia",
+ "MG": "Madagascar",
+ "MW": "Malawi",
+ "MY": "Malaysia",
+ "MV": "Maldives",
+ "ML": "Mali",
+ "MT": "Malta",
+ "MH": "Marshall Islands",
+ "MQ": "Martinique",
+ "MR": "Mauritania",
+ "MU": "Mauritius",
+ "YT": "Mayotte",
+ "MX": "Mexico",
+ "FM": "Micronesia, Federated States of",
+ "MD": "Moldova",
+ "MC": "Monaco",
+ "MN": "Mongolia",
+ "ME": "Montenegro",
+ "MS": "Montserrat",
+ "MA": "Morocco",
+ "MZ": "Mozambique",
+ "MM": "Myanmar",
+ "NA": "Namibia",
+ "NR": "Nauru",
+ "NP": "Nepal",
+ "NC": "New Caledonia",
+ "NZ": "New Zealand",
+ "NI": "Nicaragua",
+ "NE": "Niger",
+ "NG": "Nigeria",
+ "NU": "Niue",
+ "NF": "Norfolk Island",
+ "MP": "Northern Mariana Islands",
+ "KP": "North Korea",
+ "NO": "Norway",
+ "OM": "Oman",
+ "PK": "Pakistan",
+ "PW": "Palau",
+ "PS": "Palestine, State of",
+ "PA": "Panama",
+ "PG": "Papua New Guinea",
+ "PY": "Paraguay",
+ "PE": "Peru",
+ "PH": "Philippines",
+ "PN": "Pitcairn",
+ "PL": "Poland",
+ "PT": "Portugal",
+ "PR": "Puerto Rico",
+ "QA": "Qatar",
+ "RE": "R\u00e9union",
+ "RO": "Romania",
+ "RU": "Russian Federation",
+ "RW": "Rwanda",
+ "BL": "Saint Barth\u00e9lemy",
+ "SH": "Saint Helena",
+ "KN": "Saint Kitts and Nevis",
+ "LC": "Saint Lucia",
+ "MF": "Saint-Martin (France)",
+ "VC": "Saint Vincent and the Grenadines",
+ "WS": "Samoa",
+ "SM": "San Marino",
+ "ST": "Sao Tome and Principe",
+ "SA": "Saudi Arabia",
+ "SN": "Senegal",
+ "RS": "Serbia",
+ "SC": "Seychelles",
+ "SL": "Sierra Leone",
+ "SG": "Singapore",
+ "SX": "Sint Maarten (Dutch part)",
+ "SK": "Slovakia",
+ "SI": "Slovenia",
+ "SB": "Solomon Islands",
+ "SO": "Somalia",
+ "ZA": "South Africa",
+ "GS": "South Georgia and the South Sandwich Islands",
+ "KR": "South Korea",
+ "SS": "South Sudan",
+ "ES": "Spain",
+ "LK": "Sri Lanka",
+ "PM": "St. Pierre and Miquelon",
+ "SD": "Sudan",
+ "SR": "Suriname",
+ "SJ": "Svalbard and Jan Mayen Islands",
+ "SZ": "Swaziland",
+ "SE": "Sweden",
+ "CH": "Switzerland",
+ "SY": "Syria",
+ "TW": "Taiwan",
+ "TJ": "Tajikistan",
+ "TZ": "Tanzania",
+ "TH": "Thailand",
+ "NL": "The Netherlands",
+ "TL": "Timor-Leste",
+ "TG": "Togo",
+ "TK": "Tokelau",
+ "TO": "Tonga",
+ "TT": "Trinidad and Tobago",
+ "TN": "Tunisia",
+ "TR": "Turkey",
+ "TM": "Turkmenistan",
+ "TC": "Turks and Caicos Islands",
+ "TV": "Tuvalu",
+ "UG": "Uganda",
+ "UA": "Ukraine",
+ "AE": "United Arab Emirates",
+ "GB": "United Kingdom",
+ "US": "United States",
+ "UM": "United States Minor Outlying Islands",
+ "UY": "Uruguay",
+ "UZ": "Uzbekistan",
+ "VU": "Vanuatu",
+ "VA": "Vatican",
+ "VE": "Venezuela",
+ "VN": "Vietnam",
+ "VG": "Virgin Islands (British)",
+ "VI": "Virgin Islands (U.S.)",
+ "WF": "Wallis and Futuna Islands",
+ "EH": "Western Sahara",
+ "YE": "Yemen",
+ "ZM": "Zambia",
+ "ZW": "Zimbabwe"
+}
\ No newline at end of file
diff --git a/src/include/lib/KD2/data/countries.fr.json b/src/include/lib/KD2/data/countries.fr.json
new file mode 100644
index 0000000..6b14072
--- /dev/null
+++ b/src/include/lib/KD2/data/countries.fr.json
@@ -0,0 +1,251 @@
+{
+ "AF": "Afghanistan",
+ "ZA": "Afrique du Sud",
+ "AL": "Albanie",
+ "DZ": "Alg\u00e9rie",
+ "DE": "Allemagne",
+ "AD": "Andorre",
+ "AO": "Angola",
+ "AI": "Anguilla",
+ "AQ": "Antarctique",
+ "AG": "Antigua-et-Barbuda",
+ "SA": "Arabie saoudite",
+ "AR": "Argentine",
+ "AM": "Arm\u00e9nie",
+ "AW": "Aruba",
+ "AU": "Australie",
+ "AT": "Autriche",
+ "AZ": "Azerba\u00efdjan",
+ "BS": "Bahamas",
+ "BH": "Bahre\u00efn",
+ "BD": "Bangladesh",
+ "BB": "Barbade",
+ "BY": "B\u00e9larus",
+ "BE": "Belgique",
+ "BZ": "Belize",
+ "BJ": "B\u00e9nin",
+ "BM": "Bermudes",
+ "BT": "Bhoutan",
+ "BO": "Bolivie",
+ "BA": "Bosnie-Herz\u00e9govine",
+ "BW": "Botswana",
+ "BR": "Br\u00e9sil",
+ "BN": "Brunei Darussalam",
+ "BG": "Bulgarie",
+ "BF": "Burkina Faso",
+ "BI": "Burundi",
+ "KH": "Cambodge",
+ "CM": "Cameroun",
+ "CA": "Canada",
+ "CV": "Cap-Vert",
+ "CL": "Chili",
+ "CN": "Chine",
+ "CY": "Chypre",
+ "CO": "Colombie",
+ "KM": "Comores",
+ "CG": "Congo",
+ "CD": "Congo, R\u00e9publique d\u00e9mocratique du",
+ "KP": "Cor\u00e9e du Nord",
+ "KR": "Cor\u00e9e du Sud",
+ "CR": "Costa Rica",
+ "CI": "C\u00f4te d'Ivoire",
+ "HR": "Croatie",
+ "CU": "Cuba",
+ "CW": "Cura\u00e7ao",
+ "DK": "Danemark",
+ "DJ": "Djibouti",
+ "DM": "Dominique",
+ "EG": "\u00c9gypte",
+ "SV": "El Salvador",
+ "AE": "\u00c9mirats arabes unis",
+ "EC": "\u00c9quateur",
+ "ER": "\u00c9rythr\u00e9e",
+ "ES": "Espagne",
+ "EE": "Estonie",
+ "US": "\u00c9tats-Unis",
+ "ET": "\u00c9thiopie",
+ "FJ": "Fidji",
+ "FI": "Finlande",
+ "FR": "France",
+ "GA": "Gabon",
+ "GM": "Gambie",
+ "GE": "G\u00e9orgie",
+ "GS": "G\u00e9orgie du Sud et les \u00eeles Sandwich du Sud",
+ "GH": "Ghana",
+ "GI": "Gibraltar",
+ "GR": "Gr\u00e8ce",
+ "GD": "Grenade",
+ "GL": "Groenland",
+ "GP": "Guadeloupe",
+ "GU": "Guam",
+ "GT": "Guatemala",
+ "GG": "Guernesey",
+ "GN": "Guin\u00e9e",
+ "GW": "Guin\u00e9e-Bissau",
+ "GQ": "Guin\u00e9e \u00e9quatoriale",
+ "GY": "Guyana",
+ "GF": "Guyane fran\u00e7aise",
+ "HT": "Ha\u00efti",
+ "HN": "Honduras",
+ "HK": "Hong Kong",
+ "HU": "Hongrie",
+ "BV": "\u00cele Bouvet",
+ "CX": "\u00cele Christmas",
+ "IM": "\u00cele de Man",
+ "NF": "\u00cele Norfolk",
+ "KY": "\u00celes Ca\u00efmans",
+ "CC": "\u00celes Cocos (Keeling)",
+ "CK": "\u00celes Cook",
+ "AX": "\u00celes d'\u00c5land",
+ "FO": "\u00celes F\u00e9ro\u00e9",
+ "HM": "\u00celes Heard et McDonald",
+ "FK": "\u00celes Malouines",
+ "MH": "\u00celes Marshall",
+ "UM": "\u00celes mineures \u00e9loign\u00e9es des \u00c9tats-Unis",
+ "SB": "\u00celes Salomon",
+ "TC": "\u00celes Turks et Caicos",
+ "VI": "\u00celes Vierges am\u00e9ricaines",
+ "VG": "\u00celes Vierges britanniques",
+ "WF": "\u00celes Wallis-et-Futuna",
+ "IN": "Inde",
+ "ID": "Indon\u00e9sie",
+ "IQ": "Irak",
+ "IR": "Iran",
+ "IE": "Irlande",
+ "IS": "Islande",
+ "IL": "Isra\u00ebl",
+ "IT": "Italie",
+ "JM": "Jama\u00efque",
+ "JP": "Japon",
+ "JE": "Jersey",
+ "JO": "Jordanie",
+ "KZ": "Kazakhstan",
+ "KE": "Kenya",
+ "KG": "Kirghizistan",
+ "KI": "Kiribati",
+ "KW": "Kowe\u00eft",
+ "LA": "Laos",
+ "LS": "Lesotho",
+ "LV": "Lettonie",
+ "LB": "Liban",
+ "LR": "Lib\u00e9ria",
+ "LY": "Libye",
+ "LI": "Liechtenstein",
+ "LT": "Lituanie",
+ "LU": "Luxembourg",
+ "MO": "Macao",
+ "MK": "Mac\u00e9doine",
+ "MG": "Madagascar",
+ "MY": "Malaisie",
+ "MW": "Malawi",
+ "MV": "Maldives",
+ "ML": "Mali",
+ "MT": "Malte",
+ "MP": "Mariannes du Nord",
+ "MA": "Maroc",
+ "MQ": "Martinique",
+ "MU": "Maurice",
+ "MR": "Mauritanie",
+ "YT": "Mayotte",
+ "MX": "Mexique",
+ "FM": "Micron\u00e9sie, \u00c9tats f\u00e9d\u00e9r\u00e9s de",
+ "MD": "Moldavie",
+ "MC": "Monaco",
+ "MN": "Mongolie",
+ "ME": "Mont\u00e9n\u00e9gro",
+ "MS": "Montserrat",
+ "MZ": "Mozambique",
+ "MM": "Myanmar",
+ "NA": "Namibie",
+ "NR": "Nauru",
+ "NP": "N\u00e9pal",
+ "NI": "Nicaragua",
+ "NE": "Niger",
+ "NG": "Nigeria",
+ "NU": "Niue",
+ "NO": "Norv\u00e8ge",
+ "NC": "Nouvelle-Cal\u00e9donie",
+ "NZ": "Nouvelle-Z\u00e9lande",
+ "OM": "Oman",
+ "UG": "Ouganda",
+ "UZ": "Ouzb\u00e9kistan",
+ "PK": "Pakistan",
+ "PW": "Palau",
+ "PS": "Palestine",
+ "PA": "Panama",
+ "PG": "Papouasie-Nouvelle-Guin\u00e9e",
+ "PY": "Paraguay",
+ "NL": "Pays-Bas",
+ "BQ": "Pays-Bas carib\u00e9ens",
+ "PE": "P\u00e9rou",
+ "PH": "Philippines",
+ "PN": "Pitcairn",
+ "PL": "Pologne",
+ "PF": "Polyn\u00e9sie fran\u00e7aise",
+ "PT": "Portugal",
+ "PR": "Puerto Rico",
+ "QA": "Qatar",
+ "CF": "R\u00e9publique centrafricaine",
+ "DO": "R\u00e9publique dominicaine",
+ "CZ": "R\u00e9publique tch\u00e8que",
+ "RE": "R\u00e9union",
+ "RO": "Roumanie",
+ "GB": "Royaume-Uni",
+ "RU": "Russie",
+ "RW": "Rwanda",
+ "EH": "Sahara Occidental",
+ "BL": "Saint-Barth\u00e9lemy",
+ "SH": "Sainte-H\u00e9l\u00e8ne",
+ "LC": "Sainte-Lucie",
+ "KN": "Saint-Kitts-et-Nevis",
+ "SM": "Saint-Marin",
+ "MF": "Saint-Martin (France)",
+ "SX": "Saint-Martin (Pays-Bas)",
+ "PM": "Saint-Pierre-et-Miquelon",
+ "VC": "Saint-Vincent-et-les-Grenadines",
+ "WS": "Samoa",
+ "AS": "Samoa am\u00e9ricaine",
+ "ST": "Sao Tom\u00e9-et-Principe",
+ "SN": "S\u00e9n\u00e9gal",
+ "RS": "Serbie",
+ "SC": "Seychelles",
+ "SL": "Sierra Leone",
+ "SG": "Singapour",
+ "SK": "Slovaquie",
+ "SI": "Slov\u00e9nie",
+ "SO": "Somalie",
+ "SD": "Soudan",
+ "SS": "Soudan du Sud",
+ "LK": "Sri Lanka",
+ "SE": "Su\u00e8de",
+ "CH": "Suisse",
+ "SR": "Suriname",
+ "SJ": "Svalbard et \u00eele de Jan Mayen",
+ "SZ": "Swaziland",
+ "SY": "Syrie",
+ "TJ": "Tadjikistan",
+ "TW": "Ta\u00efwan",
+ "TZ": "Tanzanie",
+ "TD": "Tchad",
+ "TF": "Terres australes fran\u00e7aises",
+ "IO": "Territoire britannique de l'oc\u00e9an Indien",
+ "TH": "Tha\u00eflande",
+ "TL": "Timor-Leste",
+ "TG": "Togo",
+ "TK": "Tokelau",
+ "TO": "Tonga",
+ "TT": "Trinit\u00e9-et-Tobago",
+ "TN": "Tunisie",
+ "TM": "Turkm\u00e9nistan",
+ "TR": "Turquie",
+ "TV": "Tuvalu",
+ "UA": "Ukraine",
+ "UY": "Uruguay",
+ "VU": "Vanuatu",
+ "VA": "Vatican",
+ "VE": "Venezuela",
+ "VN": "Vietnam",
+ "YE": "Y\u00e9men",
+ "ZM": "Zambie",
+ "ZW": "Zimbabwe"
+}
\ No newline at end of file
diff --git a/src/include/lib/KD2/data/dictionary.fr.txt b/src/include/lib/KD2/data/dictionary.fr.txt
new file mode 100644
index 0000000..2b527b2
--- /dev/null
+++ b/src/include/lib/KD2/data/dictionary.fr.txt
@@ -0,0 +1,3732 @@
+paysage
+lui-même
+erreur
+pénitence
+brûlant
+canon
+prudent
+débarrasser
+séparer
+retomber
+croix
+sauter
+nid
+grand-maman
+satisfait
+défense
+compter
+déboucher
+peintre
+tentation
+devant
+envie
+pleuvoir
+fièvre
+organiser
+persévérer
+ivre
+quart
+pourvu que
+tilleul
+barrière
+police
+pareil
+invention
+fer
+guerre
+cordialement
+campagnard
+infirmier
+crépuscule
+manier
+animal
+transporter
+mobile
+perfection
+marque
+joncher
+nouveau
+désespoir
+joindre
+cordonnier
+ruelle
+risquer
+janvier
+ménage
+couronne
+fusil
+loi
+page
+clou
+armée
+flacon
+blanc
+adresser
+maître
+caverne
+rentrée
+constant
+montre
+surface
+ruban
+cause
+division
+note
+luisant
+poche
+poursuite
+gazon
+ingratitude
+pré
+rose
+activité
+joyeux
+fâcher
+préoccuper
+obligeance
+rivière
+vache
+dessin
+but
+fond
+deux
+ceinture
+lorsque
+cantique
+tâcher
+expirer
+aviser
+gibecière
+lourd
+espiègle
+sommet
+carte
+vendeur
+opérer
+disparaître
+rire
+agréer
+visite
+précédent
+simplement
+dorer
+sagesse
+revivre
+manteau
+croûte
+intelligence
+lion
+chêne
+horreur
+grandeur
+saluer
+mauvais
+sentier
+estomac
+modeste
+nullement
+hâter
+millier
+saigner
+curé
+moineau
+fuite
+flaque
+crêpe
+gant
+s'emparer
+gare
+gentil
+sacrifice
+modérer
+brutal
+feuille
+pouvoir
+dossier
+clef
+terrain
+quarante
+actif
+fou
+réconforter
+gendarme
+ignorant
+grotte
+habiller
+stationner
+implorer
+chapitre
+soupir
+chaise
+rarement
+application
+s'écrier
+ressource
+ministre
+doyen
+jeu
+graisse
+chantre
+écho
+barre
+clouer
+bourgeon
+équipage
+commun
+obliger
+berceau
+outil
+coquille
+procureur
+germer
+séminaire
+partout
+agréablement
+énorme
+radieux
+retrousser
+sorte
+billet
+mademoiselle
+condamner
+aviateur
+tourbillonner
+boucher
+fumer
+décembre
+rang
+étouffer
+sens
+parcours
+tablier
+pleur
+bluet
+cesse
+papier
+bonté
+boucle
+avantageux
+tranche
+coller
+jeune
+drapeau
+gaieté
+chemin
+deviner
+auparavant
+orée
+passage
+souffle
+agir
+s'envoler
+apparition
+hors
+président
+deuil
+berger
+merveilleux
+clin d'oeil
+priver
+oser
+mariage
+forêt
+règne
+commode
+finir
+bosquet
+mouche
+tailleur
+essuyer
+téléphone
+couronner
+château
+tourbillon
+citoyen
+neveu
+paisible
+messager
+ci-joint
+ignorer
+sûrement
+tache
+dompteur
+préférence
+maison
+revêtir
+vicaire
+vendredi
+frapper
+chef
+bâiller
+seigneur
+appuyer
+livre
+complètement
+motif
+adjectif numéral
+train
+ruisselet
+arrêter
+danse
+blottir
+raisin
+raconter
+dahlia
+épauler
+réel
+morceau
+étudier
+amical
+mouchoir
+libre
+printanier
+voiture
+journal
+musée
+village
+charge
+pâquerette
+mari
+ombrage
+attention
+choc
+rapprocher
+prévoir
+bienveillance
+suprême
+sirène
+ceci
+giroflée
+gouverner
+fraîcheur
+circuler
+mélancolie
+exactement
+désireux
+commandement
+annoncer
+cueillette
+duc
+désobéissant
+chauffeur
+dessert
+murmurer
+exclamation
+formidable
+tant
+acclamer
+son
+désaltérer
+soeur
+fourrer
+industrie
+embarquer
+bienfaiteur
+mine
+joyeusement
+fidèle
+grès
+dictionnaire
+nuage
+miroir
+conscience
+équilibre
+tarder
+verdoyant
+parvenir
+boue
+refuser
+manuel
+grand
+réchauffer
+succéder
+bloc
+front
+père
+employé
+moissonneur
+là
+majesté
+réclamer
+auquel
+général
+obscurcir
+commerce
+singulier
+consolation
+passager
+mais
+événement
+administrer
+coucher
+rapidité
+respirer
+quai
+empereur
+farine
+préserver
+glisser
+ver
+sourd
+notaire
+citer
+débris
+habile
+qualité
+inerte
+football
+abri
+miel
+franchement
+essai
+production
+errer
+ah
+idéal
+agile
+tarte
+léger
+raisonnable
+projeter
+poésie
+massif
+pommier
+ensoleillé
+couvent
+sauver
+invitation
+métier
+poussière
+école
+fuir
+pleurer
+imaginer
+sautiller
+édifier
+multitude
+commerçant
+plat
+autel
+toujours
+craindre
+tuer
+cuisinière
+ingrat
+percher
+concert
+doubler
+trésor
+cimetière
+exercice
+savon
+éblouir
+éclat
+parc
+peau
+compléter
+escalier
+sous
+matinée
+recherche
+régner
+épreuve
+autrefois
+fauteuil
+apprendre
+pétale
+sujet
+lequel
+bien-être
+chauffage
+nerveux
+décision
+participer
+gronder
+aussi
+tombe
+éternité
+rejeter
+enveloppe
+habitude
+peut-être
+accabler
+confrère
+prêtre
+parfaitement
+soigneusement
+compliment
+d'après
+chéri
+oiseau
+coureur
+mentir
+faucheur
+héros
+arrêt
+graver
+intrigué
+plaine
+combler
+planche
+recouvrir
+imprudence
+brebis
+poule
+spectacle
+semblable
+anticiper
+mener
+hommage
+déshabiller
+moins
+volume
+poulet
+main
+imposant
+fendre
+paille
+résistance
+écrivain
+franchise
+casquette
+mien
+heure
+an
+oranger
+coucou
+plomb
+télégramme
+amende
+part
+combat
+applaudir
+rédaction
+exécution
+largement
+unique
+jardinier
+belge
+réserver
+colère
+lueur
+moyen
+coiffer
+guérir
+échec
+minute
+conseiller
+fontaine
+scier
+fabrique
+oisillon
+amicalement
+oreille
+laisser
+gosse
+tard
+autoriser
+emmener
+anxiété
+patron
+abbé
+désobéissance
+bazar
+destinée
+plume
+terrasse
+potager
+dessus
+régulièrement
+distinguer
+roseau
+malade
+éteindre
+pendule
+imperméable
+fougère
+pelage
+écriture
+journalier
+comprendre
+manoeuvre
+soudain
+dessous
+parmi
+aire
+bosselé
+goutte
+bateau
+veiller
+économiser
+intelligent
+ferveur
+avertir
+repousser
+ville
+convenable
+farce
+cultiver
+provenir
+acharner
+pic
+avril
+frontière
+patte
+merci
+prince
+précieux
+espoir
+allumette
+poumon
+bleu
+inutile
+proposer
+être
+attester
+couvert
+trou
+démontrer
+effroyable
+localité
+reverdir
+trop
+user
+ours
+locomotive
+ailleurs
+fournir
+piste
+béret
+profondément
+respecter
+lecture
+congrès
+ennuyer
+lendemain
+indigne
+eux
+détail
+compagne
+vérité
+capital
+bavarder
+grue
+sueur
+urgent
+habituel
+fragile
+ôter
+briller
+border
+souper
+marteau
+agent
+moisson
+mou
+repentir
+disperser
+commande
+situation
+atteindre
+musique
+voyage
+remettre
+écharpe
+fruit
+éclaircir
+particulier
+ardent
+foi
+bercer
+papa
+marbre
+guérison
+alcoolique
+apaiser
+marguerite
+hôpital
+séance
+bousculer
+aube
+cordial
+quelque
+chaque
+difficilement
+union
+inquiétude
+filleul
+ami
+explication
+toux
+limpide
+jeter
+réellement
+inquiéter
+activer
+capable
+résister
+reconnaissance
+troupe
+aussitôt
+niveau
+jeunesse
+meunier
+signature
+consulter
+bout
+périr
+amateur
+livrer
+rapide
+engloutir
+usage
+nombreux
+sagement
+tapis
+grossier
+bourgmestre
+demoiselle
+prodiguer
+limite
+clochette
+fortune
+échanger
+inquiet
+parapluie
+quelconque
+propos
+enquête
+réduire
+chance
+énergique
+source
+dent
+voilà
+féroce
+tasse
+envers
+capitaine
+contrée
+obstacle
+île
+plier
+moderne
+allemand
+muet
+introduction
+annuel
+habitation
+jambe
+mouton
+résoudre
+ferrer
+éclater
+sein
+naufrage
+galerie
+foule
+soulier
+voûte
+toutefois
+fourrure
+marquis
+punir
+féliciter
+discussion
+défiler
+dédaigner
+tailler
+tomber
+clé
+côté
+broder
+encore
+jambon
+poulailler
+habiter
+chauffer
+normal
+aventurer
+professeur
+approuver
+peur
+automobile
+conduire
+ardeur
+vôtre
+centime
+emporter
+fois
+moquer
+éducation
+fourmillière
+pieux
+copier
+étinceler
+regagner
+trait
+esclave
+géant
+attrait
+allonger
+rude
+santé
+bébé
+décharger
+obscur
+commercial
+battre
+partir
+parure
+mesure
+encombrer
+reflet
+reproche
+recueillir
+cathédrale
+illustrer
+chiffre
+timbre
+courir
+climat
+genre
+daigner
+tente
+gauche
+imagination
+chérir
+guichet
+bulletin
+classique
+poursuivre
+renseigner
+boisson
+ranimer
+sabot
+chute
+brillant
+volaille
+grandiose
+orgueil
+brun
+plaintif
+moqueur
+bruit
+jadis
+achat
+baigner
+accourir
+inscrire
+plancher
+religieux
+joue
+avec
+accident
+figurer
+surtout
+buis
+courageusement
+moine
+employer
+baguette
+foudre
+signaler
+succès
+parfumer
+découvrir
+bête
+sien
+corniche
+renoncer
+relever
+mérite
+résonner
+type
+tournée
+préparation
+sillon
+étable
+relation
+chasseur
+cas
+description
+porc
+pourrir
+blesser
+favori
+puisque
+laid
+calculer
+veine
+chameau
+indifférent
+dégager
+crucifix
+faine
+renouveler
+principe
+crayon
+revenir
+frémir
+élégant
+secret
+parce que
+opinion
+mélanger
+colonel
+utiliser
+soin
+cinquante
+sonore
+écouler
+tel
+colline
+seulement
+futur
+expédier
+tuile
+chapeau
+mouiller
+fureur
+grâce
+consentement
+fervent
+paletot
+vêtir
+manger
+étude
+ménager
+proie
+héroïque
+pli
+canal
+lin
+effectuer
+condoléances
+savoir
+instructif
+azuré
+cristal
+plus
+chemise
+jouer
+immobile
+achever
+quatrième
+serviteur
+spécial
+science
+rechercher
+alentours
+mont
+soyeux
+cruel
+service
+jugement
+négligence
+oie
+mortel
+voyager
+fourneau
+gâteau
+début
+ciseaux
+carrefour
+mobilier
+gambader
+arroser
+imiter
+redevenir
+rapporter
+gaz
+inviter
+fort
+hardi
+passé
+riche
+oeuvre
+souterrain
+créature
+tendre
+souvent
+ralentir
+agrément
+entasser
+faute
+scintiller
+peiner
+froid
+traiter
+gorge
+remarquable
+veston
+princesse
+peinture
+basse
+provision
+poteau
+guider
+plusieurs
+série
+mûr
+affectueux
+refuge
+salaire
+innocent
+véhicule
+style
+consacrer
+gardien
+retrouver
+coupable
+charmer
+créateur
+renard
+panorama
+couteau
+rêver
+confectionner
+appartement
+traîner
+astre
+appétit
+invisible
+détester
+raide
+réponse
+ceux
+creuser
+carré
+aujourd'hui
+ficelle
+trottoir
+moulin
+glacer
+vêtement
+hâte
+appeler
+mission
+renouvellement
+dernier
+missionnaire
+fille
+gratitude
+juillet
+vieil
+déchaîner
+commettre
+attraper
+parsemer
+quantité
+gazouiller
+blancheur
+foie
+satin
+serein
+salut
+connaître
+novembre
+avenue
+curiosité
+porte-plume
+merveille
+chaland
+article
+geler
+mansarde
+alcool
+monstre
+chanter
+océan
+ressort
+sifflet
+renoncule
+entrée
+balle
+monter
+effort
+introduire
+juin
+vaillant
+valoir
+voisin
+eh
+dresser
+dortoir
+sobre
+médecin
+répéter
+problème
+négliger
+bicyclette
+couche
+facile
+visiteur
+aise
+assiette
+aurore
+sincère
+magnifique
+estimer
+durée
+accorder
+reculer
+café
+fameux
+serviette
+ainsi
+voix
+protéger
+circulation
+agréable
+communier
+chrysanthème
+animation
+végétation
+justement
+cochon
+courant
+légume
+nation
+table
+jardin
+extérieur
+attentif
+important
+noeud
+décrire
+vivant
+merle
+vermeil
+forger
+comparer
+redoubler
+forcer
+incliner
+bien-aimé
+préau
+ériger
+gras
+dentelle
+rayon
+régime
+foncer
+avant
+célébrer
+générosité
+perle
+envoi
+sonnette
+suffire
+crever
+réfectoire
+indication
+paternel
+ronronner
+péril
+second
+grappe
+courageux
+lierre
+cuire
+cousin
+souhaiter
+trembler
+pas
+patronage
+mur
+mâchoire
+paresse
+garde
+ronce
+courber
+furieux
+dedans
+brouillard
+expression
+promener
+intention
+bille
+perte
+déception
+situer
+bonne
+désormais
+azur
+roulotte
+public
+gerbe
+crème
+isoler
+séjour
+repas
+donc
+madame
+vernir
+corridor
+périlleux
+fauvette
+encourir
+position
+fin
+vide
+jusque
+agrémenter
+autorité
+pauvre
+plage
+enfouir
+marché
+vraiment
+signifier
+portière
+griffe
+minuscule
+plonger
+messe
+impossibilité
+ouvrage
+paix
+ambulance
+petit
+vacances
+corriger
+engager
+humble
+longuement
+pratiquer
+jaunir
+salutation
+dérober
+bouche
+nécessaire
+causer
+dessein
+rouler
+huit
+villa
+façonner
+malgré
+sang
+alors
+silencieusement
+nettoyer
+dévouement
+conseil
+tunnel
+parent
+fauve
+cabane
+distraction
+opération
+fruitier
+poupée
+crier
+tapage
+soir
+sot
+sentiment
+prolonger
+grimper
+pur
+produit
+satisfaction
+rustique
+chambre
+excuse
+rouge
+protection
+désolation
+étendre
+crise
+pourtant
+communiant
+enseignement
+ample
+physique
+négligent
+lanterne
+recommencer
+conserver
+chaux
+menteur
+suffisant
+vivre
+abondant
+étagère
+douter
+glace
+liberté
+docile
+pensionnat
+debout
+abattre
+clairon
+noircir
+victoire
+épuiser
+tiroir
+chevalier
+considérer
+cuiller
+réunion
+reste
+depuis
+cortège
+heureusement
+panache
+poignée
+brin
+peser
+lettre
+naissance
+réunir
+lâcher
+venir
+rassurer
+porte
+resplendir
+taper
+associer
+doute
+promettre
+sourire
+surmonter
+bienveillant
+chaleur
+nièce
+flatter
+régaler
+moelleux
+surprise
+écurie
+danger
+distinction
+offrir
+travail
+exposer
+colis
+flot
+quotidien
+permettre
+ennemi
+invoquer
+voler
+artiste
+attrister
+linge
+tombeau
+répondre
+donner
+transmettre
+août
+seul
+planter
+acquitter
+lire
+horizon
+splendeur
+amour
+kilomètre
+jardinage
+ornement
+charme
+vers
+mineur
+tempête
+circonstance
+maint
+caresse
+oeillet
+dimension
+gâter
+immaculé
+veille
+baisser
+peuplier
+rouleau
+lilas
+lumineux
+laborieux
+gland
+écrire
+flocon
+parole
+cuillère
+favoriser
+composition
+gravure
+nature
+tantôt
+dominer
+boire
+fouet
+spécialement
+coquelicot
+fourniture
+lampe
+grange
+pétrir
+port
+retraite
+douloureux
+amusement
+bibelot
+enlever
+clément
+sabre
+lutter
+pluie
+musicien
+pie
+phrase
+aventure
+perspective
+solide
+vertu
+refaire
+apostolique
+orgue
+quinze
+réciter
+bordure
+débarquer
+cycliste
+randonnée
+renvoyer
+épargner
+volonté
+savoureux
+tous
+essayer
+aliment
+haleine
+côte
+humide
+obtenir
+complet
+onduler
+ressentir
+crime
+pigeon
+vapeur
+chaume
+cirque
+chiffon
+ferraille
+ramasser
+s'éloigner
+cent
+manche
+planer
+affreux
+jurer
+trimestre
+triomphe
+pénible
+grelotter
+rigole
+commander
+épine
+suivre
+gourmand
+confier
+hurler
+joujou
+dîner
+dévorer
+plafond
+parler
+enfermer
+avion
+bienheureux
+titre
+lien
+peine
+aucun
+riant
+dont
+admirable
+triompher
+conférence
+tiède
+mourir
+diriger
+terminer
+prononcer
+acier
+splendide
+image
+histoire
+céleste
+brigand
+résultat
+dos
+foin
+rayonner
+venger
+ensuite
+patience
+ébats
+allégresse
+rideau
+malin
+flatteur
+luire
+robuste
+ardoise
+sacoche
+menton
+bassin
+mai
+doux
+date
+bonbon
+dévouer
+enflammer
+goût
+instituteur
+habit
+lier
+sympathie
+naître
+marraine
+animer
+pourquoi
+chausser
+communication
+mouvement
+sève
+aubépine
+vif
+clos
+bal
+hasard
+trouble
+ménagère
+scolaire
+marche
+prouver
+comble
+couver
+théâtre
+affliger
+écouter
+contrarier
+lieu
+étoile
+frotter
+four
+caprice
+fixe
+puissant
+ménagerie
+croître
+culotte
+tort
+pelouse
+maudire
+confus
+occuper
+tournant
+angoisse
+fièrement
+exécuter
+commission
+servir
+poil
+catastrophe
+selon
+bord
+chose
+vaisselle
+rage
+louer
+étirer
+énormément
+entre
+juge
+poutre
+blanchir
+fréquent
+las
+mériter
+inconvénient
+fréquenter
+bourgeonner
+demi
+voie
+centre
+chevelure
+raccourcir
+affiche
+découper
+succulent
+impression
+architecte
+blouse
+procurer
+faner
+utilité
+vigne
+bêche
+vain
+reprise
+chair
+secouer
+anxieux
+recommander
+onze
+velours
+moindre
+sillonner
+cité
+évangile
+duvet
+quatre
+farouche
+noyer
+si
+caillou
+manoeuvrer
+négociant
+chou
+royaume
+attirer
+missel
+rougir
+limiter
+flanc
+dommage
+dehors
+difficile
+litière
+intime
+penser
+huile
+savant
+souhait
+piété
+calmer
+faiblesse
+bénir
+préparatif
+pinson
+goûter
+membre
+écarter
+défendre
+wagon
+gamin
+pain
+insigne
+valeur
+recommandation
+butiner
+hanneton
+redoutable
+consoler
+appartenir
+soupe
+mettre
+hausser
+chocolat
+aligner
+gracieux
+haillon
+niche
+bourdonner
+peindre
+briser
+quoi
+attaquer
+nuit
+paraître
+lisière
+cesser
+recevoir
+sport
+maire
+marin
+violence
+usine
+survenir
+meule
+proprement
+saint
+possession
+cinq
+dessiner
+quant
+falloir
+politesse
+trente
+fier
+épaule
+embaumer
+dès
+abaisser
+pécher
+noir
+accrocher
+tas
+vie
+interdire
+progrès
+sacrement
+rusé
+orgueilleux
+courrier
+déjà
+au-dessus
+guetter
+pâte
+vagabond
+plante
+rôle
+avoine
+voleur
+craquement
+taquiner
+affectueusement
+portée
+grain
+porteur
+buisson
+toile
+tressaillir
+intellectuel
+mordre
+bière
+dette
+apprêter
+camion
+net
+bravo
+groupe
+espace
+aimable
+épée
+dur
+menuisier
+précaution
+volée
+orphelin
+accepter
+estrade
+bambin
+célèbre
+bétail
+déborder
+univers
+tremper
+oeuf
+toit
+bec
+coiffure
+hauteur
+filer
+bouleverser
+réserve
+talus
+expédition
+fatiguer
+annonce
+acte
+vendre
+samedi
+appliquer
+conviction
+ranger
+pan
+camarade
+terrestre
+quelquefois
+poli
+voiler
+dépendre
+directeur
+unir
+suspendre
+paradis
+dormir
+décorer
+cadet
+plan
+saut
+national
+gémir
+gloire
+terme
+indiquer
+mélodie
+exactitude
+tacher
+douzaine
+répartir
+propice
+distance
+région
+mendier
+parterre
+flèche
+idée
+bref
+confondre
+couler
+verre
+oncle
+étalage
+familier
+fumée
+désirer
+boutique
+boîte
+industriel
+corde
+veuf
+refléter
+gaiement
+arrondissement
+refermer
+lèvre
+banque
+tableau
+s'écrouler
+instant
+pardon
+turbulent
+somme
+chrétien
+rompre
+vol
+concours
+enfin
+renaître
+loup
+envelopper
+commune
+bondir
+barbe
+paître
+outre
+corbeille
+exposition
+fleurir
+pension
+pays
+brusquement
+âne
+vue
+soulever
+recourir
+coussin
+avantage
+balancer
+cigarette
+nouvelle
+charité
+pitié
+suffisamment
+secourir
+cela
+long
+longer
+trouver
+doucement
+passant
+demander
+réalité
+demeure
+queue
+procession
+fondre
+aisément
+bonheur
+respect
+changement
+aiguille
+vaste
+centaine
+transformer
+prospérité
+sacrifier
+prochain
+geste
+lointain
+flamand
+tenter
+commencement
+là-bas
+diamant
+prier
+propriété
+hirondelle
+nécéssité
+continuellement
+fatigue
+rive
+travailleur
+kermesse
+quelqu'un
+solitude
+sursauter
+salir
+évidemment
+vieillard
+cadeau
+office
+acquérir
+péniblement
+environner
+grille
+grammaire
+végétal
+pipe
+fête
+semaine
+profondeur
+délicat
+détacher
+retour
+souffrir
+supporter
+gouvernement
+barque
+lambeau
+seuil
+étranger
+froisser
+tourment
+d'abord
+personnel
+prudence
+remède
+intéresser
+étudiant
+manque
+jacinthe
+villageois
+renfermer
+égarer
+herbe
+poire
+armoire
+présent
+prétendre
+joli
+signer
+plaindre
+offre
+sucer
+ressembler
+maladie
+tandis que
+caresser
+couleur
+électricité
+plaisir
+bras
+tonneau
+bruyant
+proclamer
+couture
+bienvenue
+cage
+calvaire
+connaissance
+tenir
+propre
+confesser
+degré
+maintenant
+droit
+lancer
+gelée
+reconnaissant
+ancien
+colonne
+nord
+maussade
+talent
+contempler
+fermer
+vélo
+ni
+garantir
+résigner
+brut
+blond
+reporter
+vite
+aisance
+gêner
+blé
+forge
+nourrir
+barquette
+abord
+teinte
+pardessus
+ravir
+emploi
+étage
+sauf
+frêle
+prêt
+lièvre
+créer
+pâture
+extrême
+victime
+tendresse
+rue
+inconnu
+possible
+croquer
+encre
+anglais
+chasser
+rester
+charbonnage
+sinistre
+carnet
+effrayer
+myosotis
+fouetter
+expliquer
+écorce
+ravage
+sublime
+revue
+entretien
+géographie
+boucler
+gravement
+quel
+or
+lis
+écolier
+dégât
+taire
+insister
+onde
+supplier
+chariot
+mécanique
+baiser
+vouloir
+fossé
+mois
+porter
+exercer
+puis
+poulain
+illusion
+sécurité
+marier
+gîte
+tapisser
+domestique
+amer
+étincelant
+garnir
+providence
+espérer
+cartable
+fonds
+alouette
+ébranler
+estime
+soleil
+valise
+entourer
+insecte
+armer
+sortir
+jouir
+éclore
+mécontent
+loyal
+primaire
+contenu
+généralement
+persuader
+infini
+anniversaire
+fenêtre
+action
+forgeron
+agiter
+fortement
+réveil
+accomplir
+disposition
+ordinairement
+embellir
+mesurer
+arme
+souci
+graine
+soirée
+robe
+proverbe
+manifester
+pantalon
+dictée
+bouleau
+illuminer
+fêter
+relatif
+certes
+élever
+mort
+natal
+drôle
+point
+modèle
+exister
+voeu
+beauté
+admirer
+redresser
+par
+cours
+varier
+envahir
+content
+retenir
+amuser
+s'efforcer
+obéir
+pondre
+logis
+avouer
+museau
+parti
+grandir
+promeneur
+enfance
+autour
+flamme
+durer
+adversaire
+préférer
+retirer
+informer
+mardi
+terreur
+étonner
+aimer
+tricot
+entrer
+consentir
+carrière
+aérer
+réaliser
+régiment
+renverser
+foire
+immédiatement
+chien
+accompagner
+traitement
+inondation
+combien
+épargne
+détruire
+faible
+champ
+aigu
+arranger
+monument
+baptême
+punition
+abandonner
+rez-de-chaussée
+troupeau
+sale
+rien
+afin
+famille
+agitation
+tabac
+coupe
+fillette
+sud
+carton
+file
+habituer
+triste
+catholique
+sévèrement
+permission
+match
+retentir
+fabriquer
+communal
+défunt
+rare
+remporter
+jour
+période
+sec
+labeur
+lenteur
+débattre
+montant
+bouger
+joie
+sac
+demeurer
+muraille
+sage
+facilement
+bas
+abîme
+attentivement
+tuyau
+munir
+tricoter
+raison
+nègre
+morne
+accueil
+exquis
+lisse
+apporter
+bise
+emballer
+examiner
+américain
+réformer
+admettre
+servante
+droite
+occasion
+église
+éléphant
+garniture
+établir
+récolter
+hésiter
+avance
+compassion
+égard
+sensible
+emplacement
+montrer
+docteur
+retourner
+comme
+acheter
+étincelle
+pont
+zèle
+déterminer
+continuer
+vieillesse
+attribuer
+enfoncer
+partager
+course
+rond
+trancher
+tourmenter
+ravissant
+migrateur
+odorant
+s'empresser
+malheureux
+dimanche
+barreau
+cependant
+drap
+haine
+importer
+attachement
+sacré
+croire
+discuter
+plumier
+bouton
+araignée
+romain
+groseillier
+diminuer
+convertir
+saisir
+interroger
+garder
+atelier
+respiration
+chaudement
+distribution
+collège
+société
+tromper
+roue
+réfugier
+patin
+remuer
+mignon
+corbeau
+statue
+perdrix
+croiser
+cygne
+exciter
+peu
+nager
+remords
+découverte
+demande
+paquet
+perche
+meuble
+pis
+ferme
+froment
+symbole
+tellement
+examen
+sable
+art
+rattraper
+charbon
+gonfler
+monde
+correspondance
+soumettre
+entendre
+cerise
+entraîner
+misérable
+admiration
+imprimer
+établissement
+brumeux
+bureau
+crèche
+tirelire
+infirme
+fils
+sinon
+mille
+oui
+charrette
+troisième
+viser
+arrière
+empressement
+péché
+acheminer
+grêle
+coton
+extraire
+maintenir
+chacun
+placer
+avancer
+soutenir
+preuve
+réjouir
+provoquer
+couvercle
+tulipe
+étourdi
+postal
+dépenser
+dater
+produire
+percer
+reprocher
+émouvoir
+cache-cache
+lait
+mémoire
+bonsoir
+étaler
+volontiers
+ouate
+tigre
+naturellement
+davantage
+richesse
+avaler
+brèche
+serrer
+conformément
+paisiblement
+marchandise
+vigueur
+caisse
+darder
+principalement
+racine
+cueillir
+bouquet
+ruine
+baptiser
+épouvantable
+cadran
+arriver
+aboyer
+rôder
+reconnaître
+chaussure
+apôtre
+attendre
+incident
+violent
+fromage
+muscle
+lutte
+pratique
+ange
+propreté
+studieux
+malle
+bossu
+femme
+repos
+reconduire
+spectateur
+accord
+observation
+grouper
+ruisseler
+figure
+charmant
+vitesse
+époux
+familial
+âme
+honorer
+enterrement
+monnaie
+éclabousser
+sapin
+désespérer
+juger
+opposer
+disputer
+menu
+arbuste
+lot
+bourrasque
+supérieur
+puissance
+cuivre
+payer
+papillon
+échantillon
+pièce
+composer
+incendie
+parcourir
+patrie
+calcul
+apprécier
+timide
+orage
+labourer
+appel
+vierge
+chasse
+recours
+embrasser
+salle
+cadre
+voile
+million
+moitié
+feuillage
+haut
+puits
+augmenter
+juste
+derrière
+blessure
+midi
+calendrier
+clair
+hiver
+fleur
+magique
+touffu
+violette
+libérer
+caractère
+choix
+plumage
+remonter
+étonnement
+impatiemment
+fixer
+proposition
+banc
+trois
+irriter
+coffre
+regret
+brise
+divin
+endormir
+précéder
+allée
+noisette
+tourner
+carotte
+mère
+boulevard
+faire
+règle
+témoin
+rame
+affectionner
+sans
+adresse
+chant
+cou
+hermine
+tordre
+chevet
+panier
+fleuve
+argent
+silence
+manquer
+bourgeois
+siège
+replier
+masse
+cime
+dépens
+excuser
+actuel
+mal
+apercevoir
+tribunal
+renouveau
+domicile
+abeille
+cher
+dépêcher
+malheur
+condition
+riz
+passion
+sermon
+pointe
+arrivée
+impatient
+sérieusement
+brave
+soif
+joueur
+muguet
+carrousel
+accueillir
+continuel
+contraire
+dicter
+vanter
+régulier
+sain
+encrier
+vallée
+canne
+tendrement
+imprudent
+décéder
+précipiter
+boule
+portefeuille
+réparer
+universel
+pâtisserie
+démarche
+neiger
+souriant
+branche
+narcisse
+étrange
+eau
+clown
+parrain
+jeudi
+février
+moudre
+fil
+affaire
+salon
+sauvage
+rôti
+ouverture
+poudre
+repasser
+perroquet
+mare
+rameau
+extrémité
+disparition
+réception
+coudre
+pardonner
+central
+observer
+dépouiller
+celle-ci
+absolument
+amitié
+inférieur
+tâche
+profit
+illustre
+honorable
+assembler
+sergent
+faciliter
+pupitre
+politique
+changer
+basse-cour
+spacieux
+officier
+délaisser
+exemple
+trouer
+accuser
+descente
+beau
+chaîne
+saison
+vérifier
+deuxième
+femelle
+abîmer
+tristement
+portrait
+entrouvrir
+défenseur
+redouter
+représenter
+parages
+endosser
+excellent
+bénédiction
+combattre
+récréation
+coup
+horrible
+ramage
+constater
+képi
+paroisse
+tranquille
+décider
+rencontre
+broyer
+maigre
+paire
+ombre
+matière
+obscurité
+scène
+envier
+chanson
+signal
+exprimer
+monotone
+s'absenter
+septembre
+utile
+enrichir
+matin
+catéchisme
+aumône
+marron
+taille
+canot
+projet
+parfum
+congé
+imposer
+départ
+mars
+vitre
+sucre
+semer
+coin
+tartine
+sommeil
+infiniment
+raccommoder
+hypocrite
+intérêt
+tournoyer
+prendre
+adoucir
+averse
+monseigneur
+pompier
+givre
+méchant
+stupéfaction
+aboutir
+genêt
+orange
+malette
+hangar
+ordonner
+crainte
+fiancé
+épi
+clarté
+meilleur
+prêcher
+brochure
+gagner
+calme
+sur
+hier
+dangereux
+pois
+défaire
+convenir
+indispensable
+vainqueur
+fabrication
+prière
+prisonnier
+aîné
+gigantesque
+vente
+s'évanouir
+envoyer
+charger
+pinceau
+pousser
+retard
+autant
+distrait
+arrondir
+simplicité
+pâle
+assez
+céder
+difficulté
+généreux
+perdre
+mousse
+franc
+redescendre
+siècle
+contribuer
+excursion
+marchander
+hygiène
+délivrer
+esprit
+dix
+objet
+néanmoins
+besoin
+diable
+verser
+éprouver
+marcher
+nul
+transport
+vrai
+dame
+exaucer
+présence
+quatorze
+étroit
+épanouir
+commandant
+force
+adroit
+rouiller
+réveiller
+conduite
+mélancolique
+communion
+sursaut
+borne
+conquérir
+clocher
+étoffe
+heurter
+confiture
+épais
+gris
+honteux
+quartier
+dépasser
+ronde
+distribuer
+facteur
+asseoir
+rencontrer
+méthode
+absence
+rappeler
+constituer
+sort
+enfant
+grossir
+mât
+seau
+offenser
+fatal
+honneur
+hérissé
+prêter
+différence
+soulager
+traverser
+pourvoir
+seconde
+adorer
+sévir
+odeur
+colorer
+malheureusement
+charitable
+nu
+mouvoir
+agacer
+loisir
+supposer
+oh
+tricolore
+appui
+approcher
+institut
+bûcheron
+laver
+religion
+respectueux
+avoir
+chercher
+flamber
+savourer
+tortue
+déclarer
+éclair
+honnête
+temps
+danser
+brusque
+même
+particulièrement
+humeur
+durant
+plupart
+réussir
+barrage
+maman
+cacher
+cheval
+absolu
+modestie
+nuisible
+pencher
+tour
+peupler
+plateau
+grippe
+quitter
+lever
+régler
+coffret
+celui-ci
+obéissant
+délice
+sortie
+vivement
+vin
+osier
+enthousiasme
+firmament
+déplacer
+prairie
+enterrer
+costume
+cave
+acide
+trajet
+manière
+discours
+dizaine
+bandit
+empêcher
+poêle
+profession
+mélodieux
+relativement
+cheminée
+soupirer
+convaincre
+faucher
+adopter
+peuple
+été
+méditer
+pénétrer
+passer
+rassembler
+trace
+façade
+façon
+attaque
+apparence
+solitaire
+voici
+échelle
+époque
+immense
+fâcheux
+ici
+principal
+étang
+bonnet
+kilogramme
+français
+mendiant
+aborder
+automne
+lunette
+marchand
+doigt
+air
+conséquence
+acheteur
+haie
+bâton
+probablement
+digne
+bleuet
+sécher
+enseigner
+mêler
+tige
+interruption
+coûter
+noix
+poirier
+os
+redire
+âgé
+vilain
+contact
+pourpre
+douceur
+lac
+ruiner
+aisé
+contenir
+lundi
+langage
+espérance
+direction
+température
+brique
+sûr
+cinéma
+raser
+sel
+primevère
+douze
+charlatan
+remarquer
+entreprendre
+parfait
+mer
+regarder
+bientôt
+piquer
+dérouler
+économie
+balayer
+autrui
+homme
+évêque
+sitôt
+échapper
+minuit
+vingt
+orner
+défaut
+colonial
+couvrir
+cuir
+chaud
+canard
+rafraîchir
+couper
+harmonieux
+poids
+bourse
+purifier
+différent
+amusant
+très
+marronnier
+pensionnaire
+bienfaisant
+médaille
+navire
+comte
+oeil
+interrompre
+miette
+charrue
+mauve
+violet
+guêpe
+endroit
+souverain
+former
+administration
+aide
+bizarre
+désigner
+tonnerre
+hache
+aspirer
+bougie
+rêve
+franchir
+bain
+regard
+journée
+cerisier
+pendre
+touffe
+attente
+ténèbres
+prodigieux
+songer
+forme
+révéler
+verdure
+monsieur
+intérieur
+cri
+plein
+appétissant
+soldat
+sévère
+écluse
+local
+beaucoup
+genou
+coq
+saule
+race
+géranium
+roi
+souiller
+pomme
+privation
+botte
+large
+bâtir
+rejoindre
+thé
+question
+devoir
+terre
+client
+noble
+sombre
+installer
+valet
+habileté
+presser
+risque
+lit
+mirer
+remerciement
+rocher
+élargir
+désastre
+impossible
+misère
+moment
+coutume
+loin
+bon
+frayeur
+cloche
+pâlir
+cadavre
+honte
+carreau
+jaillir
+souvenir
+vulgaire
+hôtel
+dispenser
+inspecteur
+prévenir
+élève
+mieux
+treize
+profond
+suc
+parer
+pâtre
+déployer
+laine
+condisciple
+lys
+influence
+déchirer
+après-midi
+inonder
+occasionner
+surveiller
+déranger
+disposer
+transformation
+verdâtre
+giboulée
+piano
+face
+allure
+écrit
+ton
+matinal
+remise
+vent
+paresseux
+flotter
+grave
+creux
+autrement
+plainte
+ruche
+hêtre
+louange
+alerte
+récompense
+intervenir
+rossignol
+visage
+six
+coude
+visible
+parquet
+mètre
+perpétuel
+pavé
+solliciter
+forestier
+exhaler
+don
+directement
+équipe
+choeur
+témoigner
+faveur
+différer
+friandise
+ennuyeux
+mince
+bois
+gaufre
+tôt
+bond
+tronc
+année
+tenue
+emplir
+rosier
+moustache
+bonjour
+pied
+humanité
+injure
+roux
+gravir
+descendre
+domaine
+pêcher
+souple
+voisinage
+ça
+faim
+reposer
+premier
+élancer
+argenter
+liste
+égal
+propriétaire
+subitement
+salade
+sincèrement
+serviable
+avenir
+concerner
+arbre
+déménager
+correction
+car
+cabine
+griffer
+poing
+aiguiser
+corps
+ligue
+nommer
+sol
+destination
+lutin
+métal
+assidu
+voyageur
+attacher
+successivement
+transparent
+abondamment
+cercle
+bijou
+sifflement
+encourager
+souffler
+machine
+atmosphère
+parfois
+royal
+ramener
+canif
+nez
+détourner
+boeuf
+soigner
+chèvre
+dans
+auteur
+décourager
+aveugle
+fréquemment
+soigneux
+douleur
+langue
+sentir
+assurer
+tracer
+mystérieux
+bâtiment
+lent
+certain
+nombre
+flûte
+taillis
+désagréable
+bourdonnement
+mensonge
+cérémonie
+larme
+constamment
+importance
+allumer
+fée
+bouder
+poste
+chat
+récompenser
+ciel
+ivoire
+amener
+vénérer
+quoique
+autre
+cultivateur
+ventre
+filet
+s'enfuir
+faux
+halte
+embarras
+brume
+vigoureux
+escalader
+dissiper
+miracle
+brouter
+gratter
+exact
+légèrement
+instruction
+photographie
+exprès
+classe
+cour
+compte
+longueur
+laboureur
+protecteur
+fonction
+sou
+s'agenouiller
+favorable
+dire
+loger
+toilette
+balançoire
+résolution
+jonquille
+dénudé
+nom
+satisfaire
+plutôt
+brûler
+court
+pour
+corolle
+hameau
+subir
+majestueux
+chagrin
+également
+habitant
+cirer
+jaune
+merveilleusement
+émerveiller
+campagne
+réflexion
+relire
+affection
+enchanté
+distraire
+boulangerie
+considérable
+mystère
+ordinaire
+soulagement
+coeur
+lumière
+glissant
+interpeller
+neige
+silencieux
+refrain
+ouvrir
+récolte
+lune
+éclatant
+jaloux
+besogne
+tête
+surprendre
+rapidement
+vague
+extraordinaire
+personnage
+foyer
+bouteille
+grive
+tante
+remarque
+tout
+matériel
+espèce
+finalement
+garçon
+betterave
+débiter
+voir
+rhume
+gymnastique
+multicolore
+serrure
+bien
+craquer
+suivant
+démolir
+guère
+entièrement
+poitrine
+conter
+bruyamment
+écraser
+ballon
+effacer
+délicieux
+courage
+retardataire
+contenter
+ennui
+rosée
+gibier
+nappe
+carabine
+déjeuner
+centimètre
+tram
+chef-d'oeuvre
+photographier
+sept
+sembler
+toucher
+exiger
+lasser
+présenter
+surgir
+suave
+grincer
+cuisine
+contre
+fermier
+calice
+verger
+arracher
+quand
+fleurette
+approche
+remplacer
+souris
+pendant
+non
+place
+civil
+hélas
+destiner
+bibliothèque
+éternel
+reprendre
+consister
+élan
+imprévu
+encadrer
+lors
+mot
+liquide
+désert
+gazouillement
+personne
+attarder
+boueux
+fouiller
+entonner
+devenir
+double
+abuser
+simple
+frissonner
+désordre
+voltiger
+communiquer
+instruire
+chanteur
+fourmi
+banquier
+bonhomme
+fait
+rendez-vous
+angle
+écureuil
+remercier
+pin
+renseignement
+pointu
+dernièrement
+cheveu
+rat
+conte
+électrique
+dépôt
+octobre
+promesse
+boulanger
+ensemble
+émotion
+rendre
+ordre
+poète
+couloir
+plaie
+actuellement
+tramway
+fraise
+arbitre
+aller
+conclure
+auprès
+auberge
+abriter
+travailler
+volet
+bataille
+frère
+occupation
+sérieux
+assaut
+rater
+intéressant
+instrument
+intense
+chapelet
+divers
+frais
+couverture
+neuf
+lui
+mélange
+soie
+plaire
+grenier
+désoler
+soutien
+paysan
+vaincre
+tambour
+visiter
+compliquer
+prime
+maîtresse
+sanglot
+ravin
+printemps
+certainement
+éveiller
+remplir
+état
+impatience
+déplaire
+environ
+confiance
+banane
+seize
+moyenne
+vider
+velouté
+pensée
+refroidir
+brindille
+morale
+profiter
+pot
+assister
+téléphoner
+poser
+affairé
+claquer
+entier
+dieu
+suite
+quinzaine
+plaque
+fléau
+solennel
+vitrine
+rigoureux
+leçon
+adieu
+détour
+collection
+gai
+lieue
+construire
+demain
+sincérité
+âge
+horloge
+avis
+apparaître
+trompette
+lugubre
+rapport
+abondance
+anneau
+programme
+viande
+pêcheur
+coquet
+lamentable
+lentement
+inspirer
+ronger
+témoignage
+lapin
+épouser
+poussin
+longtemps
+entretenir
+lécher
+pittoresque
+terrier
+chaussée
+murmure
+comment
+procéder
+répandre
+tissu
+cahier
+houille
+corne
+entrevoir
+jamais
+véritable
+menacer
+éclairer
+laitier
+reparaître
+casser
+pêche
+palais
+marquer
+tinter
+humidité
+compagnon
+tranquillement
+acclamation
+commencer
+représentant
+exemplaire
+étrennes
+bande
+prix
+facilité
+rapiécer
+conversation
+multiple
+embarrasser
+pompe
+prompt
+tirer
+comparaison
+vert
+compagnie
+gros
+prise
+yeux
+vice
+cerf
+ligne
+poisson
+choisir
+excellence
+troubler
+hôte
+absent
+camp
+mode
+reine
+près
+vieux
+trotter
+désobéir
+justice
+proche
+glissoire
+heureux
+route
+étendue
+jouet
+affaiblir
+prison
+poireau
+veau
+aile
+mûrir
+militaire
+moral
+boiteux
+tristesse
+chaumière
+préparer
+partie
+éviter
+rider
+secours
+clairière
+existence
+vêpres
+rétablir
+grenouille
+bagage
+construction
+représentation
+après
+sonner
+balcon
+torrent
+revoir
+montagne
+réfléchir
+aider
+posséder
+bienfait
+expérience
+feu
+ivresse
+naturel
+gentiment
+épouvanter
+développer
+récit
+souffrance
+regretter
+combattant
+grand-père
+siffler
+entrain
+superbe
+station
+moteur
+nourriture
+ajouter
+duquel
+presque
+beurre
+atteler
+guide
+cendre
+rentrer
+signe
+pierre
+numéro
+effet
+maternel
+promenade
+travers
+chapelle
+énergie
+ouvrier
+grand-mère
+humain
+milieu
+gens
+tacheter
+ruisseau
+leur
+vase
+précisément
+désir
+singe
+diviser
+soupçonner
+terrible
+curieux
+déposer
+traîneau
+culture
+mercredi
+goal
+chez
+magasin
+association
+chelou
+teuf
+ordinateur
+tablette
+smartphone
\ No newline at end of file
diff --git a/src/include/lib/Paheko/API.php b/src/include/lib/Paheko/API.php
new file mode 100644
index 0000000..629d214
--- /dev/null
+++ b/src/include/lib/Paheko/API.php
@@ -0,0 +1,781 @@
+allowed_methods)) {
+ throw new APIException('Invalid request method: ' . $method, 405);
+ }
+
+ $this->path = trim($path, '/');
+ $this->method = $method;
+ $this->params = $params;
+ }
+
+ public function __destruct()
+ {
+ if (null !== $this->file_pointer) {
+ $this->closeFilePointer();
+ }
+ }
+
+ public function setAllowedFilesRoot(?string $root): void
+ {
+ $this->allowed_files_root = rtrim($root, '/') . '/';
+ }
+
+ public function isPathAllowed(string $path): bool
+ {
+ if (!$this->allowed_files_root) {
+ return false;
+ }
+
+ return 0 === strpos($path, $this->allowed_files_root);
+ }
+
+ public function setAccessLevelByName(string $level): void
+ {
+ if ($level === 'read') {
+ $this->access = Session::ACCESS_READ;
+ }
+ elseif ($level === 'write') {
+ $this->access = Session::ACCESS_WRITE;
+ }
+ elseif ($level === 'admin') {
+ $this->access = Session::ACCESS_ADMIN;
+ }
+ else {
+ throw new \InvalidArgumentException('Invalid access level: ' . $level);
+ }
+ }
+
+ public function setAccessLevel(int $level): void
+ {
+ $this->access = $level;
+ }
+
+ public function setFilePointer($pointer): void
+ {
+ if (!is_resource($pointer)) {
+ throw new InvalidArgumentException('Invalid argument: not a file resource');
+ }
+
+ $this->file_pointer = $pointer;
+ }
+
+ public function closeFilePointer(): void
+ {
+ @fclose($this->file_pointer);
+ $this->file_pointer = null;
+ }
+
+ protected function requireAccess(int $level)
+ {
+ if ($this->access < $level) {
+ throw new APIException('You do not have enough rights to make this request', 403);
+ }
+ }
+
+ protected function hasParam(string $param): bool
+ {
+ return array_key_exists($param, $this->params);
+ }
+
+ protected function download(string $uri)
+ {
+ if ($this->method != 'GET') {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ if ($uri === 'files') {
+ Files::zipAll();
+ }
+ elseif ($uri === '') {
+ Backup::dump();
+ }
+ else {
+ throw new APIException('Unknown path: ' . $uri, 404);
+ }
+
+ return null;
+ }
+
+ protected function sql()
+ {
+ if ($this->method != 'POST') {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ $body = $this->params['sql'] ?? self::getRequestInput();
+
+ if ($body === '') {
+ throw new APIException('Missing SQL statement', 400);
+ }
+
+ try {
+ $s = Search::fromSQL($body);
+ $result = $s->iterateResults();
+ $header = $s->getHeader();
+
+ if (isset($this->params['format']) && in_array($this->params['format'], ['xlsx', 'ods', 'csv'])) {
+ $s->export($this->params['format']);
+ return null;
+ }
+ elseif (!$this->is_http_client) {
+ return ['count' => $s->countResults, 'results' => iterator_to_array($result)];
+ }
+ else {
+ // Stream results to client, in case request is slow
+ header("Content-Type: application/json; charset=utf-8", true);
+ printf("{\n \"count\": %d,\n \"results\":\n [\n", $s->countResults());
+
+ foreach ($result as $i => $row) {
+ $line = [];
+
+ foreach ($row as $n => $v) {
+ $name = $header[$n];
+
+ // Avoid name collision
+ while (isset($line[$name])) {
+ $name .= '_' . $n;
+ }
+
+ $line[$name] = $v;
+ }
+
+ if ($i > 0) {
+ echo ",\n";
+ }
+
+ $json = json_encode($line, JSON_PRETTY_PRINT);
+ $json = " " . str_replace("\n", "\n ", $json);
+ echo $json;
+ }
+
+ echo "\n ]\n}";
+
+ return null;
+ }
+ }
+ catch (DB_Exception $e) {
+ throw new APIException('Error in SQL statement: ' . $e->getMessage(), 400);
+ }
+ }
+
+ protected function user(string $uri): ?array
+ {
+ $fn = strtok($uri, '/');
+ $fn2 = strtok('/');
+ strtok('');
+
+ // CSV import
+ if ($fn == 'import') {
+ $fp = null;
+
+ if ($this->method === 'PUT') {
+ $params = $this->params;
+ }
+ elseif ($this->method === 'POST') {
+ $params = $_POST;
+ }
+ else {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ $mode = $params['mode'] ?? 'auto';
+
+ if (!in_array($mode, ['auto', 'create', 'update'])) {
+ throw new APIException('Unknown mode. Only "auto", "create" and "update" are accepted.', 400);
+ }
+
+ $this->requireAccess(Session::ACCESS_ADMIN);
+
+ $path = tempnam(CACHE_ROOT, 'tmp-import-api');
+
+ if ($this->method === 'POST') {
+ if (empty($_FILES['file']['tmp_name']) || !empty($_FILES['file']['error'])) {
+ throw new APIException('Empty file or no file was sent.', 400);
+ }
+
+ $path = $_FILES['file']['tmp_name'] ?? null;
+ }
+ else {
+ $fp = fopen($path, 'wb');
+ stream_copy_to_stream($this->file_pointer, $fp);
+ fclose($fp);
+ $this->closeFilePointer();
+ }
+
+ try {
+ if (!filesize($path)) {
+ throw new APIException('Empty CSV file', 400);
+ }
+
+ $csv = new CSV_Custom;
+ $df = DynamicFields::getInstance();
+ $csv->setColumns($df->listImportAssocNames());
+ $required_fields = $df->listImportRequiredAssocNames($mode === 'update' ? true : false);
+ $csv->setMandatoryColumns(array_keys($required_fields));
+ $csv->loadFile($path);
+ $csv->skip((int)($params['skip_lines'] ?? 1));
+
+ if (!empty($params['column']) && is_array($params['column'])) {
+ $csv->setIndexedTable($params['column']);
+ }
+ else {
+ $csv->setTranslationTableAuto();
+ }
+
+ if (!$csv->loaded() || !$csv->ready()) {
+ throw new APIException('Missing columns or error during columns matching of import table', 400);
+ }
+
+ if ($fn2 === 'preview') {
+ $report = Users::importReport($csv, $mode);
+
+ $report['unchanged'] = array_map(
+ fn($user) => ['id' => $user->id(), 'name' => $user->name()],
+ $report['unchanged']
+ );
+
+ $report['created'] = array_map(
+ fn($user) => $user->asDetailsArray(),
+ $report['created']
+ );
+
+ $report['modified'] = array_map(
+ function ($user) {
+ $out = ['id' => $user->id(), 'name' => $user->name(), 'changed' => []];
+
+ foreach ($user->getModifiedProperties() as $key => $value) {
+ $out['changed'][$key] = ['old' => $value, 'new' => $user->$key];
+ }
+
+ return $out;
+ },
+ $report['modified']
+ );
+
+
+ return $report;
+ }
+ else {
+ Users::import($csv, $mode);
+ return null;
+ }
+ }
+ finally {
+ Utils::safe_unlink($path);
+ }
+ }
+ else {
+ throw new APIException('Unknown user action', 404);
+ }
+ }
+
+ protected function web(string $uri): ?array
+ {
+ if ($this->method != 'GET') {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ $fn = strtok($uri, '/');
+ $param = strtok('');
+
+ switch ($fn) {
+ case 'list':
+ return [
+ 'categories' => array_map(fn($p) => $p->asArray(true), Web::listCategories($param)),
+ 'pages' => array_map(fn($p) => $p->asArray(true), Web::listPages($param)),
+ ];
+ case 'attachment':
+ $attachment = Files::getFromURI($param);
+
+ if (!$attachment) {
+ throw new APIException('Page not found', 404);
+ }
+
+ $attachment->serve();
+ return null;
+ case 'html':
+ case 'page':
+ $page = Web::getByURI($param);
+
+ if (!$page) {
+ throw new APIException('Page not found', 404);
+ }
+
+ if ($fn == 'page') {
+ $out = $page->asArray(true);
+
+ if ($this->hasParam('html')) {
+ $out['html'] = $page->render();
+ }
+
+ return $out;
+ }
+
+ // HTML render
+ echo $page->render();
+ return null;
+ default:
+ throw new APIException('Unknown web action', 404);
+ }
+ }
+
+ protected function accounting(string $uri): ?array
+ {
+ $fn = strtok($uri, '/');
+ $p1 = strtok('/');
+ $p2 = strtok('');
+
+ if ($fn == 'transaction') {
+ if (!$p1) {
+ if ($this->method != 'POST') {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ $this->requireAccess(Session::ACCESS_WRITE);
+ $transaction = new Transaction;
+ $transaction->importFromAPI($this->params);
+ $transaction->save();
+
+ if (!empty($this->params['linked_users'])) {
+ $transaction->updateLinkedUsers((array)$this->params['linked_users']);
+ }
+
+ if (!empty($this->params['linked_transactions'])) {
+ $transaction->updateLinkedTransactions((array)$this->params['linked_transactions']);
+ }
+
+ if (!empty($this->params['linked_subscriptions'])) {
+ $transaction->updateSubscriptionLinks((array)$this->params['linked_subscriptions']);
+ }
+
+ if ($this->hasParam('move_attachments_from')
+ && $this->isPathAllowed($this->params['move_attachments_from'])) {
+ $file = Files::get($this->params['move_attachments_from']);
+
+ if ($file && $file->isDir()) {
+ $file->rename($transaction->getAttachementsDirectory());
+ }
+ }
+
+ return $transaction->asJournalArray();
+ }
+ // Return or edit linked users
+ elseif ($p1 && ctype_digit($p1) && $p2 == 'users') {
+ $transaction = Transactions::get((int)$p1);
+
+ if (!$transaction) {
+ throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
+ }
+
+ if ($this->method === 'POST') {
+ $this->requireAccess(Session::ACCESS_WRITE);
+ $transaction->updateLinkedUsers((array)($_POST['users'] ?? null));
+ return ['success' => true];
+ }
+ elseif ($this->method === 'DELETE') {
+ $this->requireAccess(Session::ACCESS_WRITE);
+ $transaction->updateLinkedUsers([]);
+ return ['success' => true];
+ }
+ elseif ($this->method === 'GET') {
+ return $transaction->listLinkedUsers();
+ }
+ else {
+ throw new APIException('Wrong request method', 400);
+ }
+ }
+ // Return or edit linked subscriptions
+ elseif ($p1 && ctype_digit($p1) && $p2 == 'subscriptions') {
+ $transaction = Transactions::get((int)$p1);
+
+ if (!$transaction) {
+ throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
+ }
+
+ if ($this->method === 'POST') {
+ $this->requireAccess(Session::ACCESS_WRITE);
+ $transaction->updateSubscriptionLinks((array)($_POST['subscriptions'] ?? null));
+ return ['success' => true];
+ }
+ elseif ($this->method === 'DELETE') {
+ $this->requireAccess(Session::ACCESS_WRITE);
+ $transaction->deleteAllSubscriptionLinks([]);
+ return ['success' => true];
+ }
+ elseif ($this->method === 'GET') {
+ return $transaction->listSubscriptionLinks();
+ }
+ else {
+ throw new APIException('Wrong request method', 400);
+ }
+ }
+ elseif ($p1 && ctype_digit($p1) && !$p2) {
+ $transaction = Transactions::get((int)$p1);
+
+ if (!$transaction) {
+ throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
+ }
+
+ if ($this->method == 'GET') {
+ return $transaction->asJournalArray();
+ }
+ elseif ($this->method == 'POST') {
+ $this->requireAccess(Session::ACCESS_WRITE);
+ $transaction->importFromAPI($this->params);
+ $transaction->save();
+
+ if (!empty($this->params['linked_users'])) {
+ $transaction->updateLinkedUsers((array)$this->params['linked_users']);
+ }
+
+ if (!empty($this->params['linked_transactions'])) {
+ $transaction->updateLinkedTransactions((array)$this->params['linked_transactions']);
+ }
+
+ if (!empty($this->params['linked_subscriptions'])) {
+ $transaction->updateSubscriptionLinks((array)$this->params['linked_subscriptions']);
+ }
+
+ return $transaction->asJournalArray();
+ }
+ else {
+ throw new APIException('Wrong request method', 400);
+ }
+ }
+ else {
+ throw new APIException('Unknown transactions route', 404);
+ }
+ }
+ elseif ($fn == 'years') {
+ if ($this->method != 'GET') {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ if (($p1 === 'current' || ($p1 && ctype_digit($p1))) && ($p2 === 'journal' || $p2 === 'account/journal')) {
+ if ($p1 === 'current') {
+ $id_year = Years::getCurrentOpenYearId();
+
+ if (!$id_year) {
+ throw new APIException('There are no currently open years', 404);
+ }
+ }
+ else {
+ $id_year = (int)$p1;
+ }
+
+ if ($p2 == 'journal') {
+ try {
+ return iterator_to_array(Reports::getJournal(['year' => $id_year]));
+ }
+ catch (\LogicException $e) {
+ throw new APIException('Missing parameter for journal: ' . $e->getMessage(), 400, $e);
+ }
+ }
+ else {
+ $year = Years::get($id_year);
+
+ if (!$year) {
+ throw new APIException('Invalid year.', 400, $e);
+ }
+
+ $a = $year->chart()->accounts();
+
+ if (!empty($this->params['code'])) {
+ $account = $a->getWithCode($this->params['code']);
+ }
+ else {
+ $account = $a->get((int)$this->params['code'] ?? null);
+ }
+
+ if (!$account) {
+ throw new APIException('Unknown account id or code.', 400, $e);
+ }
+
+ $list = $account->listJournal($year->id, false);
+ $list->setTitle(sprintf('Journal - %s - %s', $account->code, $account->label));
+ $list->loadFromQueryString();
+ $list->setPageSize(null);
+ $list->orderBy('date', false);
+ return iterator_to_array($list->iterate());
+ }
+ }
+ elseif (!$p1 && !$p2) {
+ return Years::list();
+ }
+ else {
+ throw new APIException('Unknown years action', 404);
+ }
+ }
+ elseif ($fn == 'charts') {
+ if ($this->method != 'GET') {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ if ($p1 && ctype_digit($p1) && $p2 === 'accounts') {
+ $a = new Accounts((int)$p1);
+ return array_map(fn($c) => $c->asArray(), $a->listAll());
+ }
+ elseif (!$p1 && !$p2) {
+ return array_map(fn($c) => $c->asArray(), Charts::list());
+ }
+ else {
+ throw new APIException('Unknown charts action', 404);
+ }
+ }
+ else {
+ throw new APIException('Unknown accounting action', 404);
+ }
+ }
+
+ protected function services(string $uri): ?array
+ {
+ $fn = strtok($uri, '/');
+ $fn2 = strtok('/');
+ strtok('');
+
+ // CSV import
+ if ($fn === 'subscriptions' && $fn2 === 'import') {
+ $fp = null;
+
+ if ($this->method === 'PUT') {
+ $params = $this->params;
+ }
+ elseif ($this->method === 'POST') {
+ $params = $_POST;
+ }
+ else {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ $this->requireAccess(Session::ACCESS_ADMIN);
+
+ $path = tempnam(CACHE_ROOT, 'tmp-import-api');
+
+ if ($this->method === 'POST') {
+ if (empty($_FILES['file']['tmp_name']) || !empty($_FILES['file']['error'])) {
+ throw new APIException('Empty file or no file was sent.', 400);
+ }
+
+ $path = $_FILES['file']['tmp_name'] ?? null;
+ }
+ else {
+ $fp = fopen($path, 'wb');
+ stream_copy_to_stream($this->file_pointer, $fp);
+ fclose($fp);
+ $this->closeFilePointer();
+ }
+
+ if (!$path) {
+ throw new APIException('Empty CSV file', 400);
+ }
+
+ try {
+ if (!filesize($path)) {
+ throw new APIException('Invalid upload', 400);
+ }
+
+ $csv = new CSV_Custom;
+ $csv->setColumns(Services_User::listImportColumns());
+ $csv->setMandatoryColumns(Services_User::listMandatoryImportColumns());
+
+ $csv->loadFile($path);
+ $csv->setTranslationTableAuto();
+
+ if (!$csv->loaded() || !$csv->ready()) {
+ throw new APIException('Missing columns or error during columns matching of import table: ' . json_encode(Services_User::listMandatoryImportColumns()), 400);
+ }
+
+ Services_User::import($csv);
+ return null;
+ }
+ finally {
+ Utils::safe_unlink($path);
+ }
+ }
+ else {
+ throw new APIException('Unknown user action', 404);
+ }
+ }
+
+ public function errors(string $uri)
+ {
+ $fn = strtok($uri, '/');
+ strtok('');
+
+ if (!ini_get('error_log')) {
+ throw new APIException('The error log is disabled', 404);
+ }
+
+ if (!ENABLE_TECH_DETAILS) {
+ throw new APIException('Access to error log is disabled.', 403);
+ }
+
+ if ($uri == 'report') {
+ if ($this->method != 'POST') {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ $this->requireAccess(Session::ACCESS_ADMIN);
+
+ $body = self::getRequestInput();
+ $report = json_decode($body);
+
+ if (!isset($report->context->id)) {
+ throw new APIException('Invalid JSON body', 400);
+ }
+
+ $log = sprintf('=========== Error ref. %s ===========', $report->context->id)
+ . PHP_EOL . PHP_EOL . "Report from API" . PHP_EOL . PHP_EOL
+ . '' . PHP_EOL . json_encode($report, \JSON_PRETTY_PRINT)
+ . PHP_EOL . ' ' . PHP_EOL;
+
+ error_log($log);
+
+ return null;
+ }
+ elseif ($uri == 'log') {
+ if ($this->method != 'GET') {
+ throw new APIException('Wrong request method', 400);
+ }
+
+ return ErrorManager::getReportsFromLog(null, null);
+ }
+ else {
+ throw new APIException('Unknown errors action', 404);
+ }
+ }
+
+ public function checkAuth(): void
+ {
+ $login = $_SERVER['PHP_AUTH_USER'] ?? null;
+ $password = $_SERVER['PHP_AUTH_PW'] ?? null;
+
+ if (!isset($login, $password)) {
+ throw new APIException('No username or password supplied', 401);
+ }
+
+ $access = API_Credentials::auth($login, $password);
+
+ if (null === $access) {
+ throw new APIException('Invalid username or password', 403);
+ }
+
+ $this->access = $access;
+ }
+
+ public function route()
+ {
+ $uri = $this->path;
+ $fn = strtok($uri, '/');
+ $uri = strtok('');
+
+ switch ($fn) {
+ case 'sql':
+ return $this->sql();
+ case 'download':
+ return $this->download($uri);
+ case 'web':
+ return $this->web($uri);
+ case 'user':
+ return $this->user($uri);
+ case 'errors':
+ return $this->errors($uri);
+ case 'accounting':
+ return $this->accounting($uri);
+ case 'services':
+ return $this->services($uri);
+ default:
+ throw new APIException('Unknown path', 404);
+ }
+ }
+
+ static public function getRequestInput(): string
+ {
+ static $input = null;
+ $input ??= trim(file_get_contents('php://input'));
+ return $input;
+ }
+
+ static public function routeHttpRequest(string $uri)
+ {
+ $type = $_SERVER['CONTENT_TYPE'] ?? null;
+ $type ??= $_SERVER['HTTP_CONTENT_TYPE'] ?? '';
+ $method = $_SERVER['REQUEST_METHOD'] ?? null;
+
+ if ($method === 'POST') {
+ if (false !== strpos($type, '/json')) {
+ $params = (array) json_decode(self::getRequestInput(), true);
+ }
+ else {
+ $params = array_merge($_GET, $_POST);
+ }
+ }
+ else {
+ $params = $_GET;
+ }
+
+ $api = new self($method, $uri, $params);
+ $api->is_http_client = true;
+
+ if ($method === 'PUT') {
+ $api->setFilePointer(fopen('php://input', 'rb'));
+ }
+
+ http_response_code(200);
+
+ try {
+ $api->checkAuth();
+
+ try {
+ $return = $api->route();
+ }
+ catch (UserException|ValidationException $e) {
+ throw new APIException($e->getMessage(), 400, $e);
+ }
+
+ if (null !== $return) {
+ header("Content-Type: application/json; charset=utf-8", true);
+ echo json_encode($return, JSON_PRETTY_PRINT);
+ }
+ }
+ catch (APIException $e) {
+ http_response_code($e->getCode());
+ header("Content-Type: application/json; charset=utf-8", true);
+ echo json_encode(['error' => $e->getMessage()]);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/API_Credentials.php b/src/include/lib/Paheko/API_Credentials.php
new file mode 100644
index 0000000..11f9b08
--- /dev/null
+++ b/src/include/lib/Paheko/API_Credentials.php
@@ -0,0 +1,74 @@
+all('SELECT * FROM @TABLE ORDER BY key;');
+ }
+
+ static public function create(): Entity
+ {
+ $e = new Entity;
+ $e->importForm();
+ $e->secret = password_hash($e->secret, \PASSWORD_DEFAULT);
+ $e->created = new \DateTime;
+ $e->save();
+ return $e;
+ }
+
+ static public function generateSecret(): string
+ {
+ return preg_replace('/[^0-9a-z]/i', '', base64_encode(random_bytes(16)));
+ }
+
+ static public function generateKey(): string
+ {
+ return strtolower(substr(self::generateSecret(), 0, 10));
+ }
+
+ static public function delete(int $id): void
+ {
+ $e = EM::findOneById(Entity::class, $id);
+
+ if (!$e) {
+ return;
+ }
+
+ $e->delete();
+ }
+
+ static public function login(string $key, string $secret): ?Entity
+ {
+ $e = EM::findOne(Entity::class, 'SELECT * FROM @TABLE WHERE key = ?;', $key);
+
+ if (!$e || !password_verify($secret, $e->secret)) {
+ return null;
+ }
+
+ EM::getInstance(Entity::class)->DB()->exec(sprintf('UPDATE %s SET last_use = datetime() WHERE id = %d;', Entity::TABLE, $e->id()));
+
+ return $e;
+ }
+
+ static public function auth(string $login, string $password): ?int
+ {
+ if (API_USER && API_PASSWORD && $login === API_USER && $password === API_PASSWORD) {
+ return Session::ACCESS_ADMIN;
+ }
+ elseif ($c = API_Credentials::login($login, $password)) {
+ return $c->access_level;
+ }
+ else {
+ return null;
+ }
+ }
+
+}
diff --git a/src/include/lib/Paheko/Accounting/Accounts.php b/src/include/lib/Paheko/Accounting/Accounts.php
new file mode 100644
index 0000000..da6276b
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Accounts.php
@@ -0,0 +1,395 @@
+chart_id = $chart_id;
+ $this->em = EntityManager::getInstance(Account::class);
+ }
+
+ static public function get(int $id)
+ {
+ return EntityManager::findOneById(Account::class, $id);
+ }
+
+ public function getWithCode(string $code): ?Account
+ {
+ return EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE code = ? AND id_chart = ?', $code, $this->chart_id);
+ }
+
+ static public function getSelector(?int $id): ?array
+ {
+ if (!$id) {
+ return null;
+ }
+
+ return [$id => self::getCodeAndLabel($id)];
+ }
+
+ static public function getCodeAndLabel(int $id): string
+ {
+ return EntityManager::getInstance(Account::class)->col('SELECT code || \' — \' || label FROM @TABLE WHERE id = ?;', $id);
+ }
+
+ public function getIdFromCode(string $code): int
+ {
+ return $this->em->col('SELECT id FROM @TABLE WHERE code = ? AND id_chart = ?;', $code, $this->chart_id);
+ }
+
+ static public function getCodeFromId(string $id): string
+ {
+ return EntityManager::getInstance(Account::class)->col('SELECT code FROM @TABLE WHERE id = ?;', $id);
+ }
+
+ public function getSelectorFromCode(?string $code): ?array
+ {
+ if (!$code) {
+ return null;
+ }
+
+ $a = DB::getInstance()->first(
+ 'SELECT id, code || \' — \' || label AS label FROM acc_accounts WHERE code = ? AND id_chart = ?;',
+ $code,
+ $this->chart_id);
+
+ if (!$a) {
+ return null;
+ }
+
+ return [$a->id => $a->label];
+ }
+
+ /**
+ * Return common accounting accounts from current chart
+ * (will not return analytical and volunteering accounts)
+ */
+ public function listCommonTypes(): array
+ {
+ $sql = sprintf('SELECT * FROM @TABLE WHERE id_chart = %d AND %s ORDER BY code COLLATE NOCASE;',
+ $this->chart_id,
+ DB::getInstance()->where('type', Account::COMMON_TYPES)
+ );
+ return $this->em->all($sql);
+ }
+
+ public function list(?array $types = null): DynamicList
+ {
+ $columns = [
+ 'id' => [
+ ],
+ 'code' => [
+ 'label' => 'N°',
+ 'order' => 'code COLLATE NOCASE %s',
+ ],
+ 'label' => [
+ 'label' => 'Libellé',
+ ],
+ 'description' => [
+ 'label' => '',
+ 'order' => null,
+ ],
+ 'level' => [
+ 'select' => 'CASE WHEN LENGTH(code) >= 6 THEN 6 ELSE LENGTH(code) END',
+ ],
+ 'report' => [
+ 'label' => ' ',
+ 'select' => null,
+ ],
+ 'position' => [
+ 'label' => 'Position',
+ ],
+ 'user' => [
+ 'label' => 'Ajouté',
+ ],
+ 'bookmark' => [
+ 'label' => 'Favori',
+ ],
+ ];
+
+ $tables = 'acc_accounts';
+ $conditions = 'id_chart = ' . $this->chart_id;
+
+ if (!empty($types)) {
+ $types = array_map('intval', $types);
+ $conditions .= ' AND ' . DB::getInstance()->where('type', $types);
+ }
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('code', false);
+ $list->setPageSize(null);
+ $list->setModifier(function (&$row) {
+ $row->position_report = !$row->position ? '' : ($row->position <= Account::ASSET_OR_LIABILITY ? 'Bilan' : 'Résultat');
+ $row->position_name = Account::POSITIONS_NAMES[$row->position];
+ });
+
+ return $list;
+ }
+
+ public function listAll(array $types = null): array
+ {
+ $condition = '';
+
+ if (!empty($types)) {
+ $types = array_map('intval', $types);
+ $condition = ' AND ' . DB::getInstance()->where('type', $types);
+ }
+
+ $sql = sprintf('SELECT * FROM @TABLE WHERE id_chart = %d %s ORDER BY code COLLATE NOCASE;', $this->chart_id, $condition);
+ return $this->em->all($sql);
+ }
+
+ public function listForCodes(array $codes): array
+ {
+ $db = DB::getInstance();
+ $sql = sprintf('SELECT code, id, label FROM acc_accounts WHERE id_chart = %d AND %s;', $this->chart_id, $db->where('code', $codes));
+ return $db->getGrouped($sql);
+ }
+
+ /**
+ * List common accounts, grouped by type
+ * @return array
+ */
+ public function listCommonGrouped(array $types = null, bool $hide_empty = false): array
+ {
+ if (null === $types) {
+ // If we want all types, then we will get used or bookmarked accounts in common types
+ // and only bookmarked accounts for other types, grouped in "Others"
+ $target = Account::COMMON_TYPES;
+ }
+ else {
+ $target = $types;
+ }
+
+ $out = [];
+
+ foreach ($target as $type) {
+ $out[$type] = (object) [
+ 'label' => Account::TYPES_NAMES[$type],
+ 'type' => $type,
+ 'accounts' => [],
+ ];
+ }
+
+ if (null === $types) {
+ $out[0] = (object) [
+ 'label' => 'Autres',
+ 'type' => 0,
+ 'accounts' => [],
+ ];
+ }
+
+ $db = $this->em->DB();
+
+ $sql = sprintf('SELECT a.* FROM @TABLE a
+ LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
+ WHERE a.id_chart = %d AND ((a.%s AND (a.bookmark = 1 OR b.id IS NOT NULL)) %s)
+ GROUP BY a.id
+ ORDER BY type, code COLLATE NOCASE;',
+ $this->chart_id,
+ $db->where('type', $target),
+ (null === $types) ? 'OR (a.bookmark = 1)' : ''
+ );
+
+ $query = $this->em->iterate($sql);
+
+ foreach ($query as $row) {
+ $t = in_array($row->type, $target) ? $row->type : 0;
+ $out[$t]->accounts[] = $row;
+ }
+
+ if ($hide_empty) {
+ foreach ($out as $key => $v) {
+ if (!count($v->accounts)) {
+ unset($out[$key]);
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * List accounts from this type that are missing in current "usual" accounts list
+ */
+ public function listMissing(int $type): array
+ {
+ if ($type != Account::TYPE_EXPENSE && $type != Account::TYPE_REVENUE && $type != Account::TYPE_THIRD_PARTY) {
+ return [];
+ }
+
+ return $this->em->DB()->get($this->em->formatQuery('SELECT a.*, CASE WHEN LENGTH(a.code) >= 6 THEN 6 ELSE LENGTH(a.code) END AS level,
+ (a.bookmark = 1 OR a.user = 1 OR b.id IS NOT NULL) AS already_listed
+ FROM @TABLE a
+ LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
+ WHERE a.id_chart = ? AND a.type = ?
+ GROUP BY a.id
+ ORDER BY type, code COLLATE NOCASE;'), $this->chart_id, $type);
+ }
+
+ public function countByType(int $type): int
+ {
+ return DB::getInstance()->count(Account::TABLE, 'id_chart = ? AND type = ?', $this->chart_id, $type);
+ }
+
+ public function getSingleAccountForType(int $type): ?Account
+ {
+ return $this->em->one('SELECT * FROM @TABLE WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
+ }
+
+ public function getIdForType(int $type): ?int
+ {
+ return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
+ }
+
+ public function getOpeningAccountId(): ?int
+ {
+ return $this->getIdForType(Account::TYPE_OPENING);
+ }
+
+ public function getClosingAccountId(): ?int
+ {
+ return $this->getIdForType(Account::TYPE_CLOSING);
+ }
+
+ public function listUserAccounts(int $year_id): DynamicList
+ {
+ $columns = [
+ 'id' => [
+ 'select' => 'u.id',
+ ],
+ 'user_number' => [
+ 'select' => 'u.' . DynamicFields::getNumberField(),
+ 'label' => 'N° membre',
+ ],
+ 'user_identity' => [
+ 'select' => DynamicFields::getNameFieldsSQL('u'),
+ 'label' => 'Membre',
+ ],
+ 'balance' => [
+ 'select' => 'SUM(l.debit - l.credit)',
+ 'label' => 'Solde',
+ //'order' => 'balance != 0 %s, balance < 0 %1$s',
+ ],
+ 'status' => [
+ 'select' => null,
+ 'label' => 'Statut',
+ ],
+ ];
+
+ $tables = 'acc_transactions_users tu
+ INNER JOIN users u ON u.id = tu.id_user
+ INNER JOIN acc_transactions t ON tu.id_transaction = t.id
+ INNER JOIN acc_transactions_lines l ON t.id = l.id_transaction
+ INNER JOIN acc_accounts a ON a.id = l.id_account';
+
+ $conditions = 'a.type = ' . Account::TYPE_THIRD_PARTY . ' AND t.id_year = ' . $year_id;
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('balance', false);
+ $list->groupBy('u.id');
+ $list->setCount('COUNT(*)');
+ $list->setPageSize(null);
+ $list->setExportCallback(function (&$row) {
+ $row->balance = Utils::money_format($row->balance, '.', '', false);
+ });
+
+ return $list;
+ }
+
+ /**
+ * Renvoie TRUE si le solde du compte est inversé (= crédit - débit, au lieu de débit - crédit)
+ * @return boolean
+ */
+ static public function isReversed(bool $simple, int $type): bool
+ {
+ if ($simple && in_array($type, [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_EXPENSE, Account::TYPE_THIRD_PARTY])) {
+ return false;
+ }
+
+ return true;
+ }
+
+/* FIXME: implement closing of accounts
+
+ public function closeRevenueExpenseAccounts(Year $year, int $user_id)
+ {
+ $closing_id = $this->getClosingAccountId();
+
+ if (!$closing_id) {
+ throw new UserException('Aucun compte n\'est indiqué comme compte de clôture dans le plan comptable');
+ }
+
+ $transaction = new Transaction;
+ $transaction->id_creator = $user_id;
+ $transaction->id_year = $year->id();
+ $transaction->type = Transaction::TYPE_ADVANCED;
+ $transaction->label = 'Clôture de l\'exercice';
+ $transaction->date = new \KD2\DB\Date;
+ $debit = 0;
+ $credit = 0;
+
+ $sql = 'SELECT a.id, SUM(l.credit - l.debit) AS sum, a.position, a.code
+ FROM acc_transactions_lines l
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ INNER JOIN acc_accounts a ON a.id = l.id_account
+ WHERE t.id_year = ? AND a.position IN (?, ?)
+ GROUP BY a.id
+ ORDER BY a.code;';
+
+ $res = DB::getInstance()->iterate($sql, $year->id(), Account::REVENUE, Account::EXPENSE);
+
+ foreach ($res as $row) {
+ $reversed = $row->position == Account::ASSET;
+
+ $line = new Line;
+ $line->id_account = $row->id;
+ $line->credit = $reversed ? abs($row->sum) : 0;
+ $line->debit = !$reversed ? abs($row->sum) : 0;
+ $transaction->addLine($line);
+
+ if ($reversed) {
+ $debit += abs($row->sum);
+ }
+ else {
+ $credit += abs($row->sum);
+ }
+ }
+
+ if ($debit) {
+ $line = new Line;
+ $line->id_account = $closing_id;
+ $line->credit = 0;
+ $line->debit = $debit;
+ $transaction->addLine($line);
+ }
+
+ if ($credit) {
+ $line = new Line;
+ $line->id_account = $closing_id;
+ $line->credit = $credit;
+ $line->debit = 0;
+ $transaction->addLine($line);
+ }
+
+ $transaction->save();
+ }
+*/
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Accounting/AdvancedSearch.php b/src/include/lib/Paheko/Accounting/AdvancedSearch.php
new file mode 100644
index 0000000..0f31b56
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/AdvancedSearch.php
@@ -0,0 +1,320 @@
+ $name) {
+ $types .= sprintf('WHEN %d THEN %s ', $num, $db->quote($name));
+ }
+
+ $types .= 'END';
+
+ return [
+ 'id' => [
+ 'label' => 'Numéro écriture',
+ 'type' => 'integer',
+ 'null' => false,
+ 'select' => 't.id',
+ ],
+ 'id_line' => [
+ 'select' => 'l.id',
+ ],
+ 'date' => [
+ 'label' => 'Date',
+ 'type' => 'date',
+ 'null' => false,
+ 'select' => 't.date',
+ ],
+ 'label' => [
+ 'label' => 'Libellé écriture',
+ 'type' => 'text',
+ 'null' => false,
+ 'select' => 't.label',
+ 'order' => 't.label COLLATE U_NOCASE %s',
+ ],
+ 'reference' => [
+ 'label' => 'Numéro pièce comptable',
+ 'type' => 'text',
+ 'null' => true,
+ 'select' => 't.reference',
+ 'order' => 't.reference COLLATE U_NOCASE %s',
+ ],
+ 'notes' => [
+ 'label' => 'Remarques',
+ 'type' => 'text',
+ 'null' => true,
+ 'select' => 't.notes',
+ 'order' => 't.notes COLLATE U_NOCASE %s',
+ ],
+ 'account_code' => [
+ 'textMatch'=> true,
+ 'label' => 'Numéro de compte',
+ 'type' => 'text',
+ 'null' => false,
+ 'select' => 'a.code',
+ ],
+ 'debit' => [
+ 'label' => 'Débit',
+ 'type' => 'text',
+ 'null' => false,
+ 'select' => 'l.debit',
+ 'normalize' => 'money',
+ ],
+ 'credit' => [
+ 'label' => 'Crédit',
+ 'type' => 'text',
+ 'null' => false,
+ 'select' => 'l.credit',
+ 'normalize' => 'money',
+ ],
+ 'line_label' => [
+ 'label' => 'Libellé ligne',
+ 'type' => 'text',
+ 'null' => true,
+ 'select' => 'l.label',
+ 'order' => 'l.label COLLATE U_NOCASE %s',
+ ],
+ 'line_reference' => [
+ 'textMatch'=> true,
+ 'label' => 'Référence ligne écriture',
+ 'type' => 'text',
+ 'null' => true,
+ 'select' => 'l.reference',
+ ],
+ 'type' => [
+ 'textMatch'=> false,
+ 'label' => 'Type d\'écriture',
+ 'type' => 'enum',
+ 'null' => false,
+ 'values' => Transaction::TYPES_NAMES,
+ 'select' => $types,
+ 'where' => 't.type %s',
+ ],
+ 'id_year' => [
+ 'textMatch'=> false,
+ 'label' => 'Exercice',
+ 'type' => 'enum',
+ 'null' => false,
+ 'values' => $db->getAssoc('SELECT id, label FROM acc_years ORDER BY end_date DESC;'),
+ 'select' => 'y.label',
+ 'where' => 't.id_year %s',
+ ],
+ 'project_code' => [
+ 'textMatch'=> true,
+ 'label' => 'Code projet',
+ 'type' => 'text',
+ 'null' => true,
+ 'select' => 'p.code',
+ ],
+ 'project_label' => [
+ 'textMatch'=> true,
+ 'label' => 'Libellé projet',
+ 'type' => 'text',
+ 'null' => true,
+ 'select' => 'p.label',
+ ],
+ 'has_linked_transactions' => [
+ 'type' => 'boolean',
+ 'label' => 'Est liée à des écritures',
+ 'null' => false,
+ 'select' => '(SELECT 1 FROM acc_transactions_links tl WHERE tl.id_transaction = t.id OR tl.id_related = t.id) IS NOT NULL',
+ 'where' => '(SELECT 1 FROM acc_transactions_links tl WHERE tl.id_transaction = t.id OR tl.id_related = t.id) IS NOT NULL %s',
+ ],
+ 'has_linked_users' => [
+ 'type' => 'boolean',
+ 'label' => 'Est liée à des membres',
+ 'null' => false,
+ 'select' => '(SELECT 1 FROM acc_transactions_users tu WHERE tu.id_transaction = t.id) IS NOT NULL',
+ 'where' => '(SELECT 1 FROM acc_transactions_users tu WHERE tu.id_transaction = t.id) IS NOT NULL %s',
+ ],
+ ];
+ }
+
+ public function simple(string $text, bool $allow_redirect = false, ?int $id_year = null): \stdClass
+ {
+ $query = [];
+
+ $text = trim($text);
+
+ if ($id_year) {
+ $query[] = [
+ 'operator' => 'AND',
+ 'conditions' => [
+ [
+ 'column' => 'id_year',
+ 'operator' => '= ?',
+ 'values' => [$id_year],
+ ],
+ ],
+ ];
+ }
+
+ // Match number: find transactions per credit or debit
+ if (preg_match('/^=\s*\d+([.,]\d+)?$/', $text))
+ {
+ $text = ltrim($text, "\n\t =");
+ $query[] = [
+ 'operator' => 'OR',
+ 'conditions' => [
+ [
+ 'column' => 'debit',
+ 'operator' => '= ?',
+ 'values' => [$text],
+ ],
+ [
+ 'column' => 'credit',
+ 'operator' => '= ?',
+ 'values' => [$text],
+ ],
+ [
+ 'column' => 'label',
+ 'operator' => '1',
+ ]
+ ],
+ ];
+ }
+ // Match account number
+ elseif ($allow_redirect && $id_year && preg_match('/^[0-9]+[A-Z]*$/', $text)
+ && ($year = Years::get($id_year))
+ && ($id = (new Accounts($year->id_chart))->getIdFromCode($text))) {
+ Utils::redirect(sprintf('!acc/accounts/journal.php?id=%d&year=%d', $id, $id_year));
+ }
+ // Match date
+ elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $text) && ($d = Utils::get_datetime($text)))
+ {
+ $query[] = [
+ 'operator' => 'OR',
+ 'conditions' => [
+ [
+ 'column' => 'date',
+ 'operator' => '= ?',
+ 'values' => [$d->format('Y-m-d')],
+ ],
+ ],
+ ];
+ }
+ // Match transaction ID
+ elseif ($allow_redirect && preg_match('/^#[0-9]+$/', $text)) {
+ Utils::redirect(sprintf('!acc/transactions/details.php?id=%d', (int)substr($text, 1)));
+ }
+ // Or search in label or reference
+ else
+ {
+ $operator = 'LIKE %?%';
+ $query[] = [
+ 'operator' => 'OR',
+ 'conditions' => [
+ [
+ 'column' => 'label',
+ 'operator' => $operator,
+ 'values' => [$text],
+ ],
+ [
+ 'column' => 'reference',
+ 'operator' => $operator,
+ 'values' => [$text],
+ ],
+ [
+ 'column' => 'reference',
+ 'operator' => $operator,
+ 'values' => [$text],
+ ],
+ ],
+ ];
+ }
+
+ return (object) [
+ 'groups' => $query,
+ 'order' => 'id',
+ 'desc' => true,
+ ];
+ }
+
+ public function schemaTables(): array
+ {
+ return [
+ 'acc_transactions' => 'Écritures',
+ 'acc_transactions_lines' => 'Lignes des écritures',
+ 'acc_accounts' => 'Comptes des plans comptables',
+ 'acc_years' => 'Exercices',
+ 'acc_projects' => 'Projets',
+ ];
+ }
+
+ public function tables(): array
+ {
+ return array_merge(array_keys($this->schemaTables()), [
+ 'acc_charts',
+ 'acc_transactions_users',
+ 'acc_transactions_links',
+ ]);
+ }
+
+ public function make(string $query): DynamicList
+ {
+ $tables = 'acc_transactions AS t
+ INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id
+ INNER JOIN acc_accounts AS a ON l.id_account = a.id
+ INNER JOIN acc_years AS y ON t.id_year = y.id
+ LEFT JOIN acc_projects AS p ON l.id_project = p.id';
+ return $this->makeList($query, $tables, 'id', true, ['id', 'id_line', 'account_code', 'debit', 'credit']);
+ }
+
+ public function defaults(): \stdClass
+ {
+ $group = [
+ 'operator' => 'AND',
+ 'conditions' => [
+ [
+ 'column' => 'id_year',
+ 'operator' => '= ?',
+ 'values' => [(int)qg('year') ?: Years::getCurrentOpenYearId()],
+ ],
+ [
+ 'column' => 'label',
+ 'operator' => 'LIKE %?%',
+ 'values' => [''],
+ ],
+ ],
+ ];
+
+ if (null !== qg('type')) {
+ $group['conditions'][] = [
+ 'column' => 'type',
+ 'operator' => '= ?',
+ 'values' => [(int)qg('type')],
+ ];
+ }
+
+ if (null !== qg('account')) {
+ $group['conditions'][] = [
+ 'column' => 'account_code',
+ 'operator' => '= ?',
+ 'values' => [qg('account')],
+ ];
+ }
+
+ return (object) ['groups' => [$group]];
+ }
+}
diff --git a/src/include/lib/Paheko/Accounting/AssistedReconciliation.php b/src/include/lib/Paheko/Accounting/AssistedReconciliation.php
new file mode 100644
index 0000000..8a0302d
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/AssistedReconciliation.php
@@ -0,0 +1,216 @@
+ 'Libellé',
+ 'date' => 'Date',
+ //'notes' => 'Remarques',
+ //'reference' => 'Numéro pièce comptable',
+ //'p_reference' => 'Référence paiement',
+ 'amount' => 'Montant',
+ 'debit' => 'Débit',
+ 'credit' => 'Crédit',
+ 'balance' => 'Solde',
+ ];
+
+ protected $csv;
+ protected Account $account;
+
+ public function __construct(Account $account)
+ {
+ $this->account = $account;
+ $this->csv = new CSV_Custom(Session::getInstance(), 'acc_reconcile_csv');
+ $this->csv->setColumns(self::COLUMNS);
+ $this->csv->setMandatoryColumns(['label', 'date']);
+ $this->csv->setModifier(function (\stdClass $line) use ($account) {
+ $date = Entity::filterUserDateValue($line->date);
+
+ $line->date = $date;
+
+ static $has_amount = null;
+
+ if (null === $has_amount) {
+ $has_amount = in_array('amount', $this->csv->getTranslationTable());
+ }
+
+ if (!$has_amount && isset($line->credit) && isset($line->debit)) {
+ $line->amount = $line->credit ?: '-' . ltrim($line->debit, '- \t\r\n');
+ }
+
+ $line->amount = Utils::moneyToInteger($line->amount ?? 0);
+
+ if (!empty($line->balance)) {
+ $line->balance = (substr($line->balance, 0, 1) == '-' ? -1 : 1) * Utils::moneyToInteger($line->balance);
+ }
+
+ $line->new_params = http_build_query([
+ 'a00' => abs($line->amount),
+ 'l' => $line->label,
+ 'dt' => $date ? $date->format('Y-m-d') : '',
+ 't' => $line->amount < 0 ? Transaction::TYPE_EXPENSE : Transaction::TYPE_REVENUE,
+ 'ab' => $account->code,
+ ]);
+
+ return $line;
+ });
+ }
+
+ public function csv(): CSV_Custom
+ {
+ return $this->csv;
+ }
+
+ public function setSettings(array $translation_table, int $skip): void
+ {
+ $this->csv->setTranslationTable($translation_table);
+
+ if ((in_array('credit', $translation_table) && !in_array('debit', $translation_table))
+ || (!in_array('credit', $translation_table) && in_array('debit', $translation_table))) {
+ $this->csv->clear();
+ throw new UserException('Il est nécessaire de sélectionner les deux colonnes "débit" et "crédit", pas seulement "crédit" ou "débit".');
+ }
+
+ $this->csv->skip($skip);
+ }
+
+ public function getStartAndEndDates(): ?array
+ {
+ $start = $end = null;
+
+ if (!$this->csv->ready()) {
+ return compact('start', 'end');
+ }
+
+ foreach ($this->csv->iterate() as $line) {
+ if (null === $start || $line->date < $start) {
+ $start = $line->date;
+ }
+
+ if (null === $end || $line->date > $end) {
+ $end = $line->date;
+ }
+ }
+
+ return compact('start', 'end');
+ }
+
+ public function mergeJournal(\Generator $journal, \DateTimeInterface $start, \DateTimeInterface $end)
+ {
+ $lines = [];
+
+ $csv = iterator_to_array($this->csv->iterate());
+ $journal = iterator_to_array($journal);
+ $i = 0;
+ $sum = 0;
+
+ foreach ($journal as $j) {
+ $id = $j->date->format('Ymd') . '.' . $i++;
+
+ $row = (object) ['csv' => null, 'journal' => $j];
+
+ if (isset($j->debit)) {
+ foreach ($csv as &$line) {
+ if (!isset($line->date)) {
+ continue;
+ }
+
+ // Match date, amount and label
+ if ($j->date->format('Ymd') == $line->date->format('Ymd')
+ && ($j->credit * -1 == $line->amount || $j->debit == $line->amount)
+ && strtolower($j->label) == strtolower($line->label)) {
+ $row->csv = $line;
+ $line = null;
+ break;
+ }
+ }
+ }
+
+ $lines[$id] = $row;
+ }
+
+ unset($line, $row, $j);
+
+ // Second round to match only amount and label
+ foreach ($lines as $row) {
+ if ($row->csv || !isset($row->journal->debit)) {
+ continue;
+ }
+
+ $j = $row->journal;
+
+ foreach ($csv as &$line) {
+ if (!isset($line->date)) {
+ continue;
+ }
+
+ if ($line->date < $start || $line->date > $end) {
+ continue;
+ }
+
+ if ($j->date->format('Ymd') == $line->date->format('Ymd')
+ && ($j->credit * -1 == $line->amount || $j->debit == $line->amount)) {
+ $row->csv = $line;
+ $line = null;
+ break;
+ }
+ }
+ }
+
+ unset($j, $line);
+
+ // Then add CSV lines on the right
+ foreach ($csv as $line) {
+ if (null == $line || !isset($line->date)) {
+ continue;
+ }
+
+ if ($line->date < $start || $line->date > $end) {
+ continue;
+ }
+
+ $id = $line->date->format('Ymd') . '.' . ($i++);
+ $lines[$id] = (object) ['csv' => $line, 'journal' => null];
+ }
+
+ ksort($lines);
+ $prev = null;
+
+ foreach ($lines as &$line) {
+ $line->add = false;
+
+ if (isset($line->csv)) {
+ $sum += $line->csv->amount;
+ $line->csv->running_sum = $sum;
+
+ if ($prev && ($prev->date->format('Ymd') != $line->csv->date->format('Ymd') || $prev->label != $line->csv->label)) {
+ $prev = null;
+ }
+ }
+
+ if (isset($line->csv) && isset($line->journal)) {
+ $prev = null;
+ }
+
+ if (isset($line->csv) && !isset($line->journal) && !$prev) {
+ $line->add = true;
+ $prev = $line->csv;
+ }
+ }
+
+ return $lines;
+ }
+}
diff --git a/src/include/lib/Paheko/Accounting/Charts.php b/src/include/lib/Paheko/Accounting/Charts.php
new file mode 100644
index 0000000..ff85b00
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Charts.php
@@ -0,0 +1,229 @@
+ 'Plan comptable associatif 1999',
+ 'fr_pca_2018' => 'Plan comptable associatif 2020 (Règlement ANC n°2018-06)',
+ 'fr_pcc_2020' => 'Plan comptable des copropriétés (2005 révisé en 2020)',
+ 'fr_cse_2015' => 'Plan comptable des CSE (Comité Social et Économique) (Règlement ANC n°2015-01)',
+ 'fr_pcg_2014' => 'Plan comptable général, pour entreprises (Règlement ANC n° 2014-03, consolidé 1er janvier 2019)',
+ 'fr_pcs_2018' => 'Plan comptable des syndicats (2018)',
+ 'be_pcmn_2019' => 'Plan comptable minimum normalisé des associations et fondations 2019',
+ 'ch_asso' => 'Plan comptable associatif',
+ ];
+
+ static public function getFirstForCountry(string $country): ?Chart
+ {
+ $db = DB::getInstance();
+
+ $chart = EntityManager::findOne(Chart::class, 'SELECT * FROM acc_charts WHERE archived = 0 AND country = ? AND code IS NOT NULL LIMIT 1;', $country);
+
+ if (!$chart) {
+ $chart = EntityManager::findOne(Chart::class, 'SELECT * FROM acc_charts LIMIT 1;',);
+ }
+
+ return $chart;
+ }
+
+ static public function updateInstalled(string $chart_code): ?Chart
+ {
+ $file = sprintf('%s/include/data/charts/%s.csv', ROOT, $chart_code);
+ $country = strtoupper(substr($chart_code, 0, 2));
+ $code = strtoupper(substr($chart_code, 3));
+
+ $chart = EntityManager::findOne(Chart::class, 'SELECT * FROM @TABLE WHERE code = ? AND country = ?;', $code, $country);
+
+ if (!$chart) {
+ return null;
+ }
+
+ $chart->importCSV($file, true);
+ return $chart;
+ }
+
+ static public function resetRules(array $country_list): void
+ {
+ foreach (self::list() as $c) {
+ if (in_array($c->country, $country_list)) {
+ $c->resetAccountsRules();
+ }
+ }
+ }
+
+ static public function installCountryDefault(string $country_code): Chart
+ {
+ if ($country_code == 'CH') {
+ $chart_code = 'ch_asso';
+ }
+ elseif ($country_code == 'be') {
+ $chart_code = 'be_pcmn_2019';
+ }
+ else {
+ $chart_code = 'fr_pca_2018';
+ }
+
+ return self::install($chart_code);
+ }
+
+ static public function install(string $chart_code): Chart
+ {
+ if (!array_key_exists($chart_code, self::BUNDLED_CHARTS)) {
+ throw new \InvalidArgumentException('Le plan comptable demandé n\'existe pas.');
+ }
+
+ $file = sprintf('%s/include/data/charts/%s.csv', ROOT, $chart_code);
+
+ if (!file_exists($file)) {
+ throw new \LogicException('Le plan comptable demandé n\'a pas de fichier CSV');
+ }
+
+ $country = strtoupper(substr($chart_code, 0, 2));
+ $code = strtoupper(substr($chart_code, 3));
+
+ if (DB::getInstance()->test(Chart::TABLE, 'country = ? AND code = ?', $country, $code)) {
+ throw new \RuntimeException('Ce plan comptable est déjà installé');
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ $chart = new Chart;
+
+ $chart->label = self::BUNDLED_CHARTS[$chart_code];
+ $chart->country = $country;
+ $chart->code = $code;
+ $chart->save();
+ $chart->importCSV($file);
+
+ $db->commit();
+ return $chart;
+ }
+
+ static public function listInstallable(): array
+ {
+ $installed = DB::getInstance()->getAssoc('SELECT id, LOWER(country || \'_\' || code) FROM acc_charts;');
+ $out = [];
+
+ foreach (self::BUNDLED_CHARTS as $code => $label) {
+ if (in_array($code, $installed)) {
+ continue;
+ }
+
+ $out[$code] = sprintf('%s — %s', Utils::getCountryName(substr($code, 0, 2)), $label);
+ }
+
+ return $out;
+ }
+
+ static public function get(int $id)
+ {
+ return EntityManager::findOneById(Chart::class, $id);
+ }
+
+ static public function list()
+ {
+ $em = EntityManager::getInstance(Chart::class);
+ return $em->all('SELECT * FROM @TABLE ORDER BY country, label;');
+ }
+
+ static public function listForCountry(string $country): array
+ {
+ $installed = DB::getInstance()->getAssoc(sprintf('SELECT id, label FROM %s WHERE country = ? AND code IS NULL ORDER BY label COLLATE U_NOCASE;', Chart::TABLE), $country);
+ $country = strtolower($country);
+
+ $list = [];
+
+ foreach (self::BUNDLED_CHARTS as $code => $label) {
+ if (substr($code, 0, 2) != $country) {
+ continue;
+ }
+
+ $list[$code] = $label;
+ }
+
+ // Don't use array_merge here, or it will erase ID keys
+ return $list + $installed;
+ }
+
+ static public function getOrInstall(string $id_or_code): int
+ {
+ if (ctype_digit($id_or_code)) {
+ return (int) $id_or_code;
+ }
+
+ $country = strtoupper(substr($id_or_code, 0, 2));
+ $code = strtoupper(substr($id_or_code, 3));
+ $id = DB::getInstance()->firstColumn('SELECT id FROM acc_charts WHERE country = ? AND code = ?;', $country, $code);
+
+ if ($id) {
+ return $id;
+ }
+
+ $chart = self::install($id_or_code);
+ return $chart->id;
+ }
+
+ static public function listByCountry(bool $filter_archived = false)
+ {
+ $where = $filter_archived ? ' AND archived = 0' : '';
+ $sql = sprintf('SELECT id, country, label FROM %s WHERE 1 %s ORDER BY country, code DESC, label;', Chart::TABLE, $where);
+ $list = DB::getInstance()->getGrouped($sql);
+ $out = [];
+
+ foreach ($list as $row) {
+ $country = $row->country ? Utils::getCountryName($row->country) : 'Aucun';
+
+ if (!array_key_exists($country, $out)) {
+ $out[$country] = [];
+ }
+
+ $out[$country][$row->id] = $row->label;
+ }
+
+ return $out;
+ }
+
+ static public function copyFrom(int $from_id, ?string $label, ?string $country): void
+ {
+ $db = DB::getInstance();
+ $db->begin();
+
+ $chart = new Chart;
+ $chart->importForm(compact('label', 'country'));
+ $chart->save();
+
+ $db->exec(sprintf('INSERT INTO %s (id_chart, code, label, description, position, type, user, bookmark)
+ SELECT %d, code, label, description, position, type, user, bookmark FROM %1$s WHERE id_chart = %d;', Account::TABLE, $chart->id, $from_id));
+ $db->commit();
+ }
+
+ static public function import(string $file_key, ?string $label, ?string $country): void
+ {
+ if (empty($_FILES[$file_key]) || empty($_FILES[$file_key]['size']) || empty($_FILES[$file_key]['tmp_name'])) {
+ throw new UserException('Fichier invalide');
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ $chart = new Chart;
+ $chart->importForm(compact('label', 'country'));
+ $chart->save();
+ $chart->importCSV($_FILES[$file_key]['tmp_name']); // This will save everything
+
+ $db->commit();
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Accounting/Export.php b/src/include/lib/Paheko/Accounting/Export.php
new file mode 100644
index 0000000..8f4eda0
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Export.php
@@ -0,0 +1,278 @@
+ 'Complet',
+ self::GROUPED => 'Groupé',
+ self::SIMPLE => 'Simplifié',
+ self::FEC => 'FEC',
+ ];
+
+ const COLUMNS_FULL = [
+ 'Numéro d\'écriture' => 'id',
+ 'Type' => 'type',
+ 'Statut' => 'status',
+ 'Libellé' => 'label',
+ 'Date' => 'date',
+ 'Remarques' => 'notes',
+ 'Numéro pièce comptable' => 'reference',
+
+ // Lines
+ 'Numéro compte' => 'account',
+ 'Libellé compte' => 'account_label',
+ 'Débit' => 'debit',
+ 'Crédit' => 'credit',
+ 'Référence ligne' => 'line_reference',
+ 'Libellé ligne' => 'line_label',
+ 'Rapprochement' => 'reconciled',
+ 'Projet analytique' => 'project',
+ 'Membres associés' => 'linked_users',
+ ];
+
+ const COLUMNS = [
+ self::GROUPED => self::COLUMNS_FULL,
+ self::FULL => self::COLUMNS_FULL,
+ self::SIMPLE => [
+ 'Numéro d\'écriture' => 'id',
+ 'Type' => 'type',
+ 'Statut' => 'status',
+ 'Libellé' => 'label',
+ 'Date' => 'date',
+ 'Remarques' => 'notes',
+ 'Numéro pièce comptable' => 'reference',
+ 'Référence paiement' => 'p_reference',
+ 'Compte de débit' => 'debit_account',
+ 'Compte de crédit' => 'credit_account',
+ 'Montant' => 'amount',
+ 'Projet analytique' => 'project',
+ 'Membres associés' => 'linked_users',
+ ],
+ self::FEC => [
+ 'JournalCode' => null,
+ 'JournalLib' => null,
+ 'EcritureNum' => 'id',
+ 'EcritureDate' => 'date',
+ 'CompteNum' => 'account',
+ 'CompteLib' => 'account_label',
+ 'CompAuxNum' => null,
+ 'CompAuxLib' => null,
+ 'PieceRef' => 'reference',
+ 'PieceDate' => null,
+ 'EcritureLib' => 'label',
+ 'Debit' => 'debit',
+ 'Credit' => 'credit',
+ 'EcritureLet' => null,
+ 'DateLet' => null,
+ 'ValidDate' => null,
+ 'MontantDevise' => null,
+ 'Idevise' => null,
+ ],
+ ];
+
+ const MANDATORY_COLUMNS = [
+ self::FULL => [
+ 'id',
+ 'type',
+ 'label',
+ 'date',
+ 'account',
+ 'credit',
+ 'debit',
+ ],
+ self::GROUPED => [
+ 'type',
+ 'label',
+ 'date',
+ 'account',
+ 'credit',
+ 'debit',
+ ],
+ self::SIMPLE => [
+ 'label',
+ 'date',
+ 'credit_account',
+ 'debit_account',
+ 'amount'
+ ],
+ self::FEC => [
+ 'label',
+ 'date',
+ 'account',
+ 'label',
+ 'debit',
+ 'credit',
+ ],
+ ];
+
+ /**
+ * Return all transactions from year
+ */
+ static public function export(Year $year, string $format, string $type): void
+ {
+ $header = null;
+
+ if (!array_key_exists($type, self::COLUMNS)) {
+ throw new \InvalidArgumentException('Unknown type: ' . $type);
+ }
+
+ CSV::export(
+ $format,
+ sprintf('%s - Export comptable - %s - %s', Config::getInstance()->org_name, self::NAMES[$type], $year->label),
+ self::iterateExport($year->id(), $type),
+ array_keys(self::COLUMNS[$type])
+ );
+ }
+
+ static public function getExamples(Year $year)
+ {
+ $out = [];
+
+ foreach (self::NAMES as $type => $label) {
+ $i = 0;
+ $out[$type] = [array_keys(self::COLUMNS[$type])];
+
+ foreach (self::iterateExport($year->id(), $type) as $row) {
+ $out[$type][] = $row;
+
+ if (++$i > 1) {
+ break;
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ static protected function iterateExport(int $year_id, string $type): \Generator
+ {
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+
+ if (self::SIMPLE == $type) {
+ $sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
+ IFNULL(l1.reference, l2.reference) AS p_reference,
+ a1.code AS debit_account,
+ a2.code AS credit_account,
+ l1.debit AS amount,
+ IFNULL(p.code, p.label) AS project,
+ GROUP_CONCAT(%s) AS linked_users
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit != 0
+ INNER JOIN acc_transactions_lines l2 ON l2.id_transaction = t.id AND l2.credit != 0
+ INNER JOIN acc_accounts a1 ON a1.id = l1.id_account
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
+ LEFT JOIN acc_projects p ON p.id = l1.id_project
+ LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
+ LEFT JOIN users u ON u.id = tu.id_user
+ WHERE t.id_year = ?
+ AND t.type != %d
+ GROUP BY t.id
+ ORDER BY t.date, t.id;';
+
+ $sql = sprintf($sql, $id_field, Transaction::TYPE_ADVANCED);
+ }
+ elseif (self::FEC == $type) {
+ // JournalCode|JournalLib|EcritureNum|EcritureDate|CompteNum|CompteLib
+ // |CompAuxNum|CompAuxLib|PieceRef|PieceDate|EcritureLib|Debit|Credit
+ // |EcritureLet|DateLet|ValidDate|MontantDevise|Idevise
+
+ $sql = 'SELECT
+ printf(\'%02d\', t.type) AS type_id, t.type,
+ t.id, t.date,
+ a.code AS account, a.label AS account_label,
+ NULL AS CompAuxNum, NULL AS CompAuxLib,
+ t.reference,
+ strftime(\'%Y%m%d\', t.date) AS ref_date,
+ t.label,
+ l.debit, l.credit,
+ NULL AS EcritureLet,
+ NULL AS DateLet,
+ NULL AS ValidDate,
+ NULL AS MontantDevise,
+ NULL AS Idevise
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
+ INNER JOIN acc_accounts a ON a.id = l.id_account
+ WHERE t.id_year = ?
+ GROUP BY t.id, l.id
+ ORDER BY t.date, t.id, l.id;';
+ }
+ elseif (self::FULL == $type || self::GROUPED == $type) {
+ $sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
+ a.code AS account, a.label AS account_label, l.debit AS debit, l.credit AS credit,
+ l.reference AS line_reference, l.label AS line_label, l.reconciled,
+ IFNULL(p.code, p.label) AS project,
+ GROUP_CONCAT(%s) AS linked_users
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
+ INNER JOIN acc_accounts a ON a.id = l.id_account
+ LEFT JOIN acc_projects p ON p.id = l.id_project
+ LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
+ LEFT JOIN users u ON u.id = tu.id_user
+ WHERE t.id_year = ?
+ GROUP BY t.id, l.id
+ ORDER BY t.date, t.id, l.id;';
+
+ $sql = sprintf($sql, $id_field);
+ }
+ else {
+ throw new \LogicException('Unknown export type: ' . $type);
+ }
+
+ $res = DB::getInstance()->iterate($sql, $year_id);
+
+ $previous_id = null;
+
+ foreach ($res as $row) {
+ if ($type == self::GROUPED && $previous_id === $row->id) {
+ // Remove transaction data to differentiate lines and transactions
+ $row->id = $row->type = $row->status = $row->label = $row->date = $row->notes = $row->reference = $row->linked_users = null;
+ }
+ else {
+ $row->type = Transaction::TYPES_NAMES[$row->type];
+
+ if (property_exists($row, 'status')) {
+ $status = [];
+
+ foreach (Transaction::STATUS_NAMES as $k => $v) {
+ if ($row->status & $k) {
+ $status[] = $v;
+ }
+ }
+
+ $row->status = implode(', ', $status);
+ }
+
+ $row->date = \DateTime::createFromFormat('Y-m-d', $row->date);
+ $row->date = $row->date->format($type == self::FEC ? 'Ymd' : 'd/m/Y');
+ $previous_id = $row->id;
+ }
+
+ if ($type == self::SIMPLE) {
+ $row->amount = Utils::money_format($row->amount, ',', '');
+ }
+ else {
+ $row->credit = Utils::money_format($row->credit, ',', '');
+ $row->debit = Utils::money_format($row->debit, ',', '');
+ }
+
+ yield $row;
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/Accounting/Graph.php b/src/include/lib/Paheko/Accounting/Graph.php
new file mode 100644
index 0000000..484598d
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Graph.php
@@ -0,0 +1,267 @@
+ 'Évolution banques et caisses',
+ ADMIN_URL . 'acc/reports/graph_plot.php?type=result&%s' => 'Évolution dépenses et recettes',
+ ADMIN_URL . 'acc/reports/graph_plot.php?type=debts&%s' => 'Évolution créances (positif) et dettes (négatif)',
+ ADMIN_URL . 'acc/reports/graph_pie.php?type=assets&%s' => 'Répartition actif',
+ ADMIN_URL . 'acc/reports/graph_pie.php?type=revenue&%s' => 'Répartition recettes',
+ ADMIN_URL . 'acc/reports/graph_pie.php?type=expense&%s' => 'Répartition dépenses',
+ ];
+
+ const PLOT_TYPES = [
+ 'assets' => [
+ 'Total' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING], 'exclude_position' => [Account::LIABILITY]],
+ 'Banques' => ['type' => Account::TYPE_BANK],
+ 'Caisses' => ['type' => Account::TYPE_CASH],
+ 'En attente' => ['type' => Account::TYPE_OUTSTANDING, 'exclude_position' => [Account::LIABILITY]],
+ ],
+ 'result' => [
+ 'Recettes' => ['position' => Account::REVENUE],
+ 'Dépenses' => ['position' => Account::EXPENSE],
+ ],
+ 'debts' => [
+ 'Comptes de tiers' => ['type' => Account::TYPE_THIRD_PARTY],
+ ],
+ ];
+
+ const PIE_TYPES = [
+ 'revenue' => ['position' => Account::REVENUE, 'exclude_type' => Account::TYPE_VOLUNTEERING_REVENUE],
+ 'expense' => ['position' => Account::EXPENSE, 'exclude_type' => Account::TYPE_VOLUNTEERING_EXPENSE],
+ 'assets' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]],
+ ];
+
+ const WEEKLY_INTERVAL = 604800; // 7 days
+ const MONTHLY_INTERVAL = 2635200; // 1 month
+
+ static public function plot(string $type, array $criterias, int $interval = self::WEEKLY_INTERVAL, int $width = 700)
+ {
+ if (!array_key_exists($type, self::PLOT_TYPES)) {
+ throw new \InvalidArgumentException('Unknown type');
+ }
+
+ $plot = new Plot($width, 300);
+
+ if ($type === 'result') {
+ $plot->setLegendPosition($plot::POSITION_BOTTOM_RIGHT);
+ }
+
+ $lines = self::PLOT_TYPES[$type];
+ $data = [];
+
+ foreach ($lines as $label => $line_criterias) {
+ $line_criterias = array_merge($criterias, $line_criterias);
+ $sums = Reports::getSumsByInterval($line_criterias, $interval);
+
+ if (count($sums) <= 1) {
+ continue;
+ }
+
+ // Invert sums for banks, cash, etc.
+ if ('assets' === $type || 'debts' === $type || ('result' === $type && $line_criterias['position'] == Account::EXPENSE)) {
+ $sums = array_map(function ($v) { return $v * -1; }, $sums);
+ }
+
+ $sums = array_map(function ($v) { return (int)$v/100; }, $sums);
+
+ $graph = new Plot_Data($sums);
+ $graph->title = $label;
+ $data[] = $graph;
+ }
+
+
+ if (count($data))
+ {
+ $labels = [];
+
+ foreach ($data[0]->get() as $k=>$v)
+ {
+ $date = new \DateTime('@' . ($k * $interval));
+ $labels[] = Utils::date_fr($date, 'M y');
+ }
+
+ $plot->setLabels($labels);
+
+ $i = 0;
+ $colors = self::getColors();
+
+ foreach ($data as $line)
+ {
+ $line->color = $colors[$i++];
+ $line->width = 3;
+ $plot->add($line);
+
+ if ($i >= count($colors))
+ $i = 0;
+ }
+ }
+
+ $out = $plot->output();
+
+ return $out;
+ }
+
+ static public function pie(string $type, array $criterias)
+ {
+ if (!array_key_exists($type, self::PIE_TYPES)) {
+ throw new \InvalidArgumentException('Unknown type');
+ }
+
+ $pie = new Pie(700, 300);
+
+ $pie_criterias = self::PIE_TYPES[$type];
+ $data = Reports::getAccountsBalances(array_merge($criterias, $pie_criterias), 'balance DESC');
+
+ $others = 0;
+ $colors = self::getColors();
+ $max = count($colors);
+ $total = 0;
+ $count = 0;
+ $i = 0;
+
+ $currency = Config::getInstance()->monnaie;
+
+ foreach ($data as $row) {
+ $total += $row->balance;
+ }
+
+ foreach ($data as $row)
+ {
+ if ($i++ >= $max || $count > $total*0.95)
+ {
+ $others += $row->balance;
+ }
+ else
+ {
+ $label = strlen($row->label) > 40 ? trim(substr($row->label, 0, 38)) . '…' : $row->label;
+ $data = new Pie_Data(abs($row->balance) / 100, $label, $colors[$i-1]);
+ $data->sublabel = Utils::money_format(intval($row->balance / 100) * 100, null, ' ', true) . ' ' . $currency;
+ $pie->add($data);
+ }
+
+ $count += $row->balance;
+ }
+
+ if ($others != 0)
+ {
+ $data = new Pie_Data(abs($others) / 100, 'Autres', '#ccc');
+ $data->sublabel = Utils::money_format(intval($others / 100) * 100, null, ' ', true) . ' ' . $currency;
+ $pie->add($data);
+ }
+
+ $pie->togglePercentage(true);
+
+ $out = $pie->output();
+
+ return $out;
+ }
+
+ static public function bar(string $type, array $criterias)
+ {
+ if (!array_key_exists($type, self::PLOT_TYPES)) {
+ throw new \InvalidArgumentException('Unknown type');
+ }
+
+ $bar = new Bar(600, 300);
+
+ $lines = self::PLOT_TYPES[$type];
+ $data = [];
+
+ $colors = self::getColors();
+
+ foreach ($lines as $label => $line_criterias) {
+ $color = current($colors);
+ next($colors);
+
+ $line_criterias = array_merge($criterias, $line_criterias);
+ $years = Reports::getSumsPerYear($line_criterias);
+
+ if (count($years) < 1) {
+ continue;
+ }
+
+ foreach ($years as $year) {
+ $start = Utils::date_fr($year->start_date, 'Y');
+ $end = Utils::date_fr($year->end_date, 'Y');
+ $year_label = $start == $end ? $start : sprintf('%s-%s', $start, substr($end, -2));
+
+ $year_id = $year_label . '-' . $year->id;
+
+ if (!isset($data[$year_id])) {
+ $data[$year_id] = new Bar_Data_Set($year_label);
+ }
+
+ $data[$year_id]->add((int) $year->balance / 100, $label, $color);
+ }
+ }
+
+ ksort($data);
+
+ foreach ($data as $group) {
+ $bar->add($group);
+ }
+
+ $out = $bar->output();
+
+ return $out;
+ }
+
+ static protected function getColors()
+ {
+ $config = Config::getInstance();
+ $c1 = $config->get('color1') ?: ADMIN_COLOR1;
+ $c2 = $config->get('color2') ?: ADMIN_COLOR2;
+ list($h, $s, $v) = Utils::rgbToHsv($c1);
+ list($h1, $s, $v) = Utils::rgbToHsv($c2);
+
+ $colors = [];
+
+ for ($i = 0; $i < 5; $i++) {
+ if ($i % 2 == 0) {
+ $s = $v = 50;
+ $h =& $h1;
+ }
+ else {
+ $s = $v = 70;
+ $h =& $h2;
+ }
+
+ $colors[] = sprintf('hsl(%d, %d%%, %d%%)', $h, $s, $v);
+
+ $h += 30;
+
+ if ($h > 360) {
+ $h -= 360;
+ }
+ }
+
+ return $colors;
+ }
+}
diff --git a/src/include/lib/Paheko/Accounting/Import.php b/src/include/lib/Paheko/Accounting/Import.php
new file mode 100644
index 0000000..1c87510
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Import.php
@@ -0,0 +1,390 @@
+iterate($sql) as $row) {
+ $found_users[$row->name] = $row->id;
+ $users[$row->name] = $row->id;
+ }
+
+ // Fill array with NULL for missing user names, so that we won't go fetch them again
+ foreach ($linked_users as $name) {
+ if (!array_key_exists($name, $users)) {
+ $users[$name] = null;
+ }
+ }
+ }
+
+ $found_users = array_filter($found_users);
+ }
+ elseif (is_array($linked_users) && count($linked_users) == 0) {
+ $found_users = [];
+ }
+
+
+ if ($transaction->countLines() > 2) {
+ $transaction->type = Transaction::TYPE_ADVANCED;
+ }
+ // Try to magically find out what kind of transaction this is
+ elseif (!isset($transaction->type)) {
+ $transaction->type = $transaction->findTypeFromAccounts();
+ }
+
+ if (!$dry_run) {
+ if ($transaction->isModified() || $transaction->diff()) {
+ $transaction->save();
+ }
+
+ if (null !== $found_users) {
+ $transaction->updateLinkedUsers($found_users);
+ }
+ }
+ else {
+ $transaction->selfCheck();
+ }
+
+ if (null !== $report) {
+ $diff = null;
+
+ if (!$transaction->exists()) {
+ $target = 'created';
+ }
+ elseif (($diff = $transaction->diff())
+ || ($linked_users = $transaction->listLinkedUsersAssoc()) && (array_values($linked_users) != array_keys($found_users))) {
+ if (!$diff) {
+ $diff = [];
+ }
+
+ $target = 'modified';
+
+ if (array_values($linked_users) != array_keys($found_users)) {
+ $diff['linked_users'] = [
+ implode(', ', $linked_users),
+ implode(', ', array_keys($found_users))
+ ];
+ }
+
+ $linked_users = implode(', ', $linked_users);
+ $diff = compact('diff', 'transaction', 'linked_users');
+ }
+ else {
+ $target = 'unchanged';
+ }
+
+ $report[$target][] = $diff ?? array_merge($transaction->asJournalArray(), ['linked_users' => implode(', ', array_keys($found_users))]);
+ }
+ }
+
+ /**
+ * Imports a CSV file of transactions in a year
+ * @param string $type Type of CSV format
+ * @param Year $year Target year where transactions should be updated or created
+ * @param CSV_Custom $csv CSV object
+ * @param int $user_id Current user ID, the one running the import
+ * @param array $options array of options
+ * @return ?array
+ */
+ static public function import(string $type, Year $year, CSV_Custom $csv, int $user_id, array $options = []): ?array
+ {
+ $options_default = [
+ 'ignore_ids' => false,
+ 'dry_run' => false,
+ 'return_report' => false,
+ ];
+
+ $o = (object) array_merge($options_default, $options);
+
+ $dry_run = $o->dry_run;
+
+ if (!array_key_exists($type, Export::MANDATORY_COLUMNS)) {
+ throw new \InvalidArgumentException('Invalid type value');
+ }
+
+ if ($year->closed) {
+ throw new \InvalidArgumentException('Closed year');
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ $accounts = $year->accounts();
+ $transaction = null;
+ $linked_users = null;
+ $types = array_flip(Transaction::TYPES_NAMES);
+
+ if ($o->return_report) {
+ $report = ['created' => [], 'modified' => [], 'unchanged' => []];
+ }
+ else {
+ $report = null;
+ }
+
+ $l = 1;
+
+ try {
+ $current_id = null;
+
+ foreach ($csv->iterate() as $l => $row) {
+ $row = (object) $row;
+
+ // Import grouped transactions
+ if ($type == Export::GROUPED) {
+ // If a line doesn't have any transaction info: this is a line following the previous transaction
+ $has_transaction = !(empty($row->id)
+ && empty($row->type)
+ && empty($row->status)
+ && empty($row->label)
+ && empty($row->date)
+ && empty($row->notes)
+ && empty($row->reference)
+ );
+
+ // New transaction, save previous one
+ if (null !== $transaction && $has_transaction) {
+ self::saveImportedTransaction($transaction, $linked_users, $dry_run, $report);
+ $transaction = null;
+ $linked_users = null;
+ }
+
+ if (!$has_transaction && null === $transaction) {
+ throw new UserException('cette ligne n\'est reliée à aucune écriture');
+ }
+ }
+ else {
+ if (!empty($row->id) && $row->id != $current_id) {
+ if (null !== $transaction) {
+ self::saveImportedTransaction($transaction, $linked_users, $dry_run, $report);
+ $transaction = null;
+ $linked_users = null;
+ }
+
+ $current_id = $row->id;
+ }
+ }
+
+ // Find or create transaction
+ if (null === $transaction) {
+ if (!empty($row->id) && !$o->ignore_ids) {
+ $transaction = Transactions::get((int)$row->id);
+
+ if (!$transaction) {
+ throw new UserException(sprintf('l\'écriture #%d est introuvable', $row->id));
+ }
+
+ if ($transaction->id_year != $year->id()) {
+ throw new UserException(sprintf('l\'écriture #%d appartient à un autre exercice', $row->id));
+ }
+
+ if ($transaction->validated) {
+ throw new UserException(sprintf('l\'écriture #%d est validée et ne peut être modifiée', $row->id));
+ }
+
+ if ($type != Export::SIMPLE) {
+ $transaction->resetLines();
+ }
+ }
+ else {
+ $transaction = new Transaction;
+ $transaction->id_creator = $user_id;
+ $transaction->id_year = $year->id();
+ }
+
+ if (isset($row->type) && !isset($types[$row->type])) {
+ throw new UserException(sprintf('le type "%s" est inconnu. Les types reconnus sont : %s.', $row->type, implode(', ', array_keys($types))));
+ }
+
+ // FEC does not define type, so don't change it
+ if (isset($row->type)) {
+ $transaction->type = $types[$row->type];
+ }
+
+ $fields = array_intersect_key((array)$row, array_flip(['label', 'date', 'notes', 'reference']));
+
+ // Remove empty values
+ $fields = array_filter($fields);
+
+ $transaction->importForm($fields);
+
+ // Set status
+ if (!empty($row->status)) {
+ $status_list = array_map('trim', explode(',', $row->status));
+ $status = 0;
+
+ foreach (Transaction::STATUS_NAMES as $k => $v) {
+ if (in_array($v, $status_list)) {
+ $status |= $k;
+ }
+ }
+
+ $transaction->set('status', $status);
+ }
+
+ if (isset($row->linked_users) && trim($row->linked_users) !== '') {
+ $linked_users = array_map('trim', explode(',', $row->linked_users));
+ }
+ else {
+ $linked_users = [];
+ }
+ }
+
+ $data = [];
+
+ if (!empty($row->project)) {
+ $id_project = Projects::getIdFromCodeOrLabel($row->project);
+
+ if (!$id_project) {
+ throw new UserException(sprintf('le projet analytique "%s" n\'existe pas', $row->project));
+ }
+
+ $data['id_project'] = $id_project;
+ }
+ elseif (property_exists($row, 'project')) {
+ $data['id_project'] = null;
+ }
+
+ // Add two transaction lines for each CSV line
+ if ($type == Export::SIMPLE) {
+ $credit_account = $accounts->getIdFromCode($row->credit_account);
+ $debit_account = $accounts->getIdFromCode($row->debit_account);
+
+ if (!$credit_account) {
+ throw new UserException(sprintf('Compte de crédit "%s" inconnu dans le plan comptable', $row->credit_account));
+ }
+
+ if (!$debit_account) {
+ throw new UserException(sprintf('Compte de débit "%s" inconnu dans le plan comptable', $row->debit_account));
+ }
+
+ $data['reference'] = isset($row->p_reference) ? $row->p_reference : null;
+
+ $l1 = $transaction->getCreditLine() ?? new Line;
+ $l2 = $transaction->getDebitLine() ?? new Line;
+
+ $l1->importForm($data + [
+ 'credit' => $row->amount,
+ 'debit' => 0,
+ 'id_account' => $credit_account,
+ ]);
+
+ $l2->importForm($data + [
+ 'credit' => 0,
+ 'debit' => $row->amount,
+ 'id_account' => $debit_account,
+ ]);
+
+ if (!$l1->exists()) {
+ $transaction->addLine($l1);
+ }
+
+ if (!$l2->exists()) {
+ $transaction->addLine($l2);
+ }
+
+ self::saveImportedTransaction($transaction, $linked_users, $dry_run, $report);
+ $transaction = null;
+ $linked_users = null;
+ }
+ else {
+ $id_account = $accounts->getIdFromCode($row->account);
+
+ if (!$id_account) {
+ throw new UserException(sprintf('le compte "%s" n\'existe pas dans le plan comptable', $row->account));
+ }
+
+ $line_label = $row->line_label ?? null;
+ $line_reference = $row->line_reference ?? null;
+
+ // Try to use label/reference if it changes from line to line
+ if (null === $line_label && isset($row->label) && $row->label != $transaction->label) {
+ $line_label = $row->label;
+ }
+
+ if (null === $line_reference && isset($row->reference) && $row->reference != $transaction->reference) {
+ $line_reference = $row->reference;
+ }
+
+ $data = $data + [
+ 'credit' => $row->credit ?: 0,
+ 'debit' => $row->debit ?: 0,
+ 'id_account' => $id_account,
+ 'reference' => $line_reference,
+ 'label' => $line_label,
+ 'reconciled' => $row->reconciled ?? false,
+ ];
+
+ $line = new Line;
+ $line->importForm($data);
+
+ if (!$line->credit && !$line->debit) {
+ continue;
+ }
+
+ $transaction->addLine($line);
+ }
+ }
+
+ if (null !== $transaction) {
+ self::saveImportedTransaction($transaction, $linked_users, $dry_run, $report);
+ $transaction = null;
+ $linked_users = null;
+ }
+ }
+ catch (UserException $e) {
+ $db->rollback();
+ $e->setMessage(sprintf('Erreur sur la ligne %d : %s', $l - 1, $e->getMessage()));
+
+ if (null !== $transaction) {
+ $e->setDetails($transaction->asDetailsArray());
+ }
+
+ throw $e;
+ }
+
+ $db->commit();
+
+ if ($report) {
+ foreach ($report as $type => $entries) {
+ $report[$type . '_count'] = count($entries);
+ }
+ }
+
+
+ return $report;
+ }
+}
diff --git a/src/include/lib/Paheko/Accounting/Projects.php b/src/include/lib/Paheko/Accounting/Projects.php
new file mode 100644
index 0000000..1e2bde7
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Projects.php
@@ -0,0 +1,163 @@
+firstColumn('SELECT id FROM acc_projects WHERE code = ? OR label = ?;', $str, $str) ?: null;
+ }
+
+ static public function getName(?int $id): ?string
+ {
+ if (!$id) {
+ return null;
+ }
+
+ static $projects = [];
+
+ if (array_key_exists($id, $projects)) {
+ return $projects[$id];
+ }
+
+ $p = DB::getInstance()->first('SELECT code, label FROM acc_projects WHERE id = ?;', $id);
+
+ $projects[$id] = $p ? ($p->code ? sprintf('%s — %s', $p->code, $p->label) : $p->label) : null;
+ return $projects[$id];
+ }
+
+ static public function count(): int
+ {
+ return DB::getInstance()->count(Project::TABLE);
+ }
+
+ static public function listAssoc(): array
+ {
+ $em = EntityManager::getInstance(Project::class);
+ $sql = $em->formatQuery('SELECT id, CASE WHEN code IS NOT NULL THEN code || \' — \' || label ELSE label END FROM @TABLE WHERE archived = 0 ORDER BY code COLLATE NOCASE, label COLLATE U_NOCASE;');
+ return $em->DB()->getAssoc($sql);
+ }
+
+ /**
+ * Return account balances per year or per project
+ * @param bool $by_year If true will return projects grouped by year, if false it will return years grouped by project
+ */
+ static public function getBalances(bool $by_year = false): \Generator
+ {
+ $join = $by_year ? 'INNER' : 'LEFT';
+ $sql = 'SELECT p.label AS project_label, p.description AS project_description, p.id AS id_project,
+ p.code AS project_code, p.archived, p.id AS project_id,
+ y.id AS id_year, y.label AS year_label, y.start_date, y.end_date,
+ SUM(l.credit - l.debit) AS sum, SUM(l.credit) AS credit, SUM(l.debit) AS debit, 0 AS total,
+ (SELECT SUM(l2.credit - l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_transactions t2 ON t2.id = l2.id_transaction
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
+ WHERE a2.position = %d AND l2.id_project = l.id_project AND t2.id_year = t.id_year) * -1 AS sum_expense,
+ (SELECT SUM(l2.credit - l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_transactions t2 ON t2.id = l2.id_transaction
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
+ WHERE a2.position = %d AND l2.id_project = l.id_project AND t2.id_year = t.id_year) AS sum_revenue
+ FROM acc_projects p
+ %s JOIN acc_transactions_lines l ON p.id = l.id_project
+ %3$s JOIN acc_transactions t ON t.id = l.id_transaction
+ %3$s JOIN acc_years y ON y.id = t.id_year
+ GROUP BY %s
+ ORDER BY p.archived, %s;';
+
+ $order = 'p.code COLLATE NOCASE, p.label COLLATE U_NOCASE';
+
+ if ($by_year) {
+ $group = 'y.id, p.id';
+ $order = 'y.start_date DESC, ' . $order;
+ }
+ else {
+ $group = 'p.id, y.id';
+ $order = $order . ', y.id';
+ }
+
+ $sql = sprintf($sql, Account::EXPENSE, Account::REVENUE, $join, $group, $order);
+
+ $current = null;
+
+ static $sums = ['credit', 'debit', 'sum', 'sum_expense', 'sum_revenue'];
+
+ $total = function (\stdClass $current, bool $by_year) use ($sums)
+ {
+ $out = (object) [
+ 'label' => 'Total',
+ 'id_project' => $by_year ? null : $current->id,
+ 'id_year' => $by_year ? $current->id : null,
+ 'total' => 1,
+ ];
+
+ foreach ($sums as $s) {
+ $out->{$s} = $current->{$s};
+ }
+
+ return $out;
+ };
+
+ foreach (DB::getInstance()->iterate($sql) as $row) {
+ $id = $by_year ? $row->id_year : $row->project_id;
+
+ if (null !== $current && $current->selector !== $id) {
+ if (count($current->items)) {
+ $current->items[] = $total($current, $by_year);
+ }
+
+ yield $current;
+ $current = null;
+ }
+
+ if (null === $current) {
+ $current = (object) [
+ 'selector' => $id,
+ 'id' => $by_year ? $row->id_year : $row->id_project,
+ 'label' => $by_year ? $row->year_label : ($row->project_code ? $row->project_code . ' — ' : '') . $row->project_label,
+ 'id_year' => $by_year ? $row->id_year : null,
+ 'description' => !$by_year ? $row->project_description : null,
+ 'archived' => !$by_year ? $row->archived : 0,
+ 'items' => [],
+ ];
+
+ foreach ($sums as $s) {
+ $current->$s = 0;
+ }
+ }
+
+ if (null === $row->sum) {
+ continue;
+ }
+
+ $row->label = !$by_year ? $row->year_label : ($row->project_code ? $row->project_code . ' — ' : '') . $row->project_label;
+ $current->items[] = $row;
+
+ foreach ($sums as $s) {
+ $current->$s += $row->$s;
+ }
+ }
+
+ if ($current === null) {
+ return;
+ }
+
+ if (count($current->items)) {
+ $current->items[] = $total($current, $by_year);
+ }
+
+ yield $current;
+ }
+
+}
diff --git a/src/include/lib/Paheko/Accounting/Reports.php b/src/include/lib/Paheko/Accounting/Reports.php
new file mode 100644
index 0000000..48c238d
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Reports.php
@@ -0,0 +1,666 @@
+quote($criterias['before']->format('Y-m-d'));
+ }
+
+ if (!empty($criterias['after']) && $criterias['after'] instanceof \DateTimeInterface) {
+ $where[] = 'date >= ' . $db->quote($criterias['after']->format('Y-m-d'));
+ }
+
+ if (!count($where)) {
+ throw new \LogicException('No criteria was provided.');
+ }
+
+ return implode(' AND ', $where);
+ }
+
+ static public function countTransactions(array $criterias): int
+ {
+ $where = self::getWhereClause($criterias);
+ return DB::getInstance()->firstColumn('SELECT COUNT(DISTINCT t.id)
+ FROM acc_transactions_lines l INNER JOIN acc_transactions t ON t.id = l.id_transaction WHERE ' .$where);
+ }
+
+ static public function getSumsPerYear(array $criterias): array
+ {
+ $where = self::getWhereClause($criterias);
+
+ $sql = sprintf('SELECT y.id, y.start_date, y.end_date, y.label, SUM(b.balance) AS balance
+ FROM acc_accounts_balances b
+ INNER JOIN acc_years y ON y.id = b.id_year
+ WHERE %s
+ GROUP BY b.id_year ORDER BY y.end_date;', $where);
+
+ return DB::getInstance()->getGrouped($sql);
+ }
+
+ static public function getSumsByInterval(array $criterias, int $interval)
+ {
+ $where = self::getWhereClause($criterias, 't', 'l', 'a');
+ $where_interval = !empty($criterias['year']) ? sprintf(' WHERE id_year = %d', $criterias['year']) : '';
+
+ $db = DB::getInstance();
+
+ $sql = sprintf('SELECT
+ strftime(\'%%s\', MIN(date)) / %d AS start_interval,
+ strftime(\'%%s\', MAX(date)) / %1$d AS end_interval
+ FROM acc_transactions %s;',
+ $interval, $where_interval);
+
+ $result = (array)$db->first($sql);
+ extract($result);
+
+ if (!isset($start_interval, $end_interval)) {
+ return [];
+ }
+
+ $out = array_fill_keys(range($start_interval, $end_interval), 0);
+
+ $sql = sprintf('SELECT strftime(\'%%s\', t.date) / %d AS interval, SUM(l.credit) - SUM(l.debit) AS sum, t.id_year
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
+ INNER JOIN acc_accounts a ON a.id = l.id_account
+ WHERE %s
+ GROUP BY %s ORDER BY %3$s;', $interval, $where, isset($criterias['year']) ? 'interval' : 't.id_year, interval');
+
+ $data = $db->getGrouped($sql);
+ $sum = 0;
+ $year = null;
+
+ foreach ($out as $k => &$v) {
+ if (array_key_exists($k, $data)) {
+ $row = $data[$k];
+ if ($row->id_year != $year) {
+ $sum = 0;
+ $year = $row->id_year;
+ }
+
+ $sum += $data[$k]->sum;
+ }
+
+ $v = $sum;
+ }
+
+ unset($v);
+
+ return $out;
+ }
+
+ static public function getResult(array $criterias): int
+ {
+ if (!empty($criterias['project']) || !empty($criterias['projects_only'])
+ || !empty($criterias['before']) || !empty($criterias['after'])) {
+ $where = self::getWhereClause($criterias, 't', 'l', 'a');
+ $sql = self::getBalancesSQL(['inner_select' => 'l.id_project', 'inner_where' => $where]);
+ $sql = sprintf('SELECT position, SUM(balance) FROM (%s) GROUP BY position;', $sql);
+ }
+ else {
+ $where = self::getWhereClause($criterias);
+ $sql = sprintf('SELECT position, SUM(balance) FROM acc_accounts_balances WHERE %s GROUP BY position;', $where);
+ }
+
+ $balances = DB::getInstance()->getAssoc($sql);
+
+ return ($balances[Account::REVENUE] ?? 0) - ($balances[Account::EXPENSE] ?? 0);
+ }
+
+ static public function getBalancesSQL(array $parts = [])
+ {
+ return sprintf('SELECT %s id_year, id, label, code, type, debit, credit, position, %s, is_debt
+ FROM (
+ SELECT %s t.id_year, a.id, a.label, a.code, a.type,
+ SUM(l.credit) AS credit,
+ SUM(l.debit) AS debit,
+ CASE -- 3 = dynamic asset or liability depending on balance
+ WHEN position = 3 AND SUM(l.debit - l.credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
+ WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
+ ELSE position
+ END AS position,
+ CASE
+ WHEN position IN (1, 4) -- 1 = asset, 4 = expense
+ OR (position = 3 AND SUM(l.debit - l.credit) > 0)
+ THEN
+ SUM(l.debit - l.credit)
+ ELSE
+ SUM(l.credit - l.debit)
+ END AS balance,
+ CASE WHEN SUM(l.debit - l.credit) > 0 THEN 1 ELSE 0 END AS is_debt
+
+ FROM acc_transactions_lines l
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ INNER JOIN acc_accounts a ON a.id = l.id_account
+ %s
+ %s
+ GROUP BY %s
+ )
+ %s
+ %s
+ ORDER BY %s',
+ isset($parts['select']) ? $parts['select'] . ',' : '',
+ // SUM(balance) is important for grouping projects when id is different but code is the same
+ isset($parts['group']) ? 'SUM(balance) AS balance' : 'balance',
+ isset($parts['inner_select']) ? $parts['inner_select'] . ',' : '',
+ $parts['inner_join'] ?? '',
+ isset($parts['inner_where']) ? 'WHERE ' . $parts['inner_where'] : '',
+ // Group by account code when multiple years are concerned
+ $parts['inner_group'] ?? 'a.code, t.id_year',
+ isset($parts['where']) ? 'WHERE ' . $parts['where'] : '',
+ isset($parts['group']) ? 'GROUP BY ' . $parts['group'] : '',
+ $order ?? 'code'
+ );
+ }
+
+ /**
+ * Returns SQL query for accounts balances according to $criterias
+ * @param array $criterias List of criterias, see self::getWhereClause
+ * @param string|null $order Order of rows (SQL clause), if NULL will order by CODE
+ * @param bool $remove_zero Remove accounts where the balance is zero from the list
+ */
+ static protected function getAccountsBalancesInnerSQL(array $criterias, ?string $order = null, bool $remove_zero = true): string
+ {
+ $group = 'code';
+ $having = '';
+
+ if ($remove_zero) {
+ $having = 'HAVING balance != 0';
+ }
+
+ $table = null;
+
+ if (empty($criterias['project'])
+ && empty($criterias['projects_only'])
+ && empty($criterias['user'])
+ && empty($criterias['creator'])
+ && empty($criterias['subscription'])
+ && empty($criterias['before'])
+ && empty($criterias['after'])) {
+ $table = 'acc_accounts_balances';
+ }
+
+ // Specific queries that can't rely on acc_accounts_balances
+ if (!$table)
+ {
+ $where = null;
+
+ // The position
+ if (!empty($criterias['position'])) {
+ $criterias['position'] = (array)$criterias['position'];
+
+ if (in_array(Account::LIABILITY, $criterias['position'])
+ || in_array(Account::ASSET, $criterias['position'])) {
+ $where = self::getWhereClause(['position' => $criterias['position']]);
+ $criterias['position'][] = Account::ASSET_OR_LIABILITY;
+ }
+ }
+
+ $inner_where = self::getWhereClause($criterias, 't', 'l', 'a');
+ $remove_zero = $remove_zero ? ', ' . $remove_zero : '';
+ $inner_group = empty($criterias['year']) ? 'a.code' : null;
+
+ $sql = self::getBalancesSQL(['group' => 'code ' . $having] + compact('order', 'inner_where', 'where', 'inner_group'));
+ }
+ else {
+ $where = self::getWhereClause($criterias);
+
+ $query = 'SELECT id_year, id, label, code, type, SUM(debit) AS debit, SUM(credit) AS credit, position, SUM(balance) AS balance, is_debt FROM %s
+ WHERE %s
+ GROUP BY %s %s
+ ORDER BY %s';
+
+ $sql = sprintf($query, $table, $where, $group, $having, $order);
+ }
+
+ return $sql;
+ }
+
+ /**
+ * Returns accounts balances according to $criterias
+ * @param array $criterias List of criterias, see self::getWhereClause
+ * @param string|null $order Order of rows (SQL clause), if NULL will order by CODE
+ * @param bool $remove_zero Remove accounts where the balance is zero from the list
+ */
+ static public function getAccountsBalances(array $criterias, ?string $order = null, bool $remove_zero = true): array
+ {
+ $db = DB::getInstance();
+ $order = $order ?: 'code COLLATE NOCASE';
+
+ $sql = self::getAccountsBalancesInnerSQL($criterias, $order, $remove_zero);
+
+ // SQLite does not support OUTER JOIN yet :(
+ if (isset($criterias['compare_year'])) {
+ $criterias2 = array_merge($criterias, ['year' => $criterias['compare_year']]);
+ $sql2 = self::getAccountsBalancesInnerSQL($criterias2, $order, true);
+
+ // Create temporary tables to store data, so that the request is not too complex
+ // and doesn't require to do the same SELECTs twice or more
+ $table_name = md5(random_bytes(10));
+ $db->begin();
+ $db->exec(sprintf('
+ CREATE TEMP TABLE acc_compare_a_%1$s (id_year, id, label, code, type, debit, credit, position, balance, is_debt);
+ CREATE TEMP TABLE acc_compare_b_%1$s (id_year, id, label, code, type, debit, credit, position, balance, is_debt);
+ INSERT INTO acc_compare_a_%1$s %2$s;
+ INSERT INTO acc_compare_b_%1$s %3$s;',
+ $table_name, $sql, $sql2));
+ $db->commit();
+
+ // The magic!
+ // Here we are selecting the balances of year A, joining with year B
+ // BUT to show the accounts used in year B but NOT in year A, we need to do this
+ // UNION ALL to select accounts from year B which are NOT in year A
+ $sql_union = 'SELECT a.id, a.code AS code, a.label, a.position, a.type, a.debit, a.credit, a.balance, IFNULL(b.balance, 0) AS balance2, IFNULL(a.balance - b.balance, a.balance) AS change
+ FROM acc_compare_a_%1$s AS a
+ LEFT JOIN acc_compare_b_%1$s AS b ON b.code = a.code AND a.position = b.position AND b.id_year = %2$d
+ UNION ALL
+ -- Select balances of second year accounts that are =zero in first year
+ SELECT
+ NULL AS id, c.code AS code, c.label, c.position, c.type, c.debit, c.credit, 0 AS balance, c.balance AS balance2, c.balance * -1 AS change
+ FROM acc_compare_b_%1$s AS c
+ LEFT JOIN acc_compare_a_%1$s AS d ON d.code = c.code AND d.balance != 0 AND d.position = c.position AND d.id_year = %3$d
+ WHERE d.id IS NULL
+ ORDER BY code COLLATE NOCASE;';
+
+ $sql = sprintf($sql_union, $table_name, $criterias['compare_year'], $criterias['year']);
+ }
+
+ $out = $db->get($sql);
+
+ return $out;
+ }
+
+ static public function getTrialBalance(array $criterias, bool $simple = false): \Iterator
+ {
+ unset($criterias['compare_year']);
+ $out = self::getAccountsBalances($criterias, null, false);
+
+ $sums = [
+ 'debit' => 0,
+ 'credit' => 0,
+ 'balance' => null,
+ 'label' => 'Total',
+ ];
+
+ foreach ($out as $row) {
+ if (!$simple) {
+ $row->balance = $row->debit - $row->credit;
+ }
+
+ $sums['debit'] += $row->debit;
+ $sums['credit'] += $row->credit;
+ yield $row;
+ }
+
+ yield (object) $sums;
+ }
+
+ /**
+ * Return a table line with the year result
+ */
+ static public function getResultLine(array $criterias): \stdClass
+ {
+ $balance = self::getResult($criterias);
+ $balance2 = null;
+ $change = null;
+ $label = $balance > 0 ? 'Résultat de l\'exercice courant (excédent)' : 'Résultat de l\'exercice courant (perte)';
+
+ if (!empty($criterias['compare_year'])) {
+ $balance2 = self::getResult(array_merge($criterias, ['year' => $criterias['compare_year']]));
+ $change = $balance - $balance2;
+ }
+
+ if (!empty($criterias['compare_year']) || $balance == 0) {
+ $label = 'Résultat de l\'exercice';
+ }
+
+ return (object) compact('balance', 'balance2', 'label', 'change');
+ }
+
+ /**
+ * Return a table line with totals
+ */
+ static public function getTotalLine(array $rows, string $label = 'Total'): \stdClass
+ {
+ $balance = 0;
+ $balance2 = 0;
+ $change = 0;
+
+ foreach ($rows as $row) {
+ $balance += $row->balance;
+ $balance2 += $row->balance2 ?? 0;
+ $change += $row->change ?? 0;
+ }
+
+ return (object) compact('label', 'balance', 'balance2', 'change');
+ }
+
+ /**
+ * Statement / Compte de résultat
+ */
+ static public function getStatement(array $criterias): \stdClass
+ {
+ $out = new \stdClass;
+
+ $out->caption_left = 'Charges';
+ $out->caption_right = 'Produits';
+ $total_left = 'Total charges';
+ $total_right = 'Total produits';
+
+ $out->body_left = self::getAccountsBalances($criterias + ['position' => Account::EXPENSE]);
+ $out->body_right = self::getAccountsBalances($criterias + ['position' => Account::REVENUE]);
+
+ $out->foot_left = [self::getTotalLine($out->body_left, $total_left)];
+ $out->foot_right = [self::getTotalLine($out->body_right, $total_right)];
+
+ $r = self::getResultLine($criterias);
+
+ if ($r->balance < 0) {
+ // Deficit should go to expense column
+ $out->foot_left[] = $r;
+ }
+ else {
+ $out->foot_right[] = $r;
+ }
+
+ return $out;
+ }
+
+ static public function getVolunteeringStatement(array $criterias, \stdClass $general_statement): \stdClass
+ {
+ $out = new \stdClass;
+
+ $criterias_all = $criterias + ['type' => [Account::TYPE_VOLUNTEERING_EXPENSE, Account::TYPE_VOLUNTEERING_REVENUE]];
+
+ $out->caption_left = 'Emplois des contributions';
+ $out->caption_right = 'Sources des contributions';
+
+ $out->body_left = self::getAccountsBalances($criterias_all + ['position' => Account::EXPENSE]);
+ $out->body_right = self::getAccountsBalances($criterias_all + ['position' => Account::REVENUE]);
+
+ $out->foot_left = [
+ self::getTotalLine($out->body_left, 'Total emplois'),
+ self::getTotalLine(array_merge($out->body_left, $general_statement->body_left), 'Total charges et emplois'),
+ ];
+ $out->foot_right = [
+ self::getTotalLine($out->body_right, 'Total sources'),
+ self::getTotalLine(array_merge($out->body_right, $general_statement->body_right), 'Total produits et sources'),
+ ];
+
+ return $out;
+ }
+
+ /**
+ * Bilan / Balance sheet
+ */
+ static public function getBalanceSheet(array $criterias): \stdClass
+ {
+ $out = new \stdClass;
+
+ $out->caption_left = 'Actif';
+ $out->caption_right = 'Passif';
+
+ $out->body_left = self::getAccountsBalances($criterias + ['position' => Account::ASSET]);
+ $out->body_right = self::getAccountsBalances($criterias + ['position' => Account::LIABILITY]);
+
+ // Append result to liability
+ $r = self::getResultLine($criterias);
+ $out->body_right[] = $r;
+
+ // Calculate the total sum for assets and liabilities
+ $out->foot_left = [self::getTotalLine($out->body_left, 'Total actif')];
+ $out->foot_right = [self::getTotalLine($out->body_right, 'Total passif')];
+
+ return $out;
+ }
+
+ /**
+ * Return list of favorite accounts (accounts with a type), grouped by type, with their current sum
+ * @return array list of accounts grouped by type
+ */
+ static public function getClosingSumsFavoriteAccounts(array $criterias): array
+ {
+ $types = Account::COMMON_TYPES;
+ $accounts = self::getAccountsBalances($criterias + ['type_or_bookmark' => $types], 'type, code COLLATE NOCASE', false);
+
+ $out = [];
+
+ foreach ($types as $type) {
+ $out[$type] = (object) [
+ 'label' => Account::TYPES_NAMES[$type],
+ 'type' => $type,
+ 'accounts' => [],
+ ];
+ }
+
+ $out[0] = (object) [
+ 'label' => 'Autres',
+ 'type' => 0,
+ 'accounts' => [],
+ ];
+
+ foreach ($accounts as $row) {
+ $t = in_array($row->type, $types, true) ? $row->type : 0;
+ $out[$t]->accounts[] = $row;
+ }
+
+ foreach ($out as $t => $group) {
+ if (!count($group->accounts)) {
+ unset($out[$t]);
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Grand livre
+ */
+ static public function getGeneralLedger(array $criterias, bool $simple = false): \Generator
+ {
+ $where = self::getWhereClause($criterias);
+
+ $db = DB::getInstance();
+
+ if (!empty($criterias['projects_only'])) {
+ $join = 'acc_projects p ON p.id = l.id_project, acc_accounts a ON a.id = l.id_account';
+ $select = '0 AS account_type, p.label AS project_label, p.code AS project_code, p.id AS id_project';
+ $group_key = 'id_project';
+ $group_type = 'project';
+ $order = 'p.code, p.label COLLATE NOCASE, t.date, t.id';
+ }
+ else {
+ $join = 'acc_accounts a ON a.id = l.id_account';
+ $select = 'a.type AS account_type';
+ $group_key = 'id_account';
+ $group_type = 'account';
+ $order = 'a.code COLLATE NOCASE, t.date, t.id';
+ }
+
+ $sql = sprintf('SELECT
+ t.id_year, a.id AS id_account, t.id, t.date, t.reference,
+ l.debit, l.credit, l.reference AS line_reference, t.label, l.label AS line_label,
+ a.label AS account_label, a.code AS account_code, %s
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
+ INNER JOIN %s
+ WHERE %s
+ ORDER BY %s;', $select, $join, $where, $order);
+
+ $group = null;
+ $debit = $credit = 0;
+
+ foreach ($db->iterate($sql) as $row) {
+ if (null !== $group && $group->id != $row->$group_key) {
+ yield $group;
+ $group = null;
+ }
+
+ if (null === $group) {
+ $group = (object) [
+ 'code' => $row->{$group_type . '_code'},
+ 'label' => $row->{$group_type . '_label'},
+ 'id' => $row->$group_key,
+ 'id_year' => $row->id_year,
+ 'sum' => 0,
+ 'debit' => 0,
+ 'credit'=> 0,
+ 'lines' => [],
+ ];
+ }
+
+ $row->date = \DateTime::createFromFormat('Y-m-d', $row->date);
+
+ $sum = $row->debit - $row->credit;
+
+ if (Accounts::isReversed($simple, $row->account_type)) {
+ $sum *= -1;
+ }
+
+ $group->sum += $sum;
+ $group->debit += $row->debit;
+ $group->credit += $row->credit;
+ $debit += $row->debit;
+ $credit += $row->credit;
+ $row->running_sum = $group->sum;
+
+ $group->lines[] = $row;
+ }
+
+ if (null === $group) {
+ return;
+ }
+
+ $group->all_debit = $debit;
+ $group->all_credit = $credit;
+
+ yield $group;
+ }
+
+ static public function getJournal(array $criterias, bool $reverse_order = false): \Generator
+ {
+ $where = self::getWhereClause($criterias, 't', 'l', 'a');
+
+ $sql = sprintf('SELECT
+ t.id_year, l.id_account, l.debit, l.credit, t.id, t.date, t.reference,
+ l.reference AS line_reference, t.label, l.label AS line_label,
+ a.label AS account_label, a.code AS account_code
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
+ INNER JOIN acc_accounts a ON l.id_account = a.id
+ WHERE %s ORDER BY t.date %s, t.id %2$s;', $where, $reverse_order ? 'DESC' : 'ASC');
+
+ $transaction = null;
+ $db = DB::getInstance();
+
+ foreach ($db->iterate($sql) as $row) {
+ if (null !== $transaction && $transaction->id != $row->id) {
+ yield $transaction;
+ $transaction = null;
+ }
+
+ if (null === $transaction) {
+ $transaction = (object) [
+ 'id' => $row->id,
+ 'label' => $row->label,
+ 'date' => \DateTime::createFromFormat('Y-m-d', $row->date),
+ 'reference' => $row->reference,
+ 'lines' => [],
+ ];
+ }
+
+ $transaction->lines[] = (object) [
+ 'account_label' => $row->account_label,
+ 'account_code' => $row->account_code,
+ 'label' => $row->line_label,
+ 'reference' => $row->line_reference,
+ 'id_account' => $row->id_account,
+ 'credit' => $row->credit,
+ 'debit' => $row->debit,
+ 'id_year' => $row->id_year,
+ ];
+ }
+
+ if (null === $transaction) {
+ return;
+ }
+
+ yield $transaction;
+ }
+}
diff --git a/src/include/lib/Paheko/Accounting/Transactions.php b/src/include/lib/Paheko/Accounting/Transactions.php
new file mode 100644
index 0000000..27e745f
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Transactions.php
@@ -0,0 +1,408 @@
+importForm($data);
+ return $transaction;
+ }
+
+ static public function get(int $id)
+ {
+ return EntityManager::findOneById(Transaction::class, $id);
+ }
+
+ static public function saveReconciled(\Generator $journal, ?array $checked)
+ {
+ if (null === $checked) {
+ $checked = [];
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ // Synchro des trucs cochés
+ $st = $db->prepare('UPDATE acc_transactions_lines SET reconciled = :r WHERE id = :id;');
+
+ foreach ($journal as $row)
+ {
+ if (!isset($row->id_line)) {
+ continue;
+ }
+
+ $st->bindValue(':id', (int)$row->id_line, \SQLITE3_INTEGER);
+ $st->bindValue(':r', !empty($checked[$row->id_line]) ? 1 : 0, \SQLITE3_INTEGER);
+ $st->execute();
+ }
+
+ $db->commit();
+ }
+
+ static public function saveDeposit(Transaction $transaction, \Generator $journal, array $checked)
+ {
+ $db = DB::getInstance();
+ $db->begin();
+
+ try {
+ $ids = [];
+ foreach ($journal as $row) {
+ if (!array_key_exists($row->id_line, $checked)) {
+ continue;
+ }
+
+ $ids[] = (int)$row->id;
+
+ $line = new Line;
+ $line->importForm([
+ 'reference' => $row->line_reference,
+ 'label' => $row->line_label ?? $row->label,
+ 'id_account' => $row->id_account,
+ 'id_project' => $row->id_project,
+ ]);
+
+ $line->credit = $row->debit;
+
+ $transaction->addLine($line);
+ }
+
+ $transaction->save();
+ $ids = implode(',', $ids);
+ $db->exec(sprintf('UPDATE acc_transactions SET status = (status | %d) WHERE id IN (%s);', Transaction::STATUS_DEPOSIT, $ids));
+ $db->commit();
+ }
+ catch (\Exception $e) {
+ $db->rollback();
+ throw $e;
+ }
+ }
+
+ static public function countForUser(int $user_id): int
+ {
+ return (int) DB::getInstance()->firstColumn('SELECT COUNT(DISTINCT id_transaction) FROM acc_transactions_users WHERE id_user = ?;', $user_id);
+ }
+
+ static public function countForCreator(int $user_id): int
+ {
+ return DB::getInstance()->count('acc_transactions', 'id_creator = ?', $user_id);
+ }
+
+ /**
+ * Returns a dynamic list of all waiting credit and debt transactions for closed years
+ */
+ static public function listPendingCreditAndDebtForOtherYears(int $current_year_id): DynamicList
+ {
+ $columns = Account::LIST_COLUMNS;
+
+ unset(
+ $columns['line_label'],
+ $columns['sum'],
+ $columns['debit'],
+ $columns['credit'],
+ $columns['project_code'],
+ $columns['id_project'],
+ $columns['line_reference'],
+ $columns['locked'],
+ $columns['files']
+ );
+
+ $columns['change']['select'] = 'SUM(l.credit)';
+ $columns['change']['label'] = 'Montant';
+
+ $columns = [
+ 'year_label' => [
+ 'select' => 'y.label',
+ 'label' => 'Exercice',
+ ],
+ 'type_label' => [
+ 'select' => 't.type',
+ 'label' => 'Type',
+ ]]
+ + $columns;
+
+ $conditions = sprintf('y.id != %d AND t.status & %d AND t.type IN (%d, %d)',
+ $current_year_id,
+ Transaction::STATUS_WAITING,
+ Transaction::TYPE_CREDIT,
+ Transaction::TYPE_DEBT
+ );
+
+ $tables = 'acc_transactions_lines l
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ INNER JOIN acc_years y ON y.id = t.id_year';
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('date', true);
+ $list->setCount('COUNT(DISTINCT t.id)');
+ $list->groupBy('t.id');
+ $list->setModifier(function (&$row) {
+ $row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
+
+ if (isset($row->type_label)) {
+ $row->type_label = Transaction::TYPES_NAMES[(int)$row->type_label];
+ }
+ });
+
+ $list->setExportCallback(function (&$row) {
+ $row->change = Utils::money_format($row->change, '.', '', false);
+ });
+
+ $list->setTitle('Dettes et créances en attente');
+
+ return $list;
+ }
+
+ static public function setProject(?int $id_project, ?array $transactions = null, ?array $lines = null)
+ {
+ $db = DB::getInstance();
+
+ if (null !== $id_project && !$db->test(Project::TABLE, 'id = ?', $id_project)) {
+ throw new \InvalidArgumentException('Invalid project ID');
+ }
+
+ if (isset($transactions, $lines) || ($transactions === null && $lines === null)) {
+ throw new \BadMethodCallException('Only one of transactions or lines should be set');
+ }
+
+ $selection = array_map('intval', $transactions ?? $lines);
+ $where = sprintf($transactions ? 'id_transaction IN (%s)' : 'id IN (%s)', implode(', ', $selection));
+
+ return $db->exec(sprintf('UPDATE acc_transactions_lines SET id_project = %s WHERE %s;',
+ (int)$id_project ?: 'NULL', $where));
+ }
+
+ static public function listByType(int $year_id, ?int $type): DynamicList
+ {
+ $reverse = 1;
+
+ $columns = Account::LIST_COLUMNS;
+
+ unset(
+ $columns['line_label'],
+ $columns['sum'],
+ $columns['debit'],
+ $columns['credit']
+ );
+
+ $db = DB::getInstance();
+
+ // Don't show locked column if no transactions are locked
+ if (!$db->test('acc_transactions', 'hash IS NOT NULL')) {
+ unset($columns['locked']);
+ }
+
+ $columns['line_reference']['label'] = 'Réf. paiement';
+ $columns['change']['select'] = sprintf('SUM(l.credit) * %d', $reverse);
+ $columns['change']['label'] = 'Montant';
+ $columns['project_code']['select'] = 'GROUP_CONCAT(IFNULL(b.code, SUBSTR(b.label, 1, 10) || \'…\'), \',\')';
+ $columns['id_project']['select'] = 'GROUP_CONCAT(l.id_project, \',\')';
+
+ if ($type == Transaction::TYPE_CREDIT || $type == Transaction::TYPE_DEBT) {
+
+ $columns['status_label'] = [
+ 'label' => 'Statut',
+ 'select' => sprintf('CASE WHEN t.status & %d THEN %s WHEN t.status & %d THEN %s ELSE NULL END',
+ Transaction::STATUS_WAITING, $db->quote('En attente'),
+ Transaction::STATUS_PAID, $db->quote('Réglée')
+ ),
+ ];
+ }
+
+ if (!$type) {
+ $columns = ['type_label' => [
+ 'select' => 't.type',
+ 'label' => 'Type d\'écriture',
+ ]]
+ + $columns;
+ }
+
+ $tables = 'acc_transactions_lines l
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ INNER JOIN acc_accounts a ON a.id = l.id_account
+ LEFT JOIN acc_projects b ON b.id = l.id_project';
+ $conditions = sprintf('t.id_year = %d', $year_id);
+
+ if (null !== $type) {
+ $conditions .= sprintf(' AND t.type = %s', $type);
+ }
+
+ $sum = 0;
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('date', true);
+ $list->setCount('COUNT(t.id)');
+ $list->setCountTables('acc_transactions t');
+ $list->groupBy('t.id');
+ $list->setModifier(function (&$row) {
+ $row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
+
+ if (isset($row->id_project, $row->project_code)) {
+ $row->project_code = array_combine(explode(',', $row->id_project), explode(',', $row->project_code));
+ }
+ else {
+ $row->project_code = [];
+ }
+
+ if (isset($row->type_label)) {
+ $row->type_label = Transaction::TYPES_NAMES[(int)$row->type_label];
+ }
+ });
+ $list->setExportCallback(function (&$row) {
+ $row->change = Utils::money_format($row->change, '.', '', false);
+ $row->project_code = implode(', ', $row->project_code);
+ unset($row->id_project);
+ });
+
+ return $list;
+ }
+
+ static public function quickSearch(string $query, ?int $id_year = null)
+ {
+ $params = [];
+ $db = DB::getInstance();
+
+ if (ctype_digit($query)) {
+ $conditions = 'id = ?';
+ $params[] = (int) $query;
+ }
+ else {
+ $query = '%' . $db->escapeLike($query, '!') . '%';
+ $conditions = 'label LIKE ? ESCAPE \'!\' COLLATE U_NOCASE OR reference LIKE ? ESCAPE \'!\' COLLATE U_NOCASE';
+ $params = [$query, $query];
+ }
+
+ if ($id_year) {
+ $conditions .= ' AND id_year = ?';
+ $params[] = $id_year;
+ }
+
+ $sql = sprintf('SELECT id, label, reference, id_year FROM acc_transactions WHERE %s ORDER BY id DESC;', $conditions);
+ return DB::getInstance()->iterate($sql, ...$params);
+ }
+
+ static public function createPayoffFrom(array $transactions): ?\stdClass
+ {
+ $new = new Transaction;
+
+ $out = (object) [
+ 'type' => null,
+ 'amount' => 0,
+ 'multiple' => null,
+ 'transactions' => [],
+ 'transaction' => $new,
+ 'linked_users' => [],
+ 'linked_transactions' => [],
+ 'type_label' => null,
+ 'targets' => implode(':', [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]),
+ 'id_project' => null,
+ 'payment_line' => null,
+ ];
+
+ $labels = [];
+
+ foreach ($transactions as $id) {
+ $id = (int) $id;
+ $t = Transactions::get($id);
+
+ if (!$t) {
+ throw new UserException('Écriture inconnue : ' . $id);
+ }
+
+ if (!$t->hasStatus(Transaction::STATUS_WAITING)) {
+ continue;
+ }
+
+ if ($t->type !== Transaction::TYPE_CREDIT && $t->type !== Transaction::TYPE_DEBT) {
+ continue;
+ }
+
+ $out->transactions[$t->id()] = $t;
+
+ if ($out->multiple === null) {
+ $out->multiple = false;
+ }
+ elseif ($out->multiple === false) {
+ $out->multiple = true;
+ }
+
+ if ($out->type === null) {
+ $out->type = $t->type;
+ }
+ elseif ($out->type !== $t->type) {
+ throw new UserException('Il n\'est pas possible de régler à la fois des créances et des dettes');
+ }
+
+ $id_project = $t->getProjectId();
+ $out->id_project = $id_project;
+
+ $sum = $t->sum();
+ $out->amount += $sum;
+ $out->linked_transactions[] = $t->id;
+
+ foreach ($t->listLinkedUsersAssoc() as $id => $name) {
+ $out->linked_users[$id] = $name;
+ }
+
+ $labels[] = $t->label;
+
+ $line = new Line;
+ $line->label = $t->label;
+ $line->reference = $t->getPaymentReference();
+ $line->id_project = $id_project;
+
+ if ($out->type === Transaction::TYPE_CREDIT) {
+ $line->credit = $sum;
+ $line->id_account = $t->getDebitLine()->id_account;
+ }
+ else {
+ $line->debit = $sum;
+ $line->id_account = $t->getCreditLine()->id_account;
+ }
+
+ $new->addLine($line);
+ }
+
+ if (!count($out->transactions)) {
+ throw new UserException('Aucune des écritures sélectionnées n\'est en attente de paiement.');
+ }
+
+ $line = new Line;
+ $line->label = 'Règlement';
+
+ if ($out->type === Transaction::TYPE_CREDIT) {
+ $line->debit = $out->amount;
+ }
+ else {
+ $line->credit = $out->amount;
+ }
+
+ $new->addLine($line);
+ $out->payment_line = $line;
+
+ if ($out->type === Transaction::TYPE_DEBT) {
+ $out->type_label = 'Règlement de dette';
+ }
+ else {
+ $out->type_label = 'Règlement de créance';
+ }
+
+ $new->label = $out->type_label . ' — ' . implode(', ', $labels);
+ $new->type = 99;
+
+ return $out;
+ }
+}
diff --git a/src/include/lib/Paheko/Accounting/Years.php b/src/include/lib/Paheko/Accounting/Years.php
new file mode 100644
index 0000000..d702f32
--- /dev/null
+++ b/src/include/lib/Paheko/Accounting/Years.php
@@ -0,0 +1,211 @@
+col('SELECT id FROM @TABLE WHERE closed = 0 ORDER BY start_date LIMIT 1;');
+ }
+
+ static public function getMatchingOpenYearId(?\DateTimeInterface $date = null)
+ {
+ if (null === $date) {
+ return self::getCurrentOpenYearId();
+ }
+
+ return EntityManager::getInstance(Year::class)->col('SELECT id FROM @TABLE WHERE closed = 0 AND start_date <= ? AND end_date >= ? ORDER BY start_date LIMIT 1;', $date, $date);
+ }
+
+ static public function listOpen($with_stats = false)
+ {
+ $db = EntityManager::getInstance(Year::class)->DB();
+ $stats = $with_stats ? ', (SELECT COUNT(*) FROM acc_transactions WHERE id_year = acc_years.id) AS nb_transactions' : '';
+ return $db->getGrouped(sprintf('SELECT id, * %s FROM acc_years WHERE closed = 0 ORDER BY end_date;', $stats));
+ }
+
+ static public function listOpenAssocExcept(int $id)
+ {
+ $db = EntityManager::getInstance(Year::class)->DB();
+ return $db->getAssoc('SELECT id, label FROM acc_years WHERE closed = 0 AND id != ? ORDER BY end_date;', $id);
+ }
+
+ static public function listOpenAssoc()
+ {
+ $db = EntityManager::getInstance(Year::class)->DB();
+ return $db->getAssoc('SELECT id, label FROM acc_years WHERE closed = 0 ORDER BY end_date DESC;');
+ }
+
+ static public function listAssoc()
+ {
+ return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years ORDER BY end_date;');
+ }
+
+ static public function listAssocExcept(int $id)
+ {
+ return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years WHERE id != ? ORDER BY end_date;', $id);
+ }
+
+ static public function listClosedAssoc()
+ {
+ return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years WHERE closed = 1 ORDER BY end_date;');
+ }
+
+ static public function listClosedAssocExcept(int $id)
+ {
+ return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years WHERE closed = 1 AND id != ? ORDER BY end_date DESC;', $id);
+ }
+
+ static public function listClosed()
+ {
+ $em = EntityManager::getInstance(Year::class);
+ return $em->all('SELECT * FROM @TABLE WHERE closed = 1 ORDER BY end_date;');
+ }
+
+ static public function countClosed()
+ {
+ return DB::getInstance()->count(Year::TABLE, 'closed = 1');
+ }
+
+ static public function count()
+ {
+ return DB::getInstance()->count(Year::TABLE);
+ }
+
+ static public function list(bool $reverse = false, ?int $except_id = null)
+ {
+ $desc = $reverse ? 'DESC' : '';
+ $except = $except_id ? ' AND y.id != ' . (int)$except_id : '';
+ $sql = sprintf('SELECT y.*,
+ (SELECT COUNT(*) FROM acc_transactions WHERE id_year = y.id) AS nb_transactions,
+ c.label AS chart_name
+ FROM acc_years y
+ INNER JOIN acc_charts c ON c.id = y.id_chart
+ WHERE 1 %s
+ ORDER BY end_date %s;', $except, $desc);
+ return DB::getInstance()->get($sql);
+ }
+
+ static public function listLastTransactions(int $count, array $years): array
+ {
+ $out = [];
+
+ foreach ($years as $year) {
+ $out[$year->id] = Transactions::listByType($year->id, null);
+ $out[$year->id]->setPageSize($count);
+ $out[$year->id]->orderBy('id', true);
+ }
+
+ return $out;
+ }
+
+ static public function getNewYearDates(): array
+ {
+ $last_year = EntityManager::findOne(Year::class, 'SELECT * FROM @TABLE ORDER BY end_date DESC LIMIT 1;');
+
+ if ($last_year) {
+ $start_date = clone $last_year->start_date;
+ $start_date->modify('+1 year');
+
+ $end_date = clone $last_year->end_date;
+ $end_date->modify('+1 year');
+ }
+ else {
+ $start_date = new Date('January 1st');
+ $end_date = new Date('December 31');
+ }
+
+ return [$start_date, $end_date];
+ }
+
+ /**
+ * Crée une écriture d'affectation automatique
+ * @param Year $year
+ * @return Transaction|null
+ */
+ static public function makeAppropriation(Year $year): ?Transaction
+ {
+ $db = DB::getInstance();
+ $balances = $db->getGrouped('SELECT a.type, a.id, SUM(l.credit) - SUM(l.debit) AS balance
+ FROM acc_accounts a
+ INNER JOIN acc_transactions_lines l ON l.id_account = a.id
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ WHERE t.id_year = ? AND (a.type = ? OR a.type = ?) GROUP BY a.type;',
+ $year->id, Account::TYPE_NEGATIVE_RESULT, Account::TYPE_POSITIVE_RESULT
+ );
+
+ if (!count($balances)) {
+ return null;
+ }
+
+ $appropriation_account = $db->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;',
+ Account::TYPE_APPROPRIATION_RESULT, $year->id_chart);
+
+ if (!$appropriation_account) {
+ return null;
+ }
+
+ $t = new Transaction;
+ $t->type = $t::TYPE_ADVANCED;
+ $t->id_year = $year->id();
+ $t->label = 'Affectation automatique du résultat';
+ $t->notes = 'Le résultat a été affecté automatiquement lors de la balance d\'ouverture';
+ $t->date = $year->start_date;
+ $t->addStatus($t::STATUS_OPENING_BALANCE);
+
+ $sum = 0;
+
+ if (!empty($balances[Account::TYPE_NEGATIVE_RESULT])) {
+ $account = $balances[Account::TYPE_NEGATIVE_RESULT];
+
+ $line = Line::create($account->id, abs($account->balance), 0);
+ $t->addLine($line);
+
+ $sum += abs($account->balance);
+ }
+
+ if (!empty($balances[Account::TYPE_POSITIVE_RESULT])) {
+ $account = $balances[Account::TYPE_POSITIVE_RESULT];
+
+ $line = Line::create($account->id, 0, abs($account->balance));
+ $t->addLine($line);
+
+ $sum -= abs($account->balance);
+ }
+
+ if ($sum == 0) {
+ return null;
+ }
+
+ if ($sum > 0) {
+ $line = Line::create($appropriation_account, 0, $sum);
+ }
+ elseif ($sum < 0) {
+ $line = Line::create($appropriation_account, abs($sum), 0);
+ }
+
+ $t->addLine($line);
+
+ return $t;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/AdvancedSearch.php b/src/include/lib/Paheko/AdvancedSearch.php
new file mode 100644
index 0000000..dd48050
--- /dev/null
+++ b/src/include/lib/Paheko/AdvancedSearch.php
@@ -0,0 +1,251 @@
+groups) || !is_array($query->groups)) {
+ throw new \InvalidArgumentException('Invalid JSON search object: missing groups');
+ }
+
+ $conditions = $this->build($query->groups);
+ array_unshift($conditions->select, $default_order); // Always include default order
+
+ foreach ($mandatory_columns as $c) {
+ if (!in_array($c, $conditions->select)) {
+ array_unshift($conditions->select, $c); // Always include
+ }
+ }
+
+ // Only select columns that we want
+ $select_columns = array_intersect_key($this->columns(), array_flip($conditions->select));
+
+ $order = $query->order ?? $default_order;
+
+ if (!in_array($order, $select_columns)) {
+ $order = $default_order;
+ }
+
+ DB::getInstance()->toggleUnicodeLike(true);
+
+ $list = new DynamicList($select_columns, $tables, $conditions->where);
+
+ $list->orderBy($order, $query->desc ?? $default_desc);
+ $list->setTitle('Recherche');
+ return $list;
+ }
+
+ /**
+ * Redirects to a URL if only one result is found for a simple search
+ */
+ public function redirect(DynamicList $list): void
+ {
+ if ($list->count() != 1) {
+ return;
+ }
+
+ $item = $list->iterate()->current();
+ Utils::redirect($item->id);
+ }
+
+ public function build(array $groups): \stdClass
+ {
+ $db = DB::getInstance();
+ $columns = $this->columns();
+
+ $select_columns = [];
+ $query_columns = [];
+ $query_groups = '';
+ $invalid = 0;
+
+ foreach ($groups as $group)
+ {
+ if (empty($group['conditions'])
+ || empty($group['operator'])
+ || !is_array($group['conditions'])
+ || !($group['operator'] === 'AND' || $group['operator'] === 'OR'))
+ {
+ // Ignorer les groupes de conditions invalides
+ continue;
+ }
+
+ if (isset($group['join_operator']) && $group['join_operator'] !== 'AND' && $group['join_operator'] !== 'OR') {
+ continue;
+ }
+
+ $query_group_conditions = [];
+
+ foreach ($group['conditions'] as $condition)
+ {
+ if (!isset($condition['column'], $condition['operator'])
+ || (isset($condition['values']) && !is_array($condition['values'])))
+ {
+ // Ignorer les conditions invalides
+ continue;
+ }
+
+ if (!array_key_exists($condition['column'], $columns))
+ {
+ // Ignorer une condition qui se rapporte à une colonne
+ // qui n'existe pas, cas possible si on reprend une recherche
+ // après avoir modifié les fiches de membres
+ $invalid++;
+ continue;
+ }
+
+ $select_columns[] = $condition['column'];
+
+ // Just append the column to the select
+ if ($condition['operator'] == '1') {
+ continue;
+ }
+
+ $query_columns[] = $condition['column'];
+ $column = $columns[$condition['column']];
+
+ if (isset($column['where'])) {
+ $query = sprintf($column['where'], $condition['operator']);
+ }
+ else {
+ $name = $column['select'] ?? $condition['column'];
+ $query = sprintf('%s %s', $name, $condition['operator']);
+ }
+
+ $values = isset($condition['values']) ? $condition['values'] : [];
+
+ if (!empty($column['normalize'])) {
+ if ($column['normalize'] == 'tel') {
+ // Normaliser le numéro de téléphone
+ $values = array_map(['Paheko\Utils', 'normalizePhoneNumber'], $values);
+ }
+ elseif ($column['normalize'] == 'money') {
+ $values = array_map(['Paheko\Utils', 'moneyToInteger'], $values);
+ }
+ }
+ elseif ($column['type'] === 'integer') {
+ $values = array_map('intval', $values);
+ }
+
+ // L'opérateur binaire est un peu spécial
+ if ($condition['operator'] === '&' || $condition['operator'] === 'NOT &')
+ {
+ if (empty($values)) {
+ throw new UserException('Aucun choix n\'a été sélectionné');
+ }
+
+ $new_query = [];
+
+ $query = str_replace('NOT ', '', $query);
+
+ foreach ($values as $value)
+ {
+ $new_query[] = sprintf('%s (1 << %d)', $query, (int) $value);
+ }
+
+ $query = '(' . implode(' AND ', $new_query) . ')';
+
+ if ($condition['operator'] === 'NOT &') {
+ $query = 'NOT ' . $query;
+ }
+ }
+ // Remplacement de liste
+ elseif (strpos($query, '??') !== false)
+ {
+ $values = array_map([$db, 'quote'], $values);
+ $query = str_replace('??', implode(', ', $values), $query);
+ }
+ // Remplacement de recherche LIKE
+ elseif (preg_match('/%\?%|%\?|\?%/', $query, $match))
+ {
+ $value = str_replace(['%', '_'], ['\\%', '\\_'], reset($values));
+ $value = str_replace('?', $value, $match[0]);
+ $query = str_replace($match[0], sprintf('%s ESCAPE \'\\\'', $db->quote($value)), $query);
+ }
+ // Remplacement de paramètre
+ elseif (strpos($query, '?') !== false)
+ {
+ $expected = substr_count($query, '?');
+ $found = count($values);
+
+ if ($expected != $found)
+ {
+ throw new \RuntimeException(sprintf('Operator %s expects at least %d parameters, only %d supplied', $condition['operator'], $expected, $found));
+ }
+
+ for ($i = 0; $i < $expected; $i++) {
+ $pos = strpos($query, '?');
+ $query = substr_replace($query, $db->quote(array_shift($values)), $pos, 1);
+ }
+ }
+
+ $query_group_conditions[] = $query;
+ }
+
+ if (count($query_group_conditions))
+ {
+ if ($query_groups !== '') {
+ $query_groups .= ' ' . ($group['join_operator'] ?? 'AND') . ' ';
+ }
+
+ $query_groups.= '(' . implode(' ' . $group['operator'] . ' ', $query_group_conditions) . ')';
+ }
+ }
+
+ if (!strlen($query_groups) && count($groups) && $invalid) {
+ throw new UserException('Cette recherche faisait référence à des champs qui n\'existent plus.' . "\n" . 'Elle ne comporte aucun critère valide. Il vaudrait mieux la supprimer.');
+ }
+
+ return (object) [
+ 'select' => $select_columns,
+ 'where' => $query_groups ?: '1',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Backup.php b/src/include/lib/Paheko/Backup.php
new file mode 100644
index 0000000..0ece185
--- /dev/null
+++ b/src/include/lib/Paheko/Backup.php
@@ -0,0 +1,535 @@
+read())
+ {
+ // Keep only backup files
+ if ($file[0] == '.' || !is_file(DATA_ROOT . '/' . $file)
+ || !preg_match('![\w\d._-]+\.' . $ext . '$!i', $file) && $file != basename(DB_FILE)) {
+ continue;
+ }
+
+ if ($file == basename(DB_FILE)) {
+ continue;
+ }
+
+ $name = preg_replace('/^association\.(.*)\.sqlite$/', '$1', $file);
+ $auto = null;
+
+ if (substr($name, 0, 5) == 'auto.') {
+ $auto = (int) substr($name, 5);
+ $name = sprintf('Automatique n°%d', $auto);
+ }
+ elseif (0 === strpos($name, 'pre-upgrade-')) {
+ $name = sprintf('Avant mise à jour %s', substr($name, strlen('pre-upgrade-')));
+ }
+ elseif (preg_match('/^\d{4}-/', $name)) {
+ $name = 'Sauvegarde manuelle';
+ }
+ else {
+ $name = str_replace('.sqlite', '', $file);
+ }
+
+ // Skip non-auto files
+ if ($auto_only && !$auto) {
+ continue;
+ }
+
+ $error = null;
+ $version = null;
+ $db = null;
+
+ try {
+ $db = new \SQLite3(DATA_ROOT . '/' . $file, \SQLITE3_OPEN_READONLY);
+ $version = DB::getVersion($db);
+ $db->close();
+ }
+ catch (\Exception $e) {
+ $error = $db ? $db->lastErrorMsg() : $e->getMessage();
+ }
+
+ $out[$file] = (object) [
+ 'filename' => $file,
+ 'date' => filemtime(DATA_ROOT . '/' . $file),
+ 'name' => $name != $file ? $name : null,
+ 'version' => $version,
+ 'can_restore' => $version ? version_compare($version, Upgrade::MIN_REQUIRED_VERSION, '>=') : false,
+ 'auto' => $auto,
+ 'size' => filesize(DATA_ROOT . '/' . $file),
+ 'error' => $error,
+ ];
+ }
+
+ $dir->close();
+
+ // Reverse date order
+ uasort($out, function ($a, $b) {
+ return $a->date > $b->date ? -1 : 1;
+ });
+
+ return $out;
+ }
+
+ /**
+ * Create a new backup
+ * @param boolean $auto If TRUE, the file name will be based on automatic backups,
+ * if FALSE a file name containing the date will be used (manual backup).
+ * @return string Backup file name
+ */
+ static public function create(bool $auto = false, ?string $name = null): string
+ {
+ $suffix = $name ?? ($auto ? 'auto.1' : date('Y-m-d-His'));
+
+ $backup = str_replace('.sqlite', sprintf('.%s.sqlite', $suffix), DB_FILE);
+
+ self::make($backup);
+
+ return basename($backup);
+ }
+
+ /**
+ * Actually create a backup
+ */
+ static public function make(string $dest): void
+ {
+ // Acquire lock
+ $version = \SQLite3::version();
+ $db = DB::getInstance();
+
+ Utils::safe_unlink($dest);
+
+ if ($version['versionNumber'] >= 3027000) {
+ // use VACUUM INTO instead when SQLite 3.27+ is required
+ $db->exec(sprintf('VACUUM INTO %s;', $db->quote($dest)));
+ }
+ else {
+ // use ::backup since PHP 7.4.0+
+ // https://www.php.net/manual/en/sqlite3.backup.php
+ $dest_db = new \SQLite3($dest);
+ $dest_db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']);
+
+ $db->backup($dest_db);
+ $dest_db->exec('PRAGMA journal_mode = DELETE;');
+ $dest_db->exec('VACUUM;');
+ $db->close();
+ }
+ }
+
+ /**
+ * Rotate automatic backups
+ * association.auto.2.sqlite -> association.auto.3.sqlite
+ * association.auto.1.sqlite -> association.auto.2.sqlite
+ * etc.
+ */
+ static public function rotate(): void
+ {
+ $config = Config::getInstance();
+ $nb = $config->get('backup_limit');
+
+ $list = self::list(true);
+
+ // Sort backups from oldest to newest
+ usort($list, function ($a, $b) {
+ return $a->auto > $b->auto ? -1 : 1;
+ });
+
+ // Delete oldest backups + 1 as we are about to create a new one
+ $delete = count($list) - ($nb - 1);
+
+ for ($i = 0; $i < $delete; $i++) {
+ $backup = array_shift($list);
+ self::remove($backup->filename);
+ }
+
+ $i = count($list) + 1;
+
+ // Rotate old backups
+ foreach ($list as $file) {
+ $old = DATA_ROOT . DIRECTORY_SEPARATOR . $file->filename;
+ $new = sprintf('%s/association.auto.%d.sqlite', DATA_ROOT, $i--);
+
+ if ($old !== $new) {
+ rename($old, $new);
+ }
+ }
+ }
+
+ /**
+ * Create a new automatic backup, if required
+ */
+ static public function auto(): void
+ {
+ $config = Config::getInstance();
+
+ // Pas besoin d'aller plus loin si on ne fait pas de sauvegarde auto
+ if ($config->get('backup_frequency') == 0 || $config->get('backup_limit') == 0) {
+ return;
+ }
+
+ $list = self::list(true);
+
+ if (count($list)) {
+ $last = current($list)->date;
+ }
+ else {
+ $last = false;
+ }
+
+ // Test de la date de création de la dernière sauvegarde
+ if ($last >= (time() - ($config->get('backup_frequency') * 3600 * 24))) {
+ return;
+ }
+
+ // Si pas de modif depuis la dernière sauvegarde, ça sert à rien d'en faire
+ if ($last >= filemtime(DB_FILE)) {
+ return;
+ }
+
+ self::rotate();
+ self::create(true);
+ }
+
+ /**
+ * Delete a local backup
+ */
+ static public function remove(string $file): void
+ {
+ if (preg_match('!\.\.+!', $file)
+ || !preg_match('!^[\w\d._-]+\.sqlite$!i', $file)
+ || $file == basename(DB_FILE)) {
+ throw new UserException('Nom de fichier non valide.');
+ }
+
+ Utils::safe_unlink(DATA_ROOT . '/' . $file);
+ }
+
+ /**
+ * Download a backup file in the browser. If $file is NULL, then the current database will be dumped.
+ */
+ static public function dump(?string $file = null): void
+ {
+ $config = Config::getInstance();
+ $tmp_file = null;
+
+ if (null === $file) {
+ $file = DB_FILE;
+ $name = sprintf('%s - Sauvegarde données - %s.sqlite', $config->get('org_name'), date('Y-m-d'));
+
+ $tmp_file = tempnam(sys_get_temp_dir(), 'gdin');
+ self::make($tmp_file);
+
+ $file = $tmp_file;
+ }
+ else {
+ if (preg_match('!\.\.+!', $file) || !preg_match('!^[\w\d._ -]+\.sqlite$!iu', $file)) {
+ throw new UserException('Nom de fichier non valide.');
+ }
+
+ $name = sprintf('%s - %s', $config->get('org_name'), str_replace('association.', '', $file));
+ $file = DATA_ROOT . '/' . $file;
+
+ if (!file_exists($file)) {
+ throw new UserException('Le fichier fourni n\'existe pas.');
+ }
+ }
+
+ $hash_length = strlen(sha1(''));
+
+ header('Content-type: application/octet-stream');
+ header(sprintf('Content-Disposition: attachment; filename="%s"', $name));
+ header(sprintf('Content-Length: %d', filesize($file) + $hash_length));
+
+ readfile($file);
+
+ // Append integrity hash
+ echo sha1_file($file);
+
+ if (null !== $tmp_file) {
+ @unlink($tmp_file);
+ }
+ }
+
+ /**
+ * Restore from a local backup
+ */
+ static public function restoreFromLocal(string $file, ?Session $session): int
+ {
+ if (preg_match('!\.\.+!', $file) || !preg_match('!^[\w\d._ -]+\.sqlite$!iu', $file)) {
+ throw new UserException('Nom de fichier non valide.');
+ }
+
+ if (!file_exists(DATA_ROOT . '/' . $file)) {
+ throw new UserException('Le fichier fourni n\'existe pas.');
+ }
+
+ return self::restoreDB(DATA_ROOT . '/' . $file, $session, false);
+ }
+
+ /**
+ * Restore from an uploaded file
+ * @param array $file Array provided by $_FILES
+ * @param Session $session
+ * @param boolean $check_integrity Validate checksum before restore
+ * @return int
+ */
+ static public function restoreFromUpload(array $file, ?Session $session, bool $check_integrity = true): int
+ {
+ if (empty($file['size']) || empty($file['tmp_name']) || !empty($file['error'])) {
+ throw new UserException('Le fichier n\'a pas été correctement envoyé. Essayer de le renvoyer à nouveau.');
+ }
+
+ if ($check_integrity) {
+ $integrity = self::checkIntegrity($file['tmp_name']);
+
+ if ($integrity === null) {
+ throw new UserException('Le fichier fourni n\'est pas une base de donnée SQLite3.', self::NOT_A_DB);
+ }
+ elseif ($integrity === false) {
+ throw new UserException('Le fichier fourni a été modifié par un programme externe.', self::INTEGRITY_FAIL);
+ }
+ }
+
+ $r = self::restoreDB($file['tmp_name'], $session, true);
+
+ if ($r) {
+ Utils::safe_unlink($file['tmp_name']);
+ }
+
+ return $r;
+ }
+
+ /**
+ * Verify if a file is a valid SQLite3 backup and its contents match the appended SHA1 hash
+ * @return null|bool NULL if file is not a SQLite3 database. FALSE if the hash does not match.
+ */
+ static protected function checkIntegrity(string $file_path, bool $remove_hash = true): ?bool
+ {
+ $size = filesize($file_path);
+ $fp = fopen($file_path, 'r+');
+
+ $header = fread($fp, 16);
+
+ // Vérifie que le fichier est bien une base SQLite3
+ if ($header !== "SQLite format 3\000") {
+ fclose($fp);
+ return null;
+ }
+
+ fseek($fp, -40, SEEK_END);
+
+ $hash = fread($fp, 40);
+
+ // Ne ressemble pas à un hash sha1
+ if (!preg_match('/[a-f0-9]{40}/', $hash)) {
+ fclose($fp);
+ return false;
+ }
+
+ $max = $size - 40;
+
+ // Suppression du hash
+ if ($remove_hash) {
+ ftruncate($fp, $max);
+ }
+
+ fclose($fp);
+
+ $file_hash = sha1_file($file_path);
+
+ // Vérification du hash
+ return ($file_hash === $hash);
+ }
+
+ /**
+ * Restore a database
+ * @param string $file Absolute path
+ * @param int $logged_user_id
+ * @param bool $check_foreign_keys
+ * @return integer:
+ * - 1 if everything is OK
+ * - & self::NEED_UPGRADE if database version is older and requires an upgrade
+ * - & self::NOT_AN_ADMIN if in the restored database the logged user ID passed is not a config admin
+ * - & self::
+ */
+ static protected function restoreDB(string $file, ?Session $session, bool $check_foreign_keys = false): int
+ {
+ $return = 1;
+
+ // First try to open database
+ try {
+ $db = new \SQLite3($file, \SQLITE3_OPEN_READONLY);
+ }
+ catch (\Exception $e) {
+ throw new UserException('Le fichier fourni n\'est pas une base de données valide. ' .
+ 'Message d\'erreur de SQLite : ' . $e->getMessage(), self::NOT_A_DB);
+ }
+
+ DB::registerCustomFunctions($db);
+
+ try {
+ // Now let's check integrity
+ $check = $db->querySingle('PRAGMA integrity_check;', false);
+ }
+ catch (\Exception $e) {
+ // SQLite can throw an error like: "file is encrypted or is not a db"
+ throw new UserException('Le fichier fourni n\'est pas une base de données valide. ' .
+ 'Message d\'erreur de SQLite : ' . $e->getMessage(), self::NOT_A_DB);
+ }
+
+ if (strtolower(trim($check)) != 'ok') {
+ throw new UserException('Le fichier fourni est corrompu. Erreur SQLite : ' . $check);
+ }
+
+ if ($check_foreign_keys) {
+ $check = $db->querySingle('PRAGMA foreign_key_check;');
+
+ if ($check) {
+ throw new UserException('Le fichier fourni est corrompu. Certaines clés étrangères référencent des lignes qui n\'existent pas.');
+ }
+ }
+
+ // We can't really check if the schema is exactly the one we are expecting
+ // as we allow to restore from old versions, and that would mean storing
+ // all possible old schemas. But we can still see if it looks like a schema
+ // coming from Paheko by looking for the config table.
+ $table = $db->querySingle('SELECT 1 FROM sqlite_master WHERE type=\'table\' AND tbl_name=\'config\';');
+
+ if (!$table) {
+ throw new UserException('Le fichier fourni ne semble pas contenir de données liées à Paheko.');
+ }
+
+ $version = DB::getVersion($db);
+
+ // We can't possibly handle any old version
+ if (version_compare($version, Upgrade::MIN_REQUIRED_VERSION, '<')) {
+ throw new UserException(sprintf('Ce fichier a été créé avec une version trop ancienne (%s), il n\'est pas possible de le restaurer.', $version));
+ }
+
+ // Check for AppID
+ $appid = $db->querySingle('PRAGMA application_id;', false);
+
+ if ($appid !== DB::APPID) {
+ throw new UserException('Ce fichier n\'est pas une sauvegarde Paheko (application_id ne correspond pas).', self::NO_APP_ID);
+ }
+
+ // Try to handle case where the admin performing the restore is no longer an admin in the restored database
+ if ($session && $session->isLogged(true)) {
+ if (version_compare($version, '1.3.0-alpha1', '<')) { // FIXME remove in 1.4
+ $sql = 'SELECT 1 FROM users_categories WHERE id = (SELECT id_category FROM membres WHERE id = %d) AND perm_connect >= %d AND perm_config >= %d';
+ }
+ else {
+ $sql = 'SELECT 1 FROM users_categories WHERE id = (SELECT id_category FROM users WHERE id = %d) AND perm_connect >= %d AND perm_config >= %d';
+ }
+
+ $sql = sprintf($sql, $session->getUser()->id, Session::ACCESS_READ, Session::ACCESS_ADMIN);
+ $is_still_admin = $db->querySingle($sql);
+
+ if (!$is_still_admin) {
+ $return |= self::NOT_AN_ADMIN;
+ }
+ }
+
+ $db->close();
+
+ $backup = str_replace('.sqlite', date('.Y-m-d-His') . '.avant_restauration.sqlite', DB_FILE);
+
+ DB::getInstance()->close();
+
+ if (!rename(DB_FILE, $backup)) {
+ throw new \RuntimeException('Unable to backup current DB file.');
+ }
+
+ if (!copy($file, DB_FILE)) {
+ rename($backup, DB_FILE);
+ throw new \RuntimeException('Unable to copy backup DB to main location.');
+ }
+
+ unlink($backup);
+
+ // Force all categories to be able to manage users
+ if (($return & self::NOT_AN_ADMIN) && version_compare($version, '1.1.0', '>=')) {
+ $db = DB::getInstance();
+ $db->exec(sprintf('UPDATE users_categories SET perm_config = %d, perm_connect = %d;', Session::ACCESS_ADMIN, Session::ACCESS_READ));
+ }
+
+ // Force user to be re-logged as the first admin
+ if ($session && version_compare($version, '1.3.0', '>=') && $session->isLogged(true)) {
+ $return |= self::CHANGED_USER;
+ }
+
+ if ($version != paheko_version()) {
+ $return |= self::NEED_UPGRADE;
+ }
+ else {
+ // Check and upgrade plugins, if a software upgrade is necessary, plugins will be upgraded after the upgrade
+ Plugins::upgradeAllIfRequired();
+
+ // Re-sync files cache with storage, if necessary
+ Storage::sync();
+ }
+
+ return $return;
+ }
+
+ /**
+ * returns current database size in bytes
+ */
+ static public function getDBSize(bool $signed = false): int
+ {
+ clearstatcache(true, DB_FILE);
+ return filesize(DB_FILE) + ($signed ? 40 : 0);
+ }
+
+ /**
+ * Returns size of all backups
+ */
+ static public function getAllBackupsTotalSize(): int
+ {
+ $size = 0;
+
+ foreach (glob(DATA_ROOT . '/*.sqlite') as $f) {
+ if ($f === DB_FILE) {
+ continue;
+ }
+
+ $size += filesize($f);
+ }
+
+ return $size;
+ }
+
+ /**
+ * Return size of all files in database
+ */
+ static public function getDBFilesSize(): int
+ {
+ $db = DB::getInstance();
+ return (int) $db->firstColumn('SELECT SUM(size) FROM files;');
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/CSV.php b/src/include/lib/Paheko/CSV.php
new file mode 100644
index 0000000..bad1b05
--- /dev/null
+++ b/src/include/lib/Paheko/CSV.php
@@ -0,0 +1,444 @@
+&1';
+ $return = shell_exec($cmd);
+
+ if (!file_exists($to)) {
+ throw new UserException('Impossible de convertir le fichier. Vérifier que le fichier est un format supporté.');
+ }
+
+ return $to;
+ }
+
+ static public function supportsXLSExport(): bool
+ {
+ return CALC_CONVERT_COMMAND ? true : false;
+ }
+
+ static public function readAsArray(string $path)
+ {
+ if (!file_exists($path) || !is_readable($path))
+ {
+ throw new \RuntimeException('Fichier inconnu : '.$path);
+ }
+
+ $fp = self::open($path);
+
+ if (!$fp)
+ {
+ return false;
+ }
+
+ $delim = self::findDelimiter($fp);
+ self::skipBOM($fp);
+
+ $line = 0;
+ $out = [];
+ $nb_columns = null;
+
+ while (!feof($fp))
+ {
+ $row = fgetcsv($fp, 4096, $delim);
+ $line++;
+
+ if (empty($row))
+ {
+ continue;
+ }
+
+ if (null === $nb_columns)
+ {
+ $nb_columns = count($row);
+ }
+
+ if (count($row) != $nb_columns)
+ {
+ throw new UserException('Erreur sur la ligne ' . $line . ' : incohérence dans le nombre de colonnes avec la première ligne.');
+ }
+
+ // Make sure the data is UTF-8 encoded
+ $row = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $row);
+
+ $out[$line] = $row;
+ }
+
+ fclose($fp);
+
+ return $out;
+ }
+
+ static public function open(string $file)
+ {
+ return fopen($file, 'r');
+ }
+
+ static public function findDelimiter(&$fp)
+ {
+ $line = '';
+
+ while ($line === '' && !feof($fp))
+ {
+ $line = fgets($fp, 4096);
+ }
+
+ if (strlen($line) >= 4095) {
+ throw new UserException('Fichier CSV illisible : la première ligne est trop longue.');
+ }
+
+ // Delete the columns content
+ $line = preg_replace('/".*?"/', '', $line);
+
+ $delims = [
+ ';' => substr_count($line, ';'),
+ ',' => substr_count($line, ','),
+ "\t"=> substr_count($line, "\t"),
+ '|' => substr_count($line, '|'),
+ ];
+
+ arsort($delims);
+ reset($delims);
+
+ rewind($fp);
+
+ return key($delims);
+ }
+
+ static public function skipBOM(&$fp)
+ {
+ // Skip BOM
+ if (fgets($fp, 4) !== chr(0xEF) . chr(0xBB) . chr(0xBF))
+ {
+ fseek($fp, 0);
+ }
+ }
+
+ static public function row($row): string
+ {
+ $row = (array) $row;
+
+ array_walk($row, function (&$field) {
+ $field = strtr((string) $field, ['"' => '""', "\r\n" => "\n"]);
+ });
+
+ return sprintf("\"%s\"\r\n", implode('","', $row));
+ }
+
+ static public function export(string $format, string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
+ {
+ // Flush any previous output, such as module HTML code etc.
+ @ob_end_clean();
+
+ if ('csv' == $format) {
+ self::toCSV(... array_slice(func_get_args(), 1));
+ }
+ elseif ('xlsx' == $format && CALC_CONVERT_COMMAND) {
+ self::toXLSX(... array_slice(func_get_args(), 1));
+ }
+ elseif ('ods' == $format) {
+ self::toODS(... array_slice(func_get_args(), 1));
+ }
+ else {
+ throw new \InvalidArgumentException('Unknown export format');
+ }
+ }
+
+ static public function exportHTML(string $format, string $html, string $name = 'Export'): void
+ {
+ $css = file_get_contents(ROOT . '/www/admin/static/styles/06-tables-export.css');
+ TableExport::download($format, $name, $html, $css);
+ exit;
+ }
+
+ static protected function rowToArray($row, ?callable $row_map_callback)
+ {
+ if (null !== $row_map_callback) {
+ call_user_func_array($row_map_callback, [&$row]);
+ }
+
+ if (is_object($row) && $row instanceof Entity) {
+ $row = $row->asArray();
+ }
+ elseif (is_object($row)) {
+ $row = (array) $row;
+ }
+
+ foreach ($row as $key => &$v) {
+ if ((is_object($v) && !($v instanceof \DateTimeInterface)) || is_array($v)) {
+ throw new \UnexpectedValueException(sprintf('Unexpected value for "%s": %s', $key, gettype($v)));
+ }
+ }
+
+ return $row;
+ }
+
+ static public function toCSV(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null, string $output = null): void
+ {
+ if (null === $output) {
+ header('Content-type: application/csv');
+ header(sprintf('Content-Disposition: attachment; filename="%s.csv"', $name));
+
+ $fp = fopen('php://output', 'w');
+ }
+ else {
+ $fp = fopen($output, 'w');
+ }
+
+ if ($header) {
+ fputs($fp, self::row($header));
+ }
+
+ if (!($iterator instanceof \Iterator) || $iterator->valid()) {
+ foreach ($iterator as $row) {
+ $row = self::rowToArray($row, $row_map_callback);
+
+ foreach ($row as $key => &$v) {
+ if (is_object($v) && $v instanceof \DateTimeInterface) {
+ if ($v->format('His') == '000000') {
+ $v = $v->format('d/m/Y');
+ }
+ else {
+ $v = $v->format('d/m/Y H:i:s');
+ }
+ }
+ }
+
+ if (!$header)
+ {
+ fputs($fp, self::row(array_keys($row)));
+ $header = true;
+ }
+
+ fputs($fp, self::row($row));
+ }
+ }
+
+ fclose($fp);
+ }
+
+ static public function toODS(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null, string $output = null): void
+ {
+ if (null === $output) {
+ header('Content-type: application/vnd.oasis.opendocument.spreadsheet');
+ header(sprintf('Content-Disposition: attachment; filename="%s.ods"', $name));
+ }
+
+ $ods = new ODSWriter;
+ $ods->table_name = $name;
+
+ if ($header) {
+ $ods->add((array) $header);
+ }
+
+ if (!($iterator instanceof \Iterator) || $iterator->valid()) {
+ foreach ($iterator as $row) {
+ $row = self::rowToArray($row, $row_map_callback);
+
+ if (!$header)
+ {
+ $ods->add(array_keys($row));
+ $header = true;
+ }
+
+ $ods->add((array) $row);
+ }
+ }
+
+ $ods->output($output);
+ }
+
+ static public function toXLSX(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
+ {
+ if (!CALC_CONVERT_COMMAND) {
+ throw new \LogicException('CALC_CONVERT_COMMAND is not set');
+ }
+
+ Utils::safe_mkdir(STATIC_CACHE_ROOT, null, true);
+ $tmpfile1 = sprintf('%s/export_%s.ods', STATIC_CACHE_ROOT, md5(random_bytes(10)));
+ $tmpfile2 = substr($tmpfile1, 0, -3) . 'xlsx';
+
+ try {
+ self::toODS($name, $iterator, $header, $row_map_callback, $tmpfile1);
+
+ self::convertXLSX($tmpfile1, $tmpfile2);
+
+ header('Content-type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+ header(sprintf('Content-Disposition: attachment; filename="%s.xlsx"', $name));
+
+ readfile($tmpfile2);
+ }
+ finally {
+ @unlink($tmpfile1);
+ @unlink($tmpfile2);
+ }
+ }
+
+ static public function importUpload(array $file, array $expected_columns): \Generator
+ {
+ if (empty($file['size']) || empty($file['tmp_name'])) {
+ throw new UserException('Fichier invalide');
+ }
+
+ return self::import($file['tmp_name'], $expected_columns);
+ }
+
+ static public function import(string $file, ?array $columns = null, array $required_columns = []): \Generator
+ {
+ $delete_after = is_uploaded_file($file);
+ $file = self::convertUploadIfRequired($file, $delete_after);
+
+ try {
+ $fp = fopen($file, 'r');
+
+ if (!$fp) {
+ throw new UserException('Le fichier ne peut être ouvert');
+ }
+
+ // Find the delimiter
+ $delim = self::findDelimiter($fp);
+ self::skipBOM($fp);
+
+ $line = 0;
+
+ $header = fgetcsv($fp, 4096, $delim);
+
+ // Make sure the data is UTF-8 encoded
+ $header = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $header);
+
+ $columns_map = [];
+
+ if (null === $columns) {
+ $columns_map = $header;
+ }
+ else {
+ $columns_is_list = is_int(key($columns));
+
+ // Check for columns
+ foreach ($header as $key => $label) {
+ // try to find with string key
+ if (!$columns_is_list && array_key_exists($label, $columns)) {
+ $columns_map[] = $label;
+ }
+ // Or with label
+ elseif (in_array($label, $columns)) {
+ $columns_map[] = $columns_is_list ? $label : array_search($label, $columns);
+ }
+ else {
+ $columns_map[] = null;
+ }
+ }
+ }
+
+ foreach ($required_columns as $key) {
+ if (!in_array($key, $columns_map)) {
+ throw new UserException(sprintf('La colonne "%s" est absente du fichier importé', $columns[$key] ?? $key));
+ }
+ }
+
+ while (!feof($fp))
+ {
+ $row = fgetcsv($fp, 4096, $delim);
+ $line++;
+
+ // Empty line, skip
+ if (empty($row)) {
+ continue;
+ }
+
+ if (count($row) != count($header))
+ {
+ throw new UserException('Erreur sur la ligne ' . $line . ' : le nombre de colonnes est incorrect.');
+ }
+
+ // Make sure the data is UTF-8 encoded
+ $row = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $row);
+
+ $row = array_combine($columns_map, $row);
+
+ yield $line => $row;
+ }
+
+ fclose($fp);
+ }
+ finally {
+ if ($delete_after) {
+ @unlink($file);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/CSV_Custom.php b/src/include/lib/Paheko/CSV_Custom.php
new file mode 100644
index 0000000..d64bed7
--- /dev/null
+++ b/src/include/lib/Paheko/CSV_Custom.php
@@ -0,0 +1,328 @@
+session = $session;
+ $this->key = $key;
+ $this->csv = $this->session ? $this->session->get($this->key) : null;
+ $this->translation = $this->session ? $this->session->get($this->key . '_translation') : null;
+ $this->skip = $this->session ? $this->session->get($this->key . '_skip') ?? 1 : 1;
+ }
+
+ public function load(?array $file): void
+ {
+ if (empty($file['size']) || empty($file['tmp_name']) || empty($file['name'])) {
+ throw new UserException('Fichier invalide');
+ }
+
+ $path = $file['tmp_name'];
+
+ $this->loadFile($path);
+
+ @unlink($path);
+ }
+
+ public function loadFile(string $path): void
+ {
+ if (CALC_CONVERT_COMMAND && strtolower(substr($path, -4)) != '.csv') {
+ $path = CSV::convertUploadIfRequired($path, true);
+ }
+
+ $this->csv = CSV::readAsArray($path);
+
+ if (!count($this->csv)) {
+ throw new UserException('Ce fichier est vide (aucune ligne trouvée).');
+ }
+
+ if ($this->session) {
+ $this->session->set($this->key, $this->csv);
+ $this->session->save();
+ }
+ }
+
+ public function iterate(): \Generator
+ {
+ if (empty($this->csv)) {
+ throw new \LogicException('No file has been loaded');
+ }
+
+ if (!$this->columns || !$this->translation) {
+ throw new \LogicException('Missing columns or translation table');
+ }
+
+ for ($i = 0; $i < count($this->csv); $i++) {
+ if ($i < $this->skip) {
+ continue;
+ }
+
+ yield $i+1 => $this->getLine($i + 1);
+ }
+ }
+
+ public function getLine(int $i): ?\stdClass
+ {
+ if (!isset($this->csv[$i])) {
+ return null;
+ }
+
+ if (!isset($this->_default)) {
+ $this->_default = array_map(function ($a) { return null; }, array_flip($this->translation));
+ }
+
+ $row = $this->_default;
+
+ foreach ($this->csv[$i] as $col => $value) {
+ if (!isset($this->translation[$col])) {
+ continue;
+ }
+
+ $row[$this->translation[$col]] = trim($value);
+ }
+
+ $row = (object) $row;
+
+ if (null !== $this->modifier) {
+ try {
+ $row = call_user_func($this->modifier, $row);
+ }
+ catch (UserException $e) {
+ throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()));
+ }
+ }
+
+ return $row;
+ }
+
+ public function getFirstLine(): array
+ {
+ if (!$this->loaded()) {
+ throw new \LogicException('No file has been loaded');
+ }
+
+ return current($this->csv);
+ }
+
+ public function setModifier(callable $callback): void
+ {
+ $this->modifier = $callback;
+ }
+
+ public function searchColumn(string $str, array $columns)
+ {
+ foreach ($columns as $key => $value) {
+ $columns[$key] = mb_strtolower(str_replace('’', '\'', $value));
+ }
+
+ $str = mb_strtolower(str_replace('’', '\'', $str));
+
+ return array_search($str, $columns, true);
+ }
+
+ public function getSelectedTable(?array $source = null): array
+ {
+ if (null === $source && isset($_POST['translation_table'])) {
+ $source = $_POST['translation_table'];
+ }
+ elseif (null === $source) {
+ $source = [];
+ }
+
+ $selected = $this->getFirstLine();
+
+ foreach ($selected as $i => &$v) {
+ if (isset($source[$i])) {
+ $v = $source[$i];
+ }
+ elseif (isset($this->translation[$i])) {
+ $v = $this->translation[$i];
+ }
+ // Match by key: code_postal === code_postal
+ elseif (array_key_exists($v, $this->columns)) {
+ // $v is already good, do nothing
+ }
+ // Match by label: Code postal === Code postal
+ elseif ($found = $this->searchColumn($v, $this->columns)) {
+ $v = $found;
+ }
+ elseif ($found = $this->searchColumn($v, $this->columns_defaults)) {
+ $v = $found;
+ }
+ else {
+ $v = null;
+ }
+ }
+
+ return $selected;
+ }
+
+ public function getTranslationTable(): ?array
+ {
+ return $this->translation;
+ }
+
+ public function setTranslationTableAuto(): void
+ {
+ $sel = $this->getSelectedTable([]);
+ $this->setTranslationTable($sel);
+ }
+
+ public function setTranslationTable(array $table): void
+ {
+ if (!count($table)) {
+ throw new UserException('Aucune colonne n\'a été sélectionnée');
+ }
+
+ $translation = [];
+
+ foreach ($table as $csv => $target) {
+ if (empty($target)) {
+ continue;
+ }
+
+ if (!array_key_exists($target, $this->columns)) {
+ throw new UserException('Colonne inconnue: ' . $target);
+ }
+
+ $translation[(int)$csv] = $target;
+ }
+
+ $this->setIndexedTable($translation);
+ }
+
+ public function setIndexedTable(array $table): void
+ {
+ if (!count($table)) {
+ throw new UserException('Aucune colonne n\'a été sélectionnée');
+ }
+
+ foreach ($this->mandatory_columns as $key) {
+ if (!in_array($key, $table, true)) {
+ throw new UserException(sprintf('La colonne "%s" est obligatoire mais n\'a pas été sélectionnée ou n\'existe pas.', $this->columns[$key]));
+ }
+ }
+
+ $this->translation = $table;
+
+ if ($this->session) {
+ $this->session->set($this->key . '_translation', $this->translation);
+ $this->session->save();
+ }
+ }
+
+ public function clear(): void
+ {
+ if ($this->session) {
+ $this->session->set($this->key, null);
+ $this->session->set($this->key . '_translation', null);
+ $this->session->set($this->key . '_skip', null);
+ $this->session->save();
+ }
+
+ $this->csv = null;
+ $this->translation = null;
+ $this->skip = 1;
+ }
+
+ public function loaded(): bool
+ {
+ return null !== $this->csv;
+ }
+
+ public function ready(): bool
+ {
+ return $this->loaded() && !empty($this->translation);
+ }
+
+ public function count(): ?int
+ {
+ return null !== $this->csv ? count($this->csv) : null;
+ }
+
+ public function skip(int $count): void
+ {
+ $this->skip = $count;
+
+ if ($this->session) {
+ $this->session->set($this->key . '_skip', $count);
+ $this->session->save();
+ }
+ }
+
+ public function setColumns(array $columns, array $defaults = []): void
+ {
+ $this->columns = array_filter($columns);
+ $this->columns_defaults = array_filter($defaults);
+ }
+
+ public function setMandatoryColumns(array $columns): void
+ {
+ $this->mandatory_columns = $columns;
+ }
+
+ public function getColumnsString(): string
+ {
+ if (!empty($this->columns_defaults)) {
+ $c = array_intersect_key($this->columns_defaults, $this->columns);
+ }
+ else {
+ $c = $this->columns;
+ }
+
+ return implode(', ', $c);
+ }
+
+ public function getMandatoryColumnsString(): string
+ {
+ if (!empty($this->columns_defaults)) {
+ $c = array_intersect_key($this->columns_defaults, $this->columns);
+ }
+ else {
+ $c = $this->columns;
+ }
+
+ return implode(', ', array_intersect_key($c, array_flip($this->getMandatoryColumns())));
+ }
+
+ public function getColumns(): array
+ {
+ return $this->columns;
+ }
+
+ public function getColumnLabel(string $key): ?string
+ {
+ return $this->columns[$key] ?? null;
+ }
+
+ public function getColumnsWithDefaults(): array
+ {
+ $out = [];
+
+ foreach ($this->columns as $key => $label) {
+ $out[] = compact('key', 'label') + ['match' => $this->columns_defaults[$key] ?? $label];
+ }
+
+ return $out;
+ }
+
+ public function getMandatoryColumns(): array
+ {
+ return $this->mandatory_columns;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Config.php b/src/include/lib/Paheko/Config.php
new file mode 100644
index 0000000..12fee38
--- /dev/null
+++ b/src/include/lib/Paheko/Config.php
@@ -0,0 +1,400 @@
+ File::CONTEXT_CONFIG . '/admin_bg.png',
+ 'admin_homepage' => File::CONTEXT_CONFIG . '/admin_homepage.skriv',
+ 'admin_css' => File::CONTEXT_CONFIG . '/admin.css',
+ 'logo' => File::CONTEXT_CONFIG . '/logo.png',
+ 'icon' => File::CONTEXT_CONFIG . '/icon.png',
+ 'favicon' => File::CONTEXT_CONFIG . '/favicon.png',
+ 'signature' => File::CONTEXT_CONFIG . '/signature.png',
+ ];
+
+ const FILES_TYPES = [
+ 'admin_background' => 'image',
+ 'admin_css' => 'code',
+ 'admin_homepage' => 'web',
+ 'logo' => 'image',
+ 'icon' => 'image',
+ 'favicon' => 'image',
+ 'signature' => 'image',
+ ];
+
+ /**
+ * List of config files that should be public no matter what
+ */
+ const FILES_PUBLIC = [
+ 'logo', 'icon', 'favicon', 'admin_background', 'admin_css',
+ ];
+
+ const VERSIONING_POLICIES = [
+ 'none' => [
+ 'label' => 'Ne pas conserver les anciennes versions',
+ 'help' => 'Permet d\'utiliser moins d\'espace disque',
+ ],
+ 'min' => [
+ 'label' => 'Conservation minimale',
+ 'help' => 'Jusqu\'à 5 versions seront conservées pour chaque fichier.',
+ 'intervals' => [
+ // Keep one version only after 2 months
+ -1 => INF,
+
+ // First 10 minutes, one version
+ 600 => INF,
+
+ // Next hour, one version
+ 3600 => INF,
+
+ // Next 24h, one version
+ 3600*24 => INF,
+
+ // Next 2 months, one version
+ 3600*24*60 => INF,
+ ],
+ ],
+ 'avg' => [
+ 'label' => 'Conservation moyenne',
+ 'help' => 'Jusqu\'à 20 versions seront conservées pour chaque fichier.',
+ 'intervals' => [
+ // Keep one version after first 4 months
+ -1 => INF,
+
+ // First 10 minutes, one version every 5 minutes
+ 600 => 300,
+
+ // Next hour, one version every 15 minutes
+ 3600 => 90,
+
+ // Next 24h, one version every 3 hours
+ 3600*24 => 3*3600,
+
+ // Next 4 months, one version per month
+ 3600*24*120 => 3600*24*30,
+ ],
+ ],
+ 'max' => [
+ 'label' => 'Conservation maximale',
+ 'help' => 'Jusqu\'à 50 versions seront conservées pour chaque fichier.',
+ 'intervals' => [
+ //ends_after => step (interval size)
+ // Keep one version each trimester after first 2 months
+ -1 => 3600*24*30,
+
+ // First 10 minutes, one version every 1 minute
+ 600 => 60,
+
+ // Next hour, one version every 10 minutes
+ 3600 => 600,
+
+ // Next 24h, one version every hour
+ 3600*24 => 3600,
+
+ // Next 2 months, one version per week
+ 3600*24*60 => 3600*24*7,
+ ],
+ ],
+ ];
+
+ protected string $org_name;
+ protected ?string $org_infos;
+ protected string $org_email;
+ protected ?string $org_address;
+ protected ?string $org_phone;
+ protected ?string $org_web;
+
+ protected string $currency;
+ protected string $country;
+
+ protected int $default_category;
+
+ protected ?int $backup_frequency;
+ protected ?int $backup_limit;
+
+ protected ?int $last_chart_change;
+ protected ?string $last_version_check;
+
+ protected ?string $color1;
+ protected ?string $color2;
+
+ protected array $files = [];
+
+ protected bool $site_disabled;
+
+ protected int $log_retention;
+ protected bool $analytical_set_all;
+
+ protected ?string $file_versioning_policy = null;
+ protected int $file_versioning_max_size = 0;
+
+ protected ?int $auto_logout = 0;
+
+ static protected $_instance = null;
+
+ static public function getInstance()
+ {
+ return self::$_instance ?: self::$_instance = new self;
+ }
+
+ static public function deleteInstance()
+ {
+ self::$_instance = null;
+ }
+
+ public function __clone()
+ {
+ throw new \LogicException('Cannot clone config');
+ }
+
+ protected function __construct()
+ {
+ parent::__construct();
+
+ $db = DB::getInstance();
+
+ $config = $db->getAssoc('SELECT key, value FROM config ORDER BY key;');
+
+ if (empty($config)) {
+ return;
+ }
+
+ $default = array_fill_keys(array_keys($this->_types), null);
+ $config = array_merge($default, $config);
+
+ foreach ($this->_types as $key => $type) {
+ $value = $config[$key];
+
+ if ($type[0] == '?' && $value === null) {
+ continue;
+ }
+ }
+
+ $this->load($config);
+ }
+
+ public function setCreateFlag(): void
+ {
+ foreach ($this->_types as $key => $t) {
+ $this->_modified[$key] = null;
+ }
+
+ $this->files = array_map(fn($a) => null, self::FILES);
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ if (!count($this->_modified)) {
+ return true;
+ }
+
+ if ($selfcheck) {
+ $this->selfCheck();
+ }
+
+ $values = $this->getModifiedProperties();
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ foreach ($values as $key => $value)
+ {
+ $value = $this->getAsString($key);
+ $db->preparedQuery('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?);', $key, $value);
+ }
+
+ $db->commit();
+
+ $this->_modified = [];
+
+ if (array_key_exists('log_retention', $values)) {
+ Log::clean();
+ }
+
+ return true;
+ }
+
+ public function delete(): bool
+ {
+ throw new \LogicException('Cannot delete config');
+ }
+
+ public function importForm($source = null): void
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ // N'enregistrer les couleurs que si ce ne sont pas les couleurs par défaut
+ if (isset($source['color1'], $source['color2'])
+ && ($source['color1'] == ADMIN_COLOR1 && $source['color2'] == ADMIN_COLOR2))
+ {
+ $source['color1'] = null;
+ $source['color2'] = null;
+ }
+
+ parent::importForm($source);
+ }
+
+ protected function _filterType(string $key, $value)
+ {
+ switch ($this->_types[$key]) {
+ case 'int':
+ return (int) $value;
+ case 'bool':
+ return (bool) $value;
+ case 'string':
+ return (string) $value;
+ default:
+ throw new \InvalidArgumentException(sprintf('"%s" has unknown type "%s"', $key, $this->_types[$key]));
+ }
+ }
+
+ public function selfCheck(): void
+ {
+ $this->assert(trim($this->org_name) != '', 'Le nom de l\'association ne peut rester vide.');
+ $this->assert(trim($this->currency) != '', 'La monnaie ne peut rester vide.');
+ $this->assert(trim($this->country) != '' && Utils::getCountryName($this->country), 'Le pays ne peut rester vide.');
+ $this->assert(!isset($this->org_web) || filter_var($this->org_web, FILTER_VALIDATE_URL), 'L\'adresse URL du site web est invalide.');
+ $this->assert(trim($this->org_email) != '' && SMTP::checkEmailIsValid($this->org_email, false), 'L\'adresse e-mail de l\'association est invalide.');
+
+ $this->assert($this->log_retention >= 0, 'La durée de rétention doit être égale ou supérieur à zéro.');
+
+ // Files
+ $this->assert(count($this->files) == count(self::FILES));
+
+ foreach ($this->files as $key => $value) {
+ $this->assert(array_key_exists($key, self::FILES));
+ $this->assert(is_int($value) || is_null($value));
+ }
+
+ $db = DB::getInstance();
+ $this->assert($db->test('users_categories', 'id = ?', $this->default_category), 'Catégorie de membres inconnue');
+ }
+
+ public function file(string $key): ?File
+ {
+ if (!isset(self::FILES[$key])) {
+ throw new \InvalidArgumentException('Invalid file key: ' . $key);
+ }
+
+ if (empty($this->files[$key])) {
+ return null;
+ }
+
+ return Files::get(self::FILES[$key]);
+ }
+
+ public function fileURL(string $key, string $params = ''): ?string
+ {
+ if (empty($this->files[$key])) {
+
+ if ($key == 'favicon') {
+ return ADMIN_URL . 'static/favicon.png';
+ }
+ elseif ($key == 'icon') {
+ return ADMIN_URL . 'static/icon.png';
+ }
+
+ return null;
+ }
+
+ $params = $params ? $params . '&' : '';
+
+ return WWW_URI . self::FILES[$key] . '?' . $params . 'h=' . substr(md5($this->files[$key]), 0, 10);
+ }
+
+
+ public function hasFile(string $key): bool
+ {
+ return $this->files[$key] ? true : false;
+ }
+
+ public function updateFiles(): void
+ {
+ $files = $this->files;
+
+ foreach (self::FILES as $key => $path) {
+ if ($f = Files::get($path)) {
+ $files[$key] = $f->modified->getTimestamp();
+ }
+ else {
+ $files[$key] = null;
+ }
+ }
+
+ $this->set('files', $files);
+ }
+
+ public function setFile(string $key, ?string $value, bool $upload = false): ?File
+ {
+ $f = Files::get(self::FILES[$key]);
+ $files = $this->files;
+ $type = self::FILES_TYPES[$key];
+ $path = self::FILES[$key];
+
+ // NULL = delete file
+ if (null === $value) {
+ if ($f) {
+ $f->delete();
+ }
+
+ $f = null;
+ }
+ elseif ($upload) {
+ $f = Files::upload(Utils::dirname($path), $value, Utils::basename($path));
+
+ if ($type === 'image' && !$f->image) {
+ $this->setFile($key, null);
+ throw new UserException('Le fichier n\'est pas une image.');
+ }
+
+ try {
+ // Force favicon format
+ if ($key === 'favicon') {
+ $format = 'png';
+ $i = $f->asImageObject();
+ $i->cropResize(32, 32);
+ $f->setContent($i->output($format, true));
+ }
+ // Force icon format
+ else if ($key === 'icon') {
+ $format = 'png';
+ $i = $f->asImageObject();
+ $i->cropResize(512, 512);
+ $f->setContent($i->output($format, true));
+ }
+ // Force signature size
+ else if ($key === 'signature') {
+ $format = 'png';
+ $i = $f->asImageObject();
+ $i->resize(200, 200);
+ $f->setContent($i->output($format, true));
+ }
+ }
+ catch (\Exception $e) {
+ throw new UserException('Cet format d\'image n\'est pas supporté.', 0, $e);
+ }
+ }
+ elseif ($f) {
+ $f->setContent($value);
+ }
+ else {
+ $f = Files::createFromString($path, $value);
+ }
+
+ $files[$key] = $f ? $f->modified->getTimestamp() : null;
+ $this->set('files', $files);
+
+ return $f;
+ }
+}
diff --git a/src/include/lib/Paheko/DB.php b/src/include/lib/Paheko/DB.php
new file mode 100644
index 0000000..77f38ff
--- /dev/null
+++ b/src/include/lib/Paheko/DB.php
@@ -0,0 +1,481 @@
+ DB_FILE]);
+ }
+
+ return self::$_instance;
+ }
+
+ static public function deleteInstance()
+ {
+ self::$_instance = null;
+ }
+
+ private function __clone()
+ {
+ // Désactiver le clonage, car on ne veut qu'une seule instance
+ }
+
+ public function __construct(string $driver, array $params)
+ {
+ if (self::$_instance !== null) {
+ throw new \LogicException('Cannot start instance');
+ }
+
+ parent::__construct($driver, $params);
+
+ // Enable SQL debug log if configured
+ if (SQL_DEBUG) {
+ $this->callback = [$this, 'log'];
+ $this->_log_start = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true);
+ }
+ }
+
+ public function __destruct()
+ {
+ parent::__destruct();
+
+ if (null !== $this->callback) {
+ $this->saveLog();
+ }
+ }
+
+ /**
+ * Disable logging if enabled
+ * useful to disable logging when reloading log page
+ */
+ public function disableLog(): void {
+ $this->callback = null;
+ $this->_log_store = [];
+ }
+
+ /**
+ * Saves the log in a different database at the end of the script
+ */
+ protected function saveLog(): void
+ {
+ if (!count($this->_log_store)) {
+ return;
+ }
+
+ $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
+ $db->exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, script TEXT, user TEXT);
+ CREATE TABLE IF NOT EXISTS log (session INTEGER NOT NULL REFERENCES sessions (id), time INTEGER, duration INTEGER, sql TEXT, trace TEXT);');
+
+ $user = $_SESSION['userSession']->id ?? null;
+
+ $db->insert('sessions', ['script' => str_replace(ROOT, '', $_SERVER['SCRIPT_NAME']), 'user' => $user]);
+ $id = $db->lastInsertId();
+
+ $db->begin();
+
+ foreach ($this->_log_store as $row) {
+ $db->insert('log', array_merge($row, ['session' => $id]));
+ }
+
+ $db->commit();
+ $db->close();
+ }
+
+ /**
+ * Log current SQL query
+ */
+ protected function log(string $method, ?string $timing, $object, ...$params): void
+ {
+ if ($method === '__destruct') {
+ $this->_log_store[] = ['duration' => 0, 'time' => round((microtime(true) - $this->_log_start) * 1000 * 1000), 'sql' => null, 'trace' => null];
+ return;
+ }
+
+ if ($method != 'execute' && $method != 'exec') {
+ return;
+ }
+
+ if ($timing == 'before') {
+ $this->_log_last = microtime(true);
+ return;
+ }
+
+ $now = microtime(true);
+ $duration = round(($now - $this->_log_last) * 1000 * 1000);
+ $time = round(($now - $this->_log_start) * 1000 * 1000);
+
+ if ($method == 'execute') {
+ $sql = $params[0]->getSQL(true);
+ }
+ else {
+ $sql = $params[0];
+ }
+
+ $sql = preg_replace('/^\s+/m', ' ', $sql);
+
+ $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
+ $trace = '';
+
+ foreach ($backtrace as $line) {
+ if (!isset($line['file']) || in_array(basename($line['file']), ['DB.php', 'SQLite3.php']) || strstr($line['file'], 'lib/KD2')) {
+ continue;
+ }
+
+ $file = isset($line['file']) ? str_replace(ROOT . '/', '', $line['file']) : '';
+
+ $trace .= sprintf("%s:%d\n", $file, $line['line']);
+ }
+
+ $this->_log_store[] = compact('duration', 'time', 'sql', 'trace');
+ }
+
+ /**
+ * Return a debug log session using its ID
+ */
+ static public function getDebugSession(int $id): ?\stdClass
+ {
+ $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
+ $s = $db->first('SELECT * FROM sessions WHERE id = ?;', $id);
+
+ if ($s) {
+ $s->list = $db->get('SELECT * FROM log WHERE session = ? ORDER BY time;', $id);
+
+ foreach ($s->list as &$row) {
+ try {
+ $explain = DB::getInstance()->get('EXPLAIN QUERY PLAN ' . $row->sql);
+ $row->explain = '';
+
+ foreach ($explain as $e) {
+ $row->explain .= $e->detail . "\n";
+ }
+ }
+ catch (DB_Exception $e) {
+ $row->explain = 'Error: ' . $e->getMessage();
+ }
+ }
+ }
+
+ $db->close();
+
+ return $s;
+ }
+
+ /**
+ * Return the list of all debug sessions
+ */
+ static public function getDebugSessionsList(): array
+ {
+ $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
+ $s = $db->get('SELECT s.*, SUM(l.duration) / 1000 AS sql_time, COUNT(l.rowid) AS count, MAX(l.time) / 1000 AS request_time
+ FROM sessions s
+ INNER JOIN log l ON l.session = s.id
+ GROUP BY s.id
+ ORDER BY s.date DESC;');
+
+ $db->close();
+
+ return $s;
+ }
+
+ public function connect(): void
+ {
+ if (null !== $this->db) {
+ return;
+ }
+
+ parent::connect();
+
+ // Activer les contraintes des foreign keys
+ $this->db->exec('PRAGMA foreign_keys = ON;');
+
+ // 10 secondes
+ $this->db->busyTimeout(10 * 1000);
+
+ $mode = strtoupper(SQLITE_JOURNAL_MODE);
+ $set_mode = $this->db->querySingle('PRAGMA journal_mode;');
+ $set_mode = strtoupper($set_mode);
+
+ if ($set_mode !== $mode) {
+ // WAL = performance enhancement
+ // see https://www.cs.utexas.edu/~jaya/slides/apsys17-sqlite-slides.pdf
+ // https://ericdraken.com/sqlite-performance-testing/
+ $this->exec(sprintf(
+ 'PRAGMA journal_mode = %s; PRAGMA synchronous = NORMAL; PRAGMA journal_size_limit = %d;',
+ $mode,
+ 32 * 1024 * 1024
+ ));
+ }
+
+ self::registerCustomFunctions($this->db);
+ }
+
+ static public function registerCustomFunctions($db)
+ {
+ $db->createFunction('dirname', [Utils::class, 'dirname']);
+ $db->createFunction('basename', [Utils::class, 'basename']);
+ $db->createFunction('unicode_like', [self::class, 'unicodeLike']);
+ $db->createFunction('transliterate_to_ascii', [Utils::class, 'unicodeTransliterate']);
+ $db->createFunction('email_hash', [Email::class, 'getHash']);
+ $db->createFunction('md5', 'md5');
+ $db->createFunction('uuid', [Utils::class, 'uuid']);
+ $db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']);
+ }
+
+ public function toggleUnicodeLike(bool $enable): void
+ {
+ if ($enable) {
+ $this->createFunction('like', [$this, 'unicodeLike']);
+ }
+ else {
+ // We should revert LIKE to the default, but we can't currently (FIXME?)
+ // see https://github.com/php/php-src/issues/10726
+ //$db->createFunction('like', null);
+ }
+ }
+
+ public function version(): ?string
+ {
+ if (-1 === $this->_version) {
+ $this->connect();
+ $this->_version = self::getVersion($this->db);
+ }
+
+ return $this->_version;
+ }
+
+ static public function getVersion($db)
+ {
+ $v = (int) $db->querySingle('PRAGMA user_version;');
+ $v = self::parseVersion($v);
+
+ if (null === $v) {
+ try {
+ // For legacy version before 1.1.0
+ $v = $db->querySingle('SELECT valeur FROM config WHERE cle = \'version\';');
+ }
+ catch (\Exception $e) {
+ throw new \RuntimeException('Cannot find application version', 0, $e);
+ }
+ }
+
+ return $v ?: null;
+ }
+
+ static public function parseVersion(int $v): ?string
+ {
+ if ($v > 0) {
+ $major = intval($v / 1000000);
+ $v -= $major * 1000000;
+ $minor = intval($v / 10000);
+ $v -= $minor * 10000;
+ $release = intval($v / 100);
+ $v -= $release * 100;
+ $type = $v;
+
+ if ($type == 0) {
+ $type = '';
+ }
+ // Corrective release: 1.2.3.1
+ elseif ($type > 75) {
+ $type = '.' . ($type - 75);
+ }
+ // RC release
+ elseif ($type > 50) {
+ $type = '-rc' . ($type - 50);
+ }
+ // Beta
+ elseif ($type > 25) {
+ $type = '-beta' . ($type - 25);
+ }
+ // Alpha
+ else {
+ $type = '-alpha' . $type;
+ }
+
+ $v = sprintf('%d.%d.%d%s', $major, $minor, $release, $type);
+ }
+
+ return $v ?: null;
+ }
+
+ /**
+ * Save version to database
+ * rc, alpha, beta and corrective release (4th number) are limited to 24 versions each
+ * @param string $version Version string, eg. 1.2.3-rc2
+ */
+ public function setVersion(string $version): void
+ {
+ if (!preg_match('/^(\d+)\.(\d+)\.(\d+)(?:(?:-(alpha|beta|rc)|\.)(\d+)|)?$/', $version, $match)) {
+ throw new \InvalidArgumentException('Invalid version number: ' . $version);
+ }
+
+ $version = ($match[1] * 100 * 100 * 100) + ($match[2] * 100 * 100) + ($match[3] * 100);
+
+ if (isset($match[5])) {
+ if ($match[5] > 24) {
+ throw new \InvalidArgumentException('Invalid version number: cannot have a 4th component larger than 24: ' . $version);
+ }
+
+ if ($match[4] == 'rc') {
+ $version += $match[5] + 50;
+ }
+ elseif ($match[4] == 'beta') {
+ $version += $match[5] + 25;
+ }
+ elseif ($match[4] == 'alpha') {
+ $version += $match[5];
+ }
+ else {
+ $version += $match[5] + 75;
+ }
+ }
+
+ $this->db->exec(sprintf('PRAGMA user_version = %d;', $version));
+ }
+
+ public function beginSchemaUpdate()
+ {
+ // Only start if not already taking place
+ if ($this->_schema_update++ == 0) {
+ $this->toggleForeignKeys(false);
+ $this->begin();
+ }
+ }
+
+ public function commitSchemaUpdate()
+ {
+ // Only commit if last call
+ if (--$this->_schema_update == 0) {
+ $this->commit();
+ $this->toggleForeignKeys(true);
+ }
+ }
+
+ public function lastErrorMsg()
+ {
+ return $this->db->lastErrorMsg();
+ }
+
+ /**
+ * @see https://www.sqlite.org/lang_altertable.html
+ */
+ public function toggleForeignKeys(bool $enable): void
+ {
+ $this->connect();
+
+ if (!$enable) {
+ $this->db->exec('PRAGMA legacy_alter_table = ON;');
+ $this->db->exec('PRAGMA foreign_keys = OFF;');
+
+ if ($this->firstColumn('PRAGMA foreign_keys;')) {
+ throw new \LogicException('Cannot disable foreign keys in an already started transaction');
+ }
+ }
+ else {
+ $this->db->exec('PRAGMA legacy_alter_table = OFF;');
+ $this->db->exec('PRAGMA foreign_keys = ON;');
+ }
+ }
+
+ /**
+ * This is a rewrite of SQLite LIKE function that is transforming
+ * the pattern and the value to lowercase ascii, so that we can match
+ * "émilie" with "emilie".
+ *
+ * This is probably not the best way to do that, but we have to resort to that
+ * as ICU extension is rarely available.
+ *
+ * @see https://www.sqlite.org/c3ref/strlike.html
+ * @see https://sqlite.org/src/file?name=ext/icu/icu.c&ci=trunk
+ */
+ static public function unicodeLike($pattern, $value, $escape = null) {
+ if (null === $pattern || null === $value) {
+ return false;
+ }
+
+ $escape ??= '\\';
+ $pattern = str_replace('’', '\'', $pattern); // Normalize French apostrophe
+ $value = str_replace('’', '\'', $value);
+
+ $id = md5($pattern . $escape);
+
+ // Build regexp
+ if (!array_key_exists($id, self::$unicode_patterns_cache)) {
+ // Match escaped special chars | special chars | unicode characters | other
+ $regexp = '/!([%_!])|([%_!])|(\pL+)|(.+?)/iu';
+ $regexp = str_replace('!', preg_quote($escape, '/'), $regexp);
+
+ preg_match_all($regexp, $pattern, $parts, PREG_SET_ORDER);
+ $pattern = '';
+
+ foreach ($parts as $part) {
+ // Append other characters
+ if (isset($part[4])) {
+ $pattern .= preg_quote(strtolower($part[0]), '/');
+ }
+ // Append unicode
+ elseif (isset($part[3])) {
+ $pattern .= preg_quote(Utils::unicodeCaseFold($part[3]), '/');
+ }
+ // Append .*
+ elseif (isset($part[2]) && $part[2] == '%') {
+ $pattern .= '.*';
+ }
+ // Append .
+ elseif (isset($part[2]) && $part[2] == '_') {
+ $pattern .= '.';
+ }
+ // Append escaped special character
+ else {
+ $pattern .= preg_quote($part[1], '/');
+ }
+ }
+
+ // Store pattern in cache
+ $pattern = '/^' . $pattern . '$/im';
+ self::$unicode_patterns_cache[$id] = $pattern;
+ }
+
+ $value = Utils::unicodeCaseFold($value);
+
+ return (bool) preg_match(self::$unicode_patterns_cache[$id], $value);
+ }
+
+ public function dropIndexes(): void
+ {
+ foreach ($this->getAssoc('SELECT name, name FROM sqlite_master WHERE type = \'index\';') as $index) {
+ if (preg_match('!^(?:sqlite_|plugin_|prv_)!', $index)) {
+ continue;
+ }
+
+ $this->exec(sprintf('DROP INDEX IF EXISTS %s;', $index));
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/DynamicList.php b/src/include/lib/Paheko/DynamicList.php
new file mode 100644
index 0000000..fa14b4d
--- /dev/null
+++ b/src/include/lib/Paheko/DynamicList.php
@@ -0,0 +1,483 @@
+ true, 'columns' => true, 'conditions' => true, 'group' => true];
+
+ /**
+ * COUNT result, cached here to avoid multiple requests
+ */
+ private ?int $count_result = null;
+
+ public function __construct(array $columns, string $tables, string $conditions = '1')
+ {
+ $this->columns = $columns;
+ $this->tables = $tables;
+ $this->conditions = $conditions;
+ $this->order = key($columns);
+ }
+
+ public function __isset($key)
+ {
+ return property_exists($this, $key);
+ }
+
+ public function __get($key)
+ {
+ return $this->$key;
+ }
+
+ public function togglePreferenceHashElement(string $name, bool $enable): void
+ {
+ $this->preference_hash_elements[$name] = $enable;
+ }
+
+ public function setParameter($key, $value) {
+ $this->parameters[$key] = $value;
+ }
+
+ public function setTitle(string $title) {
+ $this->title = $title;
+ }
+
+ public function setModifier(callable $fn) {
+ $this->modifier = $fn;
+ }
+
+ public function setExportCallback(callable $fn) {
+ $this->export_callback = $fn;
+ }
+
+ public function setPageSize(?int $size) {
+ $this->per_page = $size;
+ }
+
+ public function setConditions(string $conditions)
+ {
+ $this->conditions = $conditions;
+ }
+
+ /**
+ * If an entity is set, then each row will return the specified entity
+ * (using the SELECT clause passed) instead of the specified columns.
+ * Columns will only be used for the header and ordering
+ */
+ public function setEntity(string $entity, string $select = '*')
+ {
+ $this->entity = $entity;
+ $this->entity_select = $select;
+ }
+
+ public function orderBy(string $key, bool $desc)
+ {
+ if (!array_key_exists($key, $this->columns)) {
+ throw new UserException('Invalid order: ' . $key);
+ }
+
+ $this->order = $key;
+ $this->desc = $desc;
+ }
+
+ public function groupBy(string $value)
+ {
+ $this->group = $value;
+ }
+
+ public function count(): int
+ {
+ if (null === $this->count_result) {
+ $sql = sprintf('SELECT %s FROM %s WHERE %s;', $this->count, $this->count_tables ?? $this->tables, $this->conditions);
+ $this->count_result = DB::getInstance()->firstColumn($sql, $this->parameters);
+ }
+
+ return (int) $this->count_result;
+ }
+
+ public function export(string $name, string $format = 'csv')
+ {
+ $this->setPageSize(null);
+ $columns = [];
+
+ foreach ($this->columns as $key => $column) {
+ if (empty($column['label'])) {
+ $columns[] = $key;
+ continue;
+ }
+
+ $columns[] = $column['label'];
+ }
+
+ CSV::export($format, $name, $this->iterate(false), $this->getExportHeaderColumns(), $this->export_callback);
+ }
+
+ public function asArray(): array
+ {
+ $out = [];
+
+ foreach ($this->iterate(true) as $row) {
+ $out[] = $row;
+ }
+
+ return $out;
+ }
+
+ public function orderURL(string $order, bool $desc)
+ {
+ $query = array_merge($_GET, ['o' => $order, 'd' => (int) $desc]);
+ $url = Utils::getSelfURI($query);
+ return $url;
+ }
+
+ public function setCount(string $count)
+ {
+ $this->count = $count;
+ }
+
+ public function setCountTables(string $tables)
+ {
+ $this->count_tables = $tables;
+ }
+
+ public function getHeaderColumns(bool $export = false)
+ {
+ $columns = [];
+
+ foreach ($this->columns as $alias => $properties) {
+ if (isset($properties['only_with_order']) && !($properties['only_with_order'] == $this->order)) {
+ continue;
+ }
+
+ // Skip columns that require a certain order AND paginated result
+ if (isset($properties['only_with_order']) && $this->page > 1) {
+ continue;
+ }
+
+ if (!isset($properties['label'])) {
+ continue;
+ }
+
+ if (isset($properties['export'])) {
+ if (!$properties['export'] && $export) {
+ continue;
+ }
+ elseif ($properties['export'] && !$export) {
+ continue;
+ }
+ }
+
+ $columns[$alias] = $export ? $properties['label'] : $properties;
+ }
+
+ return $columns;
+ }
+
+ public function countHeaderColumns(): int
+ {
+ return count($this->getHeaderColumns());
+ }
+
+ public function getExportHeaderColumns(): array
+ {
+ return $this->getHeaderColumns(true);
+ }
+
+ public function iterate(bool $include_hidden = true)
+ {
+ if ($this->entity) {
+ $list = EM::getInstance($this->entity)->iterate($this->SQL());
+ }
+ else {
+ $list = DB::getInstance()->iterate($this->SQL(), $this->parameters);
+ }
+
+ foreach ($list as $row_key => $row) {
+ if ($this->modifier) {
+ call_user_func_array($this->modifier, [&$row]);
+ }
+
+ // Hide columns without a label in results
+ if (!$this->entity) {
+ foreach ($this->columns as $key => $config) {
+ if (empty($config['label']) && !$include_hidden) {
+ unset($row->$key);
+ }
+ }
+ }
+
+ yield $row_key => $row;
+ }
+ }
+
+ public function SQL()
+ {
+ $start = ($this->page - 1) * $this->per_page;
+ $db = DB::getInstance();
+
+ if ($this->entity) {
+ $select = $this->entity_select;
+ }
+ else {
+ $columns = [];
+
+ foreach ($this->columns as $alias => $properties) {
+ // Skip columns that require a certain order (eg. calculating a running sum)
+ if (isset($properties['only_with_order']) && !($properties['only_with_order'] == $this->order)) {
+ continue;
+ }
+
+ // Skip columns that require a certain order AND paginated result
+ if (isset($properties['only_with_order']) && $this->page > 1) {
+ continue;
+ }
+
+ if (array_key_exists('select', $properties)) {
+ $select = $properties['select'] ?? 'NULL';
+ $columns[] = sprintf('%s AS %s', $select, $db->quoteIdentifier($alias));
+ }
+ else {
+ $columns[] = $db->quoteIdentifier($alias);
+ }
+ }
+
+ $select = implode(', ', $columns);
+ }
+
+ if (isset($this->columns[$this->order]['order'])) {
+ $order = sprintf($this->columns[$this->order]['order'], $this->desc ? 'DESC' : 'ASC');
+ }
+ else {
+ $order = $db->quoteIdentifiers($this->order);
+
+ if (true === $this->desc) {
+ $order .= ' DESC';
+ }
+ }
+
+ $group = $this->group ? 'GROUP BY ' . $this->group : '';
+
+ $sql = sprintf('SELECT %s FROM %s WHERE %s %s ORDER BY %s',
+ $select, $this->tables, $this->conditions, $group, $order);
+
+ if (null !== $this->per_page) {
+ $sql .= sprintf(' LIMIT %d,%d', $start, $this->per_page);
+ }
+
+ return $sql;
+ }
+
+ public function loadFromQueryString(): void
+ {
+ $export = $_POST['_dl_export'] ?? ($_GET['export'] ?? null);
+ $page = $_POST['_dl_page'] ?? ($_GET['p'] ?? null);
+
+ $order = null;
+ $desc = null;
+ $hash = null;
+ $preferences = null;
+ $u = null;
+
+ if ($u = Session::getLoggedUser()) {
+ $elements = [];
+
+ foreach ($this->preference_hash_elements as $e => $enabled) {
+ if (!$enabled) {
+ continue;
+ }
+
+ $elements[$e] = $this->$e;
+ }
+
+ ksort($elements);
+
+ $hash = md5(json_encode($elements));
+ $preferences = $u->getPreference('list_' . $hash) ?? null;
+
+ $order = $preferences->o ?? null;
+ $desc = $preferences->d ?? null;
+ }
+
+ if (!empty($_POST['_dl_order'])) {
+ $order = substr($_POST['_dl_order'], 1);
+ $desc = substr($_POST['_dl_order'], 0, 1) == '>' ? true : false;
+ }
+ elseif (!empty($_GET['o'])) {
+ $order = $_GET['o'];
+ $desc = !empty($_GET['d']);
+ }
+
+ if ($export) {
+ $this->export($this->title, $export);
+ exit;
+ }
+
+ // Save current order, if different than default
+ if ($u && $hash
+ && ($order != ($preferences->o ?? null) || $desc != ($preferences->d ?? null))) {
+ if ($order == $this->order && $desc == $this->desc) {
+ $u->deletePreference('list_' . $hash);
+ }
+ else {
+ $u->setPreference('list_' . $hash, ['o' => $order, 'd' => $desc]);
+ }
+ }
+
+ if ($order) {
+ $this->orderBy($order, $desc);
+ }
+
+ if ($page) {
+ $this->page = (int) $page;
+ }
+
+ if ($this->per_page !== null && ($nb = Session::getPreference('page_size'))) {
+ $this->setPageSize((int) $nb);
+ }
+ }
+
+ public function isPaginated(): bool
+ {
+ if (null === $this->per_page) {
+ return false;
+ }
+
+ return $this->count() > $this->per_page;
+ }
+
+ public function getHTMLPagination(bool $use_buttons = false): string
+ {
+ if (!$this->isPaginated()) {
+ return '';
+ }
+
+ $pagination = Utils::getGenericPagination($this->page, $this->count(), $this->per_page);
+
+ if (empty($pagination)) {
+ return '';
+ }
+
+ $url = Utils::getModifiedURL('?p=DDD');
+
+ $out = '';
+ return $out;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Email/Emails.php b/src/include/lib/Paheko/Email/Emails.php
new file mode 100644
index 0000000..2c2b97c
--- /dev/null
+++ b/src/include/lib/Paheko/Email/Emails.php
@@ -0,0 +1,687 @@
+ 'Texte brut',
+ Render::FORMAT_MARKDOWN => 'MarkDown',
+ ];
+
+ /**
+ * Email sending contexts
+ */
+ const CONTEXT_BULK = 1;
+ const CONTEXT_PRIVATE = 2;
+ const CONTEXT_SYSTEM = 0;
+ const CONTEXT_NOTIFICATION = 3;
+
+ /**
+ * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification.
+ */
+ const FAIL_LIMIT = 5;
+
+ static public function appendToQueue(int $context, string $email, array $data = [])
+ {
+
+ }
+
+ /**
+ * Add a message to the sending queue using templates
+ * @param int $context
+ * @param iterable $recipients List of recipients, this accepts a wide range of types:
+ * - a single e-mail address
+ * - array of e-mail addresses as values ['a@b.c', 'd@e.f']
+ * - array of user entities
+ * - array where each key is the email address, and the value is an array or a \stdClass containing
+ * pgp_key, data and user items
+ * @param string $sender
+ * @param string $subject
+ * @param UserTemplate|string $content
+ * @return void
+ */
+ static public function queue(int $context, iterable $recipients, ?string $sender, string $subject, $content, array $attachments = []): void
+ {
+ if (DISABLE_EMAIL) {
+ return;
+ }
+
+ foreach ($attachments as $i => $file) {
+ if (!is_object($file) || !($file instanceof File) || $file->context() != $file::CONTEXT_ATTACHMENTS) {
+ throw new \InvalidArgumentException(sprintf('Attachment #%d is not a valid file', $i));
+ }
+ }
+
+ $list = [];
+
+ // Build email list
+ foreach ($recipients as $key => $r) {
+ $data = [];
+ $emails = [];
+ $user = null;
+ $pgp_key = null;
+
+ if (is_array($r)) {
+ $user = $r['user'] ?? null;
+ $data = $r['data'] ?? null;
+ $pgp_key = $r['pgp_key'] ?? null;
+ }
+ elseif (is_object($r) && $r instanceof User) {
+ $user = $r;
+ $data = $r->asArray();
+ $pgp_key = $user->pgp_key ?? null;
+ }
+ elseif (is_object($r)) {
+ $user = $r->user ?? null;
+ $data = $r->data ?? null;
+ $pgp_key = $user->pgp_key ?? ($r->pgp_key ?? null);
+ }
+
+ // Get e-mail address from key
+ if (is_string($key) && false !== strpos($key, '@')) {
+ $emails[] = $key;
+ }
+ // Get e-mail address from value
+ elseif (is_string($r) && false !== strpos($r, '@')) {
+ $emails[] = $r;
+ }
+ // Get email list from user object
+ elseif ($user) {
+ $emails = $user->getEmails();
+ }
+ else {
+ // E-mail not found
+ continue;
+ }
+
+ // Filter out invalid addresses
+ foreach ($emails as $key => $value) {
+ if (!preg_match('/.+@.+\..+$/', $value)) {
+ unset($emails[$key]);
+ }
+ }
+
+ if (!count($emails)) {
+ continue;
+ }
+
+ $data = compact('user', 'data', 'pgp_key');
+
+ foreach ($emails as $value) {
+ $list[$value] = $data;
+ }
+ }
+
+ if (!count($list)) {
+ return;
+ }
+
+ $recipients = $list;
+ unset($list);
+
+ $is_system = $context === self::CONTEXT_SYSTEM;
+ $template = (!$is_system && $content instanceof UserTemplate) ? $content : null;
+
+ if ($template) {
+ $template->toggleSafeMode(true);
+ }
+
+ $signal = Plugins::fire('email.queue.before', true,
+ compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments'));
+
+ // queue handling was done by a plugin, stop here
+ if ($signal && $signal->isStopped()) {
+ return;
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+ $html = null;
+ $main_tpl = null;
+
+ // Apart from SYSTEM emails, all others should be wrapped in the email.html template
+ if (!$is_system) {
+ $main_tpl = new UserTemplate('web/email.html');
+ }
+
+ if (!$is_system && !$template) {
+ // If E-Mail does not have placeholders, we can render the MarkDown just once for HTML
+ $html = Render::render(Render::FORMAT_MARKDOWN, null, $content);
+ }
+
+ foreach ($recipients as $recipient => $r) {
+ $data = $r['data'];
+ $recipient_pgp_key = $r['pgp_key'];
+
+ // We won't try to reject invalid/optout recipients here,
+ // it's done in the queue clearing (more efficient)
+ $recipient_hash = Email::getHash($recipient);
+
+ // Replace placeholders: {{$name}}, etc.
+ if ($template) {
+ $template->assignArray((array) $data, null, false);
+
+ // Disable HTML escaping for plaintext emails
+ $template->setEscapeDefault(null);
+ $content = $template->fetch();
+
+ // Add Markdown rendering
+ $content_html = Render::render(Render::FORMAT_MARKDOWN, null, $content);
+ }
+ else {
+ $content_html = $html;
+ }
+
+ if (!$is_system) {
+ // Wrap HTML content in the email skeleton
+ $main_tpl->assignArray([
+ 'html' => $content_html,
+ 'address' => $recipient,
+ 'data' => $data,
+ 'context' => $context,
+ 'from' => $sender,
+ ]);
+
+ $content_html = $main_tpl->fetch();
+ }
+
+ $signal = Plugins::fire('email.queue.insert', true,
+ compact('context', 'recipient', 'sender', 'subject', 'content', 'recipient_hash', 'recipient_pgp_key', 'content_html', 'attachments'));
+
+ if ($signal && $signal->isStopped()) {
+ // queue insert was done by a plugin, stop here
+ continue;
+ }
+
+ unset($signal);
+
+ $db->insert('emails_queue', compact('sender', 'subject', 'context', 'recipient', 'recipient_pgp_key', 'recipient_hash', 'content', 'content_html'));
+
+ // Clean up memory
+ unset($content_html);
+
+ $id = $db->lastInsertId();
+
+ foreach ($attachments as $file) {
+ $db->insert('emails_queue_attachments', ['id_queue' => $id, 'path' => $file->path]);
+ }
+ }
+
+ $db->commit();
+
+ $signal = Plugins::fire('email.queue.after', true,
+ compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments'));
+
+ if ($signal && $signal->isStopped()) {
+ return;
+ }
+
+ // If no crontab is used, then the queue should be run now
+ if (!USE_CRON) {
+ self::runQueue();
+ }
+ // Always send system emails right away
+ elseif ($is_system) {
+ self::runQueue(self::CONTEXT_SYSTEM);
+ }
+ }
+
+ /**
+ * Return an Email entity from the optout code
+ */
+ static public function getEmailFromOptout(string $code): ?Email
+ {
+ $hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT));
+
+ if (!$hash) {
+ return null;
+ }
+
+ $hash = bin2hex($hash);
+ return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash);
+ }
+
+ /**
+ * Sets the address as invalid (no email can be sent to this address ever)
+ */
+ static public function markAddressAsInvalid(string $address): void
+ {
+ $e = self::getEmail($address);
+
+ if (!$e) {
+ return;
+ }
+
+ $e->set('invalid', true);
+ $e->set('optout', false);
+ $e->set('verified', false);
+ $e->save();
+ }
+
+ /**
+ * Return an Email entity from an email address
+ */
+ static public function getEmail(string $address): ?Email
+ {
+ return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', Email::getHash(strtolower($address)));
+ }
+
+ /**
+ * Return or create a new email entity
+ */
+ static public function getOrCreateEmail(string $address): ?Email
+ {
+ $address = strtolower($address);
+
+ if (!Email::isAddressValid($address, false)) {
+ return null;
+ }
+
+ $e = self::getEmail($address);
+
+ if (!$e) {
+ $e = self::createEmail($address);
+ }
+
+ return $e;
+ }
+
+ static public function createEmail(string $address): Email
+ {
+ $e = new Email;
+ $e->added = new \DateTime;
+ $e->hash = $e::getHash($address);
+ $e->validate($address);
+ $e->save();
+ return $e;
+ }
+
+ /**
+ * Run the queue of emails that are waiting to be sent
+ */
+ static public function runQueue(?int $context = null): ?int
+ {
+ $db = DB::getInstance();
+
+ $queue = self::listQueueAndMarkAsSending($context);
+ $ids = [];
+
+ $save_sent = function () use (&$ids, $db) {
+ if (!count($ids)) {
+ return null;
+ }
+
+ $db->exec(sprintf('UPDATE emails_queue SET sending = 2 WHERE %s;', $db->where('id', $ids)));
+ $ids = [];
+ };
+
+ $limit_time = strtotime('1 month ago');
+ $count = 0;
+ $all_attachments = [];
+
+ // listQueue nettoie déjà la queue
+ foreach ($queue as $row) {
+ // We allow system emails to be sent to invalid addresses after a while, and to optout addresses all the time
+ if ($row->optout || $row->invalid || $row->fail_count >= self::FAIL_LIMIT) {
+ if ($row->context != self::CONTEXT_SYSTEM || (!$row->optout && $row->last_sent > $limit_time)) {
+ self::deleteFromQueue($row->id);
+ continue;
+ }
+ }
+
+ // Create email address in database
+ if (!$row->email_hash) {
+ $email = self::getOrCreateEmail($row->recipient);
+
+ if (!$email || !$email->canSend()) {
+ // Email address is invalid, skip
+ self::deleteFromQueue($row->id);
+ continue;
+ }
+ }
+
+ $headers = [
+ 'From' => $row->sender,
+ 'To' => $row->recipient,
+ 'Subject' => $row->subject,
+ ];
+
+ try {
+ $attachments = $db->getAssoc('SELECT id, path FROM emails_queue_attachments WHERE id_queue = ?;', $row->id);
+ $all_attachments = array_merge($all_attachments, $attachments);
+ $sent = self::send($row->context, $row->recipient_hash, $headers, $row->content, $row->content_html, $row->recipient_pgp_key, $attachments);
+
+ // Keep waiting until email is sent
+ if (!$sent) {
+ continue;
+ }
+ }
+ catch (\Exception $e) {
+ // If sending fails, at least save what has been sent so far
+ // so they won't get re-sent again
+ $save_sent();
+ throw $e;
+ }
+
+ $ids[] = $row->id;
+ $count++;
+
+ // Mark messages as sent from time to time
+ // to avoid starting from the beginning if the queue is killed
+ // and also avoid passing too many IDs to SQLite at once
+ if (count($ids) >= 50) {
+ $save_sent();
+ }
+ }
+
+ // Update emails list and send count
+ // then delete messages from queue
+ $db->begin();
+ $db->exec(sprintf('
+ UPDATE emails_queue SET sending = 2 WHERE %s;
+ INSERT OR IGNORE INTO %s (hash) SELECT recipient_hash FROM emails_queue WHERE sending = 2;
+ UPDATE %2$s SET sent_count = sent_count + 1, last_sent = datetime()
+ WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE sending = 2);
+ DELETE FROM emails_queue WHERE sending = 2;',
+ $db->where('id', $ids),
+ Email::TABLE));
+ $db->commit();
+
+ $unused_attachments = array_diff($all_attachments, $db->getAssoc('SELECT id, path FROM emails_queue_attachments;'));
+
+ foreach ($unused_attachments as $path) {
+ $file = Files::get($path);
+
+ if ($file) {
+ $file->delete();
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Lists the queue, marks listed elements as "sending"
+ * @return array
+ */
+ static protected function listQueueAndMarkAsSending(?int $context = null): array
+ {
+ $queue = self::listQueue($context);
+
+ if (!count($queue)) {
+ return $queue;
+ }
+
+ $ids = [];
+
+ foreach ($queue as $row) {
+ $ids[] = $row->id;
+ }
+
+ $db = DB::getInstance();
+ $db->update('emails_queue', ['sending' => 1, 'sending_started' => new \DateTime], $db->where('id', $ids));
+
+ return $queue;
+ }
+
+ /**
+ * Returns the lits of emails waiting to be sent, except invalid ones and emails that haved failed too much
+ *
+ * DO NOT USE for sending, use listQueueAndMarkAsSending instead, or there might be multiple processes sending
+ * the same email over and over.
+ *
+ * @param int|null $context Context to list, leave NULL to have all contexts
+ * @return array
+ */
+ static protected function listQueue(?int $context = null): array
+ {
+ // Clean-up the queue from reject emails
+ self::purgeQueueFromRejected();
+
+ // Reset messages that failed during the queue run
+ self::resetFailed();
+
+ $condition = null === $context ? '' : sprintf(' AND context = %d', $context);
+
+ return DB::getInstance()->get(sprintf('SELECT q.*, e.optout, e.verified, e.hash AS email_hash,
+ e.invalid, e.fail_count, strftime(\'%%s\', e.last_sent) AS last_sent
+ FROM emails_queue q
+ LEFT JOIN emails e ON e.hash = q.recipient_hash
+ WHERE q.sending = 0 %s;', $condition));
+ }
+
+ static public function countQueue(): int
+ {
+ return DB::getInstance()->count('emails_queue');
+ }
+
+ /**
+ * Supprime de la queue les messages liés à des adresses invalides
+ * ou qui ne souhaitent plus recevoir de message
+ * @return boolean
+ */
+ static protected function purgeQueueFromRejected(): void
+ {
+ DB::getInstance()->delete('emails_queue',
+ 'recipient_hash IN (SELECT hash FROM emails WHERE (invalid = 1 OR fail_count >= ?)
+ AND last_sent >= datetime(\'now\', \'-1 month\'));',
+ self::FAIL_LIMIT);
+ }
+
+ /**
+ * If emails have been marked as sending but sending failed, mark them for resend after a while
+ */
+ static protected function resetFailed(): void
+ {
+ $sql = 'UPDATE emails_queue SET sending = 0, sending_started = NULL
+ WHERE sending = 1 AND sending_started < datetime(\'now\', \'-3 hours\');';
+ DB::getInstance()->exec($sql);
+ }
+
+ /**
+ * Supprime un message de la queue d'envoi
+ * @param integer $id
+ * @return boolean
+ */
+ static protected function deleteFromQueue($id)
+ {
+ return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id);
+ }
+
+ static public function getRejectionStatusClause(string $prefix): string
+ {
+ $prefix .= '.';
+
+ return sprintf('CASE
+ WHEN %1$soptout = 1 THEN \'Désinscription globale\'
+ WHEN %1$sinvalid = 1 THEN \'Invalide\'
+ WHEN %1$sfail_count >= %2$d THEN \'Trop d\'\'erreurs\'
+ ELSE \'\'
+ END', $prefix, self::FAIL_LIMIT);
+ }
+
+ static public function listRejectedUsers(): DynamicList
+ {
+ $db = DB::getInstance();
+ $email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField());
+
+ $columns = [
+ 'id' => [
+ 'select' => 'e.id',
+ ],
+ 'identity' => [
+ 'label' => 'Membre',
+ 'select' => DynamicFields::getNameFieldsSQL('u'),
+ ],
+ 'email' => [
+ 'label' => 'Adresse',
+ 'select' => $email_field,
+ ],
+ 'user_id' => [
+ 'select' => 'u.id',
+ ],
+ 'hash' => [
+ ],
+ 'status' => [
+ 'label' => 'Statut',
+ 'select' => self::getRejectionStatusClause('e'),
+ ],
+ 'sent_count' => [
+ 'label' => 'Messages envoyés',
+ ],
+ 'fail_log' => [
+ 'label' => 'Journal d\'erreurs',
+ ],
+ 'last_sent' => [
+ 'label' => 'Dernière tentative d\'envoi',
+ ],
+ 'optout' => [],
+ 'fail_count' => [],
+ ];
+
+ $tables = sprintf('emails e INNER JOIN users u ON %s IS NOT NULL AND %1$s != \'\' AND e.hash = email_hash(%1$s)', $email_field);
+
+ $conditions = sprintf('e.invalid = 1 OR e.fail_count >= %d', self::FAIL_LIMIT);
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('last_sent', true);
+ $list->setModifier(function (&$row) {
+ $row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null;
+ });
+ return $list;
+ }
+
+ static public function sendMessage(int $context, Mail_Message $message): bool
+ {
+ if (DISABLE_EMAIL) {
+ return false;
+ }
+
+ $signal = Plugins::fire('email.send.before', true, compact('context', 'message'), ['sent' => null]);
+
+ if ($signal && $signal->isStopped()) {
+ return $signal->getOut('sent') ?? true;
+ }
+
+ if (SMTP_HOST) {
+ $const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);
+ $secure = constant($const);
+
+ $smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure, SMTP_HELO_HOSTNAME);
+
+ $smtp->send($message);
+ }
+ else {
+ $message->send();
+ }
+
+ Plugins::fire('email.send.after', false, compact('context', 'message'));
+ return true;
+ }
+
+ /**
+ * Handle a bounce message
+ * @param string $raw_message Raw MIME message from SMTP
+ */
+ static public function handleBounce(string $raw_message): ?array
+ {
+ $message = new Mail_Message;
+ $message->parse($raw_message);
+
+ $return = $message->identifyBounce();
+ $address = $return['recipient'] ?? null;
+
+ $signal = Plugins::fire('email.bounce', false, compact('address', 'message', 'return', 'raw_message'));
+
+ if ($signal && $signal->isStopped()) {
+ return null;
+ }
+
+ if (!$return) {
+ return null;
+ }
+
+ if ($return['type'] == 'autoreply') {
+ // Ignore auto-responders
+ return $return;
+ }
+ elseif ($return['type'] == 'genuine') {
+ // Forward emails that are not automatic to the organization email
+ $config = Config::getInstance();
+
+ $new = new Mail_Message;
+ $new->setHeaders([
+ 'To' => $config->org_email,
+ 'Subject' => 'Réponse à un message que vous avez envoyé',
+ ]);
+
+ $new->setBody('Veuillez trouver ci-joint une réponse à un message que vous avez envoyé à un de vos membre.');
+
+ $new->attachMessage($message->output());
+
+ self::sendMessage(self::CONTEXT_SYSTEM, $new);
+ return $return;
+ }
+
+ return self::handleManualBounce($return['recipient'], $return['type'], $return['message']);
+ }
+
+ static public function handleManualBounce(string $address, string $type, ?string $message): ?array
+ {
+ $return = compact('address', 'type', 'message');
+ $email = self::getOrCreateEmail($address);
+
+ if (!$email) {
+ return null;
+ }
+
+ $email->hasFailed($return);
+ Plugins::fire('email.bounce.save.before', false, compact('email', 'return', 'type', 'message'));
+ $email->save();
+
+ return $return;
+ }
+
+
+ static public function getFromHeader(string $name = null, string $email = null): string
+ {
+ $config = Config::getInstance();
+
+ if (null === $name) {
+ $name = $config->org_name;
+ }
+ if (null === $email) {
+ $email = $config->org_email;
+ }
+
+ $name = str_replace('"', '\\"', $name);
+ $name = str_replace(',', '', $name); // Remove commas
+
+ return sprintf('"%s" <%s>', $name, $email);
+ }
+
+}
diff --git a/src/include/lib/Paheko/Email/Mailings.php b/src/include/lib/Paheko/Email/Mailings.php
new file mode 100644
index 0000000..0feaae3
--- /dev/null
+++ b/src/include/lib/Paheko/Email/Mailings.php
@@ -0,0 +1,183 @@
+ [
+ ],
+ 'subject' => [
+ 'label' => 'Sujet',
+ ],
+ 'nb_recipients' => [
+ 'label' => 'Destinataires',
+ 'select' => '(SELECT COUNT(*) FROM mailings_recipients WHERE id_mailing = mailings.id)',
+ ],
+ 'sent' => [
+ 'label' => 'Date d\'envoi',
+ 'order' => 'id %s',
+ ],
+ ];
+
+ $tables = 'mailings';
+
+ $list = new DynamicList($columns, $tables);
+ $list->orderBy('sent', true);
+ return $list;
+ }
+
+ static public function get(int $id): ?Mailing
+ {
+ return EntityManager::findOneById(Mailing::class, $id);
+ }
+
+ static public function create(string $subject, string $target_type, ?string $target_value, ?string $target_label): Mailing
+ {
+ $db = DB::getInstance();
+ $db->begin();
+
+ $m = new Mailing;
+ $m->set('subject', $subject);
+ $m->importForm(compact('subject', 'target_type', 'target_value', 'target_label'));
+ $m->save();
+ $m->populate();
+
+ $db->commit();
+ return $m;
+ }
+
+ static public function anonymize(): void
+ {
+ $em = EntityManager::getInstance(Mailing::class);
+ $db = DB::getInstance();
+
+ $db->begin();
+ foreach ($em->iterate('SELECT * FROM @TABLE WHERE sent < datetime(\'now\', \'-6 month\') AND anonymous = 0;') as $m) {
+ $m->anonymize();
+ $m->set('anonymous', true);
+ $m->save();
+ }
+
+ $db->commit();
+ }
+
+ static public function listTargets(string $type): array
+ {
+ if ($type === 'field') {
+ $list = self::listCheckboxFieldsTargets();
+ }
+ elseif ($type === 'category') {
+ $list = Categories::listWithStats(Categories::WITHOUT_HIDDEN);
+ }
+ elseif ($type === 'service') {
+ $list = iterator_to_array(Services::listWithStats(true)->iterate());
+ }
+ elseif ($type === 'search') {
+ $list = Search::list(SearchEntity::TARGET_USERS, Session::getUserId());
+ $list = array_filter($list, fn($s) => $s->hasUserId());
+ array_walk($search_list, function (&$s) {
+ $s = (object) ['label' => $s->label, 'id' => $s->id, 'count' => $s->countResults()];
+ });
+
+ }
+ else {
+ throw new \InvalidArgumentException('Unknown target type: ' . $type);
+ }
+
+ if (!count($list)) {
+ throw new UserException('Il n\'y aucun résultat correspondant à cette cible d\'envoi.');
+ }
+
+ return $list;
+ }
+
+ static public function listCheckboxFieldsTargets(): array
+ {
+ $fields = DynamicFields::getInstance()->fieldsByType('checkbox');
+
+ if (!count($fields)) {
+ return [];
+ }
+
+ $db = DB::getInstance();
+ $sql = [];
+
+ foreach ($fields as $field) {
+ $sql[] = sprintf('SELECT %s AS name, %s AS label, COUNT(*) AS count FROM users WHERE %s = 1 AND id_category IN (SELECT id FROM users_categories WHERE hidden = 0)',
+ $db->quote($field->name),
+ $db->quote($field->label),
+ $db->quoteIdentifier($field->name)
+ );
+ }
+
+ $sql = implode(' UNION ALL ', $sql);
+ return $db->get($sql);
+ }
+
+ static public function getOptoutUsersList(): DynamicList
+ {
+ $db = DB::getInstance();
+ $email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField());
+
+ $columns = [
+ 'id' => [
+ 'select' => 'e.id',
+ ],
+ 'identity' => [
+ 'label' => 'Membre',
+ 'select' => DynamicFields::getNameFieldsSQL('u'),
+ ],
+ 'email' => [
+ 'label' => 'Adresse',
+ 'select' => $email_field,
+ ],
+ 'user_id' => [
+ 'select' => 'u.id',
+ ],
+ 'hash' => [
+ ],
+ 'status' => [
+ 'label' => 'Désinscription',
+ 'select' => 'CASE WHEN e.optout = 1 THEN \'Désinscription globale\' ELSE o.target_label END',
+ ],
+ 'sent_count' => [
+ 'label' => 'Messages envoyés',
+ ],
+ 'last_sent' => [
+ 'label' => 'Dernière tentative d\'envoi',
+ ],
+ 'optout' => [],
+ 'target_type' => [],
+ 'target_label' => [],
+ ];
+
+ $tables = sprintf('users u
+ INNER JOIN emails e ON e.hash = email_hash(%1$s)
+ LEFT JOIN mailings_optouts o ON o.email_hash = e.hash', $email_field);
+
+ $conditions = sprintf('%s IS NOT NULL AND %1$s != \'\' AND (e.optout = 1 OR o.email_hash IS NOT NULL)', $email_field);
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('last_sent', true);
+ $list->setModifier(function (&$row) {
+ $row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null;
+ });
+ return $list;
+ }
+
+}
diff --git a/src/include/lib/Paheko/Email/Templates.php b/src/include/lib/Paheko/Email/Templates.php
new file mode 100644
index 0000000..776a02c
--- /dev/null
+++ b/src/include/lib/Paheko/Email/Templates.php
@@ -0,0 +1,53 @@
+assign($variables);
+ $tpl->setEscapeType('disable');
+ $body = trim($tpl->fetch('emails/' . $template));
+ $subject = $tpl->getTemplateVars('subject');
+
+ if (!$subject) {
+ throw new \LogicException('Template did not define a subject');
+ }
+
+ Emails::queue(Emails::CONTEXT_SYSTEM, (array)$to, null, $subject, $body);
+ }
+
+ static public function loginChanged(User $user): void
+ {
+ $login_field = DynamicFields::getLoginField();
+ self::send($user, 'login_changed.tpl', ['new_login' => $user->$login_field]);
+ }
+
+ static public function passwordRecovery(string $email, string $recovery_url, ?string $pgp_key): void
+ {
+ self::send([$email => compact('pgp_key')], 'password_recovery.tpl', compact('recovery_url'));
+ }
+
+ static public function passwordChanged(User $user): void
+ {
+ $ip = Utils::getIP();
+ $login_field = DynamicFields::getLoginField();
+ $login = $user->$login_field;
+ self::send($user, 'password_changed.tpl', compact('ip', 'login'));
+ }
+
+ static public function verifyAddress(string $email, string $verify_url): void
+ {
+ self::send($email, 'verify_email.tpl', compact('verify_url'));
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/API_Credentials.php b/src/include/lib/Paheko/Entities/API_Credentials.php
new file mode 100644
index 0000000..c1aaafc
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/API_Credentials.php
@@ -0,0 +1,39 @@
+ 'Peut lire les données',
+ Session::ACCESS_WRITE => 'Peut lire et modifier les données',
+ Session::ACCESS_ADMIN => 'Peut tout faire, y compris supprimer les données',
+ ];
+
+ public function selfCheck(): void
+ {
+ parent::selfCheck();
+
+ $this->assert(trim($this->label) !== '', 'La description ne peut être laissée vide.');
+ $this->assert(trim($this->key) !== '', 'La clé ne peut être laissée vide.');
+ $this->assert(trim($this->secret) !== '', 'Le secret ne peut être laissé vide.');
+ $this->assert(array_key_exists($this->access_level, self::ACCESS_LEVELS));
+
+ $this->assert(preg_match('/^[a-z0-9_]+$/', $this->key), 'L\'identifiant ne peut contenir que des lettres, des chiffres et des tirets bas.');
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Accounting/Account.php b/src/include/lib/Paheko/Entities/Accounting/Account.php
new file mode 100644
index 0000000..e471936
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/Account.php
@@ -0,0 +1,959 @@
+ [
+ '^1' => self::LIABILITY,
+ '^2' => self::ASSET,
+ '^3' => self::ASSET,
+ '^4' => self::ASSET_OR_LIABILITY,
+ '^5' => self::ASSET_OR_LIABILITY,
+ '^6' => self::EXPENSE,
+ '^7' => self::REVENUE,
+ '^86' => self::EXPENSE,
+ '^87' => self::REVENUE,
+ ],
+ 'BE' => [
+ '^69' => self::ASSET_OR_LIABILITY,
+ '^6' => self::EXPENSE,
+ '^79' => self::ASSET_OR_LIABILITY,
+ '^7' => self::REVENUE,
+ '^5' => self::ASSET_OR_LIABILITY,
+ '^4' => self::ASSET_OR_LIABILITY,
+ '^3' => self::ASSET,
+ '^2' => self::ASSET,
+ '^1' => self::LIABILITY,
+ ],
+ 'CH' => [
+ '^1' => self::ASSET,
+ '^2' => self::LIABILITY,
+ '^3(?!910)|^4910' => self::EXPENSE,
+ '^4(?!910)|^3910' => self::REVENUE,
+ '^5' => self::ASSET_OR_LIABILITY,
+ ],
+ ];
+
+ /**
+ * Codes that should be enforced according to type (and vice-versa)
+ */
+ const LOCAL_TYPES = [
+ 'FR' => [
+ self::TYPE_BANK => '512',
+ self::TYPE_TEMPORARY_TRANSFER => '580',
+ self::TYPE_CASH => '53',
+ self::TYPE_OUTSTANDING => '511',
+ self::TYPE_THIRD_PARTY => '4',
+ self::TYPE_EXPENSE => '6',
+ self::TYPE_REVENUE => '7',
+ self::TYPE_VOLUNTEERING_EXPENSE => '86',
+ self::TYPE_VOLUNTEERING_REVENUE => '87',
+ self::TYPE_OPENING => '890',
+ self::TYPE_CLOSING => '891',
+ self::TYPE_POSITIVE_RESULT => '120',
+ self::TYPE_NEGATIVE_RESULT => '129',
+ self::TYPE_APPROPRIATION_RESULT => '1068',
+ self::TYPE_CREDIT_REPORT => '110',
+ self::TYPE_DEBIT_REPORT => '119',
+ ],
+ 'BE' => [
+ self::TYPE_APPROPRIATION_RESULT => '139',
+ self::TYPE_CREDIT_REPORT => '4931',
+ self::TYPE_DEBIT_REPORT => '4932',
+ self::TYPE_BANK => '56',
+ self::TYPE_CASH => '570',
+ self::TYPE_OUTSTANDING => '499',
+ self::TYPE_EXPENSE => '6',
+ self::TYPE_REVENUE => '7',
+ self::TYPE_POSITIVE_RESULT => '692',
+ self::TYPE_NEGATIVE_RESULT => '690',
+ self::TYPE_THIRD_PARTY => '4',
+ self::TYPE_OPENING => '890',
+ self::TYPE_CLOSING => '891',
+ ],
+ 'CH' => [
+ self::TYPE_BANK => '102',
+ self::TYPE_CASH => '100',
+ self::TYPE_OUTSTANDING => '109',
+ self::TYPE_THIRD_PARTY => '5',
+ self::TYPE_EXPENSE => '3',
+ self::TYPE_REVENUE => '4',
+ self::TYPE_OPENING => '9100',
+ self::TYPE_CLOSING => '9101',
+ self::TYPE_POSITIVE_RESULT => '29991',
+ self::TYPE_NEGATIVE_RESULT => '29999',
+ self::TYPE_APPROPRIATION_RESULT => '2910',
+ self::TYPE_CREDIT_REPORT => '2990',
+ self::TYPE_DEBIT_REPORT => '2990',
+ ],
+ ];
+
+ const LIST_COLUMNS = [
+ 'id' => [
+ 'select' => 't.id',
+ 'label' => 'N°',
+ ],
+ 'id_line' => [
+ 'select' => 'l.id',
+ ],
+ 'date' => [
+ 'label' => 'Date',
+ 'select' => 't.date',
+ 'order' => 'date %s, id %1$s',
+ ],
+ 'debit' => [
+ 'select' => 'l.debit',
+ 'label' => 'Débit',
+ ],
+ 'credit' => [
+ 'select' => 'l.credit',
+ 'label' => 'Crédit',
+ ],
+ 'change' => [
+ 'select' => '(l.debit - l.credit) * %d',
+ 'label' => 'Mouvement',
+ ],
+ 'sum' => [
+ 'select' => NULL,
+ 'label' => 'Solde cumulé',
+ 'only_with_order' => 'date',
+ ],
+ 'reference' => [
+ 'label' => 'Pièce comptable',
+ 'select' => 't.reference',
+ ],
+ 'type' => [
+ 'select' => 't.type',
+ ],
+ 'label' => [
+ 'select' => 't.label',
+ 'label' => 'Libellé',
+ ],
+ 'line_label' => [
+ 'select' => 'l.label',
+ 'label' => 'Libellé ligne'
+ ],
+ 'line_reference' => [
+ 'label' => 'Réf. ligne',
+ 'select' => 'l.reference',
+ ],
+ 'id_project' => [
+ 'select' => 'l.id_project',
+ ],
+ 'project_code' => [
+ 'select' => 'IFNULL(p.code, SUBSTR(p.label, 1, 10) || \'…\')',
+ 'label' => 'Projet',
+ ],
+ 'locked' => [
+ 'label' => 'Verrouillée',
+ 'header_icon' => 'lock',
+ 'select' => 'CASE WHEN t.hash IS NOT NULL THEN \'Oui\' ELSE \'\' END',
+ ],
+ 'files' => [
+ 'label' => 'Fichiers joints',
+ 'header_icon' => 'attach',
+ 'select' => '(SELECT COUNT(tf.id_file) FROM acc_transactions_files tf INNER JOIN files f ON f.id = tf.id_file WHERE tf.id_transaction = t.id AND f.trash IS NULL)',
+ ],
+ 'status' => [
+ 'select' => 't.status',
+ ],
+ ];
+
+ protected ?int $id;
+ protected int $id_chart;
+ protected string $code;
+ protected string $label;
+ protected ?string $description;
+ protected int $position = 0;
+ protected int $type;
+ protected bool $user = false;
+ protected bool $bookmark = false;
+
+ protected $_position = [];
+ protected ?Chart $_chart = null;
+
+ static protected ?array $_charts;
+
+ public function selfCheck(): void
+ {
+ $db = DB::getInstance();
+
+ $this->assert(trim($this->code) !== '', 'Le numéro de compte ne peut rester vide.');
+ $this->assert(trim($this->label) !== '', 'L\'intitulé de compte ne peut rester vide.');
+
+ // Only enforce code limits if the account is new, or if the code is changed
+ if (!$this->exists() || $this->isModified('code')) {
+ $this->assert(strlen($this->code) <= 20, 'Le numéro de compte ne peut faire plus de 20 caractères.');
+ $this->assert(preg_match('/^[a-z0-9_]+$/i', $this->code), 'Le numéro de compte ne peut comporter que des lettres et des chiffres.');
+ }
+
+ $this->assert(strlen($this->label) <= 200, 'L\'intitulé de compte ne peut faire plus de 200 caractères.');
+ $this->assert(!isset($this->description) || strlen($this->description) <= 2000, 'La description de compte ne peut faire plus de 2000 caractères.');
+
+ $this->assert(!empty($this->id_chart), 'Aucun plan comptable lié');
+
+ $where = 'code = ? AND id_chart = ?';
+ $where .= $this->exists() ? sprintf(' AND id != %d', $this->id()) : '';
+
+ if ($db->test(self::TABLE, $where, $this->code, $this->id_chart)) {
+ throw new ValidationException(sprintf('Le numéro "%s" est déjà utilisé par un autre compte.', $this->code));
+ }
+
+ $this->assert(isset($this->type));
+
+ $this->checkLocalRules();
+
+ $this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type invalide: ' . $this->type);
+ $this->assert(array_key_exists($this->position, self::POSITIONS_NAMES), 'Position invalide');
+
+ parent::selfCheck();
+ }
+
+ protected function getCountry(): ?string
+ {
+ if (!isset(self::$_charts)) {
+ self::$_charts = DB::getInstance()->getGrouped('SELECT id, country, code FROM acc_charts;');
+ }
+
+ return self::$_charts[$this->id_chart]->country ?? null;
+ }
+
+ protected function isChartOfficial(): bool
+ {
+ $country = $this->getCountry();
+ return !empty(self::$_charts[$this->id_chart]->code);
+ }
+
+ /**
+ * This sets the account position according to local rules
+ * if the chart is linked to a country, but only
+ * if the account is user-created, or if the chart is non-official
+ */
+ protected function getLocalPosition(string $country = null): ?int
+ {
+ if (!func_num_args()) {
+ $country = $this->getCountry();
+ }
+
+ $is_official = $this->isChartOfficial();
+
+ if (!$country) {
+ return null;
+ }
+
+ // Do not change position of official chart accounts
+ if (!$this->user && $is_official) {
+ return null;
+ }
+
+ foreach (self::LOCAL_POSITIONS[$country] as $pattern => $position) {
+ if (preg_match('/' . $pattern . '/', $this->code)) {
+ return $position;
+ }
+ }
+
+ return null;
+ }
+
+ protected function getLocalType(string $country = null): int
+ {
+ if (!func_num_args()) {
+ $country = $this->getCountry();
+ }
+
+ if (!$country) {
+ return self::TYPE_NONE;
+ }
+
+ foreach (self::LOCAL_TYPES[$country] as $type => $number) {
+ if ($this->matchType($type, $country)) {
+ return $type;
+ }
+ }
+
+ return self::TYPE_NONE;
+ }
+
+ protected function matchType(int $type, string $country = null): bool
+ {
+ if (func_num_args() < 2) {
+ $country = $this->getCountry();
+ }
+
+ $pattern = self::LOCAL_TYPES[$country][$type] ?? null;
+
+ if (!$pattern) {
+ return false;
+ }
+
+ if (in_array($type, self::COMMON_TYPES)) {
+ $pattern = sprintf('/^%s.+/', $pattern);
+ }
+ else {
+ $pattern = sprintf('/^%s$/', $pattern);
+ }
+
+ return (bool) preg_match($pattern, $this->code);
+ }
+
+ public function setLocalRules(string $country = null): void
+ {
+ if (!func_num_args()) {
+ $country = $this->getCountry();
+ }
+
+ if (!$country) {
+ $this->set('type', 0);
+ return;
+ }
+
+ $this->set('type', $this->getLocalType($country));
+
+ if (null !== ($p = $this->getLocalPosition($country))) {
+ // If the allowed local position is asset OR liability, we allow either one of those 3 choices
+ if ($p != self::ASSET_OR_LIABILITY
+ || !in_array($this->position, [self::ASSET_OR_LIABILITY, self::ASSET, self::LIABILITY])) {
+ $this->set('position', $p);
+ }
+ }
+
+ if (!isset($this->type)) {
+ $this->set('type', 0);
+ }
+ }
+
+ public function checkLocalRules(): void
+ {
+ $country = $this->getCountry();
+
+ if (!$this->type) {
+ return;
+ }
+
+ if (!isset(self::LOCAL_TYPES[$country][$this->type])) {
+ return;
+ }
+
+ $this->assert($this->matchType($this->type), sprintf('Compte "%s - %s" : le numéro des comptes de type "%s" doit commencer par "%s" (%s).', $this->code, $this->label, self::TYPES_NAMES[$this->type], self::LOCAL_TYPES[$country][$this->type], $this->code));
+ }
+
+ public function getNewNumberAvailable(?string $base = null): ?string
+ {
+ $base ??= $this->getNumberBase();
+
+ if (!$base) {
+ return $base;
+ }
+
+ $pattern = $base . '_%';
+
+ $db = DB::getInstance();
+ $used_codes = $db->getAssoc(sprintf('SELECT code, code FROM %s WHERE code LIKE ? AND id_chart = ?;', Account::TABLE), $pattern, $this->id_chart);
+ $used_codes = array_values($used_codes);
+ $used_codes = array_map(fn($a) => substr($a, strlen($base)), $used_codes);
+
+ $count = $db->count(Account::TABLE, 'id_chart = ? AND code LIKE ?', $this->id_chart, $pattern);
+ $letter = null;
+
+ // Make sure we don't reuse an existing code
+ while (!$letter || in_array($letter, $used_codes)) {
+ // Get new account code, eg. 512A, 99AA, 99BZ etc.
+ $letter = Utils::num2alpha($count++);
+ }
+
+ return $letter;
+ }
+
+ public function getNumberUserPart(): ?string
+ {
+ $base = $this->getNumberBase();
+
+ if (!$base) {
+ return $base;
+ }
+
+ return substr($this->code, strlen($base));
+ }
+
+ public function getNumberBase(): ?string
+ {
+ if (!$this->type) {
+ return null;
+ }
+
+ $country = $this->getCountry();
+
+ if (!isset(self::LOCAL_TYPES[$country][$this->type])) {
+ return null;
+ }
+
+
+ return self::LOCAL_TYPES[$country][$this->type];
+ }
+
+ public function listJournal(int $year_id, bool $simple = false, ?DateTimeInterface $start = null, ?DateTimeInterface $end = null)
+ {
+ $db = DB::getInstance();
+ $columns = self::LIST_COLUMNS;
+
+ // Don't show locked column if no transactions are locked
+ if (!$db->test('acc_transactions', 'hash IS NOT NULL')) {
+ unset($columns['locked']);
+ }
+
+ $tables = 'acc_transactions_lines l
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ LEFT JOIN acc_projects p ON p.id = l.id_project';
+ $conditions = sprintf('l.id_account = %d AND t.id_year = %d', $this->id(), $year_id);
+
+ $sum = null;
+ $reverse = $this->isReversed($simple, $year_id) ? -1 : 1;
+
+ if ($start) {
+ $conditions .= sprintf(' AND t.date >= %s', $db->quote($start->format('Y-m-d')));
+ }
+
+ if ($end) {
+ $conditions .= sprintf(' AND t.date <= %s', $db->quote($end->format('Y-m-d')));
+ }
+
+ $columns['change']['select'] = sprintf($columns['change']['select'], $reverse);
+
+ if ($simple) {
+ unset($columns['debit']['label'], $columns['credit']['label'], $columns['line_label']['label']);
+ $columns['line_reference']['label'] = 'Réf. paiement';
+ }
+ else {
+ unset($columns['change']['label']);
+ }
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('date', true);
+ $list->setCount('COUNT(*)');
+ $list->setPageSize(null); // Because with paging we can't calculate the running sum
+ $list->setModifier(function (&$row) use (&$sum, &$list, $reverse, $year_id, $start, $end) {
+ if (property_exists($row, 'sum')) {
+ // Reverse running sum needs the last sum, first
+ if ($list->desc && null === $sum) {
+ $sum = $this->getSumAtDate($year_id, ($end ?? new \DateTime($row->date))->modify('+1 day')) * -1 * $reverse;
+ }
+ elseif (!$list->desc) {
+ if (null === $sum && $start) {
+ $sum = $this->getSumAtDate($year_id, $start) * -1 * $reverse;
+ }
+
+ $sum += $row->change;
+ }
+
+ $row->sum = $sum;
+
+ if ($list->desc) {
+ $sum -= $row->change;
+ }
+ }
+
+ $row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
+ });
+
+ $list->setExportCallback(function (&$row) {
+ static $columns = ['change', 'sum', 'credit', 'debit'];
+ foreach ($columns as $key) {
+ if (isset($row->$key)) {
+ $row->$key = Utils::money_format($row->$key, '.', '', false);
+ }
+ }
+ });
+
+ return $list;
+ }
+
+ /**
+ * Renvoie TRUE si le solde du compte est inversé en vue simplifiée (= crédit - débit, au lieu de débit - crédit)
+ * @return boolean
+ */
+ public function isReversed(bool $simple, int $id_year): bool
+ {
+ $is_reversed = Accounts::isReversed($simple, $this->type);
+
+ if (!$is_reversed) {
+ return $is_reversed;
+ }
+
+ $position = $this->getPosition($id_year);
+
+ if ($position == self::ASSET || $position == self::EXPENSE) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getPosition(int $id_year): int
+ {
+ $position = $this->_position[$id_year] ?? $this->position;
+
+ if ($position == self::ASSET_OR_LIABILITY) {
+ $balance = DB::getInstance()->firstColumn('SELECT debit - credit FROM acc_accounts_balances WHERE id = ? AND id_year = ?;', $this->id, $id_year);
+ $position = $balance > 0 ? self::ASSET : self::LIABILITY;
+ }
+
+ $this->_position[$id_year] = $position;
+
+ return $position;
+ }
+
+ public function getReconcileJournal(int $year_id, DateTimeInterface $start_date, DateTimeInterface $end_date, bool $only_non_reconciled = false)
+ {
+ if ($end_date < $start_date) {
+ throw new ValidationException('La date de début ne peut être avant la date de fin.');
+ }
+
+ $condition = $only_non_reconciled ? ' AND l.reconciled = 0' : '';
+
+ $db = DB::getInstance();
+ $sql = 'SELECT l.debit, l.credit, t.id, t.date, t.reference, l.reference AS line_reference, t.label, l.label AS line_label, l.reconciled, l.id AS id_line
+ FROM acc_transactions_lines l
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ WHERE l.id_account = ? AND t.id_year = ? AND t.date >= ? AND t.date <= ? %s
+ ORDER BY t.date, t.id;';
+ $rows = $db->iterate(sprintf($sql, $condition), $this->id(), $year_id, $start_date->format('Y-m-d'), $end_date->format('Y-m-d'));
+
+ $sum = $this->getSumAtDate($year_id, $start_date);
+ $reconciled_sum = $this->getSumAtDate($year_id, $start_date, true);
+
+ $start_sum = false;
+
+ foreach ($rows as $row) {
+ if (!$start_sum) {
+ yield (object) ['sum' => $sum, 'date' => $start_date];
+ $start_sum = true;
+ }
+
+ $row->date = \DateTime::createFromFormat('Y-m-d', $row->date);
+ $sum += ($row->credit - $row->debit);
+ $row->running_sum = $sum;
+
+ if ($row->reconciled) {
+ $reconciled_sum += ($row->credit - $row->debit);
+ }
+
+ $row->reconciled_sum = $reconciled_sum;
+
+ yield $row;
+ }
+
+ if (!$only_non_reconciled) {
+ yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date];
+ }
+ }
+
+ public function getDepositJournal(int $year_id, array $checked = []): DynamicList
+ {
+ $columns = [
+ 'id' => [
+ 'label' => 'Num.',
+ 'select' => 't.id',
+ ],
+ 'date' => [
+ 'select' => 't.date',
+ 'label' => 'Date',
+ 'order' => 't.date %s, t.id %1$s',
+ ],
+ 'reference' => [
+ 'select' => 't.reference',
+ 'label' => 'Réf. écriture',
+ ],
+ 'line_reference' => [
+ 'select' => 'l.reference',
+ 'label' => 'Réf. paiement',
+ ],
+ 'label' => [
+ 'label' => 'Libellé',
+ 'select' => 't.label',
+ ],
+ 'amount' => [
+ 'label' => 'Montant',
+ 'select' => 'l.debit',
+ ],
+ 'running_sum' => [
+ 'label' => 'Solde cumulé',
+ 'only_with_order' => 'date',
+ 'select' => null,
+ ],
+ 'credit' => [
+ 'select' => 'l.credit',
+ ],
+ 'debit' => [
+ 'select' => 'l.debit',
+ ],
+ 'id_account' => [
+ 'select' => 'l.id_account',
+ ],
+ 'id_line' => [
+ 'select' => 'l.id',
+ ],
+ 'id_project' => [
+ 'select' => 'l.id_project',
+ ],
+ ];
+
+ $tables = 'acc_transactions_lines l INNER JOIN acc_transactions t ON t.id = l.id_transaction';
+ $conditions = sprintf('t.id_year = %d AND l.id_account = %d AND l.credit = 0 AND NOT (t.status & %d)',
+ $year_id, $this->id(), Transaction::STATUS_DEPOSIT);
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->setPageSize(null);
+ $list->orderBy('date', true);
+ $list->setModifier(function (&$row) use (&$sum, $checked) {
+ $sum += ($row->credit - $row->debit);
+ $row->running_sum = $sum;
+ $row->checked = array_key_exists($row->id, $checked);
+ });
+
+ return $list;
+ }
+
+ public function getDepositMissingBalance(int $year_id): int
+ {
+ $deposit_balance = DB::getInstance()->firstColumn('SELECT SUM(l.debit)
+ FROM acc_transactions_lines l
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
+ ORDER BY t.date, t.id;',
+ $year_id, $this->id(), Transaction::STATUS_DEPOSIT);
+ $account_balance = $this->getSum($year_id)->balance;
+
+ return $account_balance - $deposit_balance;
+ }
+
+ public function getSum(int $year_id, bool $simple = false): ?\stdClass
+ {
+ $sum = DB::getInstance()->first('SELECT balance, credit, debit
+ FROM acc_accounts_balances
+ WHERE id = ? AND id_year = ?;', $this->id(), $year_id);
+
+ return $sum ?: null;
+ }
+
+
+ public function getSumAtDate(int $year_id, DateTimeInterface $date, bool $reconciled_only = false): int
+ {
+ $sql = sprintf('SELECT SUM(l.credit) - SUM(l.debit)
+ FROM acc_transactions_lines l
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ WHERE l.id_account = ? AND t.id_year = ? AND t.date < ? %s;',
+ $reconciled_only ? 'AND l.reconciled = 1' : '');
+ return (int) DB::getInstance()->firstColumn($sql, $this->id(), $year_id, $date->format('Y-m-d'));
+ }
+
+ public function importLimitedForm(?array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ $data = array_intersect_key($source, array_flip(['type', 'description']));
+ parent::import($data);
+ }
+
+ public function canDelete(): bool
+ {
+ if ($this->chart()->code && !$this->user) {
+ return false;
+ }
+
+ return !DB::getInstance()->firstColumn(sprintf('SELECT 1 FROM %s WHERE id_account = ? LIMIT 1;', Line::TABLE), $this->id());
+ }
+
+ /**
+ * An account properties (position, label and code) can only be changed if:
+ * * it's either a user-created account or an account part of a user-created chart
+ * * has no transactions in a closed year
+ * @return bool
+ */
+ public function canEdit(): bool
+ {
+ if (!$this->exists()) {
+ return true;
+ }
+
+ $db = DB::getInstance();
+ $is_user = $this->user ?: $db->test(Chart::TABLE, 'id = ? AND code IS NULL', $this->id_chart);
+
+ if (!$is_user) {
+ return false;
+ }
+
+ $sql = sprintf('SELECT 1 FROM %s l
+ INNER JOIN %s t ON t.id = l.id_transaction
+ INNER JOIN %s y ON y.id = t.id_year
+ WHERE l.id_account = ? AND y.closed = 1
+ LIMIT 1;', Line::TABLE, Transaction::TABLE, Year::TABLE);
+ $has_transactions_in_closed_year = $db->firstColumn($sql, $this->id());
+
+ if ($has_transactions_in_closed_year) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * We can set account position if:
+ * - account is not in a supported chart country
+ * - account is not part of an official chart
+ * - account is not affected by local position rules
+ */
+ public function canSetPosition(): bool
+ {
+ if (!$this->getCountry()) {
+ return true;
+ }
+
+ if ($this->isChartOfficial() && !$this->user) {
+ return false;
+ }
+
+ if ($this->type || $this->getLocalType()) {
+ return false;
+ }
+
+ if (null !== $this->getLocalPosition()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * We can set account asset or liability if:
+ * - local position rules allow for asset or liability
+ */
+ public function canSetAssetOrLiabilityPosition(): bool
+ {
+ if (!$this->getCountry()) {
+ return true;
+ }
+
+ if ($this->isChartOfficial() && !$this->user) {
+ return false;
+ }
+
+ $type = $this->type ?: $this->getLocalType();
+
+ if ($type == self::TYPE_THIRD_PARTY) {
+ return true;
+ }
+ elseif ($type) {
+ return false;
+ }
+
+ $position = $this->getLocalPosition();
+
+ if ($position == self::ASSET_OR_LIABILITY) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function chart(): Chart
+ {
+ $this->_chart ??= Charts::get($this->id_chart);
+ return $this->_chart;
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ $this->setLocalRules();
+ DB::getInstance()->exec(sprintf('REPLACE INTO config (key, value) VALUES (\'last_chart_change\', %d);', time()));
+
+ return parent::save($selfcheck);
+ }
+
+ public function position_name(): string
+ {
+ return self::POSITIONS_NAMES[$this->position];
+ }
+
+ public function type_name(): string
+ {
+ return self::TYPES_NAMES[$this->type];
+ }
+
+ public function importForm(array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ if (isset($source['code_value'], $source['code_base'])) {
+ $source['code'] = trim($source['code_base']) . trim($source['code_value']);
+ }
+
+ parent::importForm($source);
+ }
+
+ public function level(): int
+ {
+ $level = strlen($this->code);
+
+ if ($level > 6) {
+ $level = 6;
+ }
+
+ return $level;
+ }
+
+ public function isListedAsFavourite(): bool
+ {
+ if ($this->bookmark) {
+ return true;
+ }
+
+ if ($this->user) {
+ return true;
+ }
+
+ return DB::getInstance()->test('acc_transactions_lines', 'id_account = ?', $this->id);
+ }
+
+ public function createOpeningBalance(Year $year, int $amount, ?string $label = null): Transaction
+ {
+ $accounts = $year->accounts();
+ $opening_account = $accounts->getOpeningAccountId();
+
+ if (!$opening_account) {
+ throw new UserException('Impossible de créer la balance d\'ouverture : le plan comptable sélectionné n\'a pas de compte 890 — Balance d\'ouverture.');
+ }
+
+ $t = new Transaction;
+ $t->label = $label ?? 'Solde d\'ouverture du compte';
+ $t->date = clone $year->start_date;
+ $t->type = $t::TYPE_ADVANCED;
+ $t->notes = 'Créé automatiquement à l\'ajout du compte';
+ $t->id_year = $year->id;
+
+
+ $credit = $amount > 0 ? 0 : abs($amount);
+ $debit = $amount < 0 ? 0 : abs($amount);
+ $t->addLine(Line::create($this->id(), $credit, $debit));
+ $t->addLine(Line::create($opening_account, $debit, $credit));
+ return $t;
+ }
+
+}
diff --git a/src/include/lib/Paheko/Entities/Accounting/Chart.php b/src/include/lib/Paheko/Entities/Accounting/Chart.php
new file mode 100644
index 0000000..9088734
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/Chart.php
@@ -0,0 +1,192 @@
+ 'Belgique',
+ 'FR' => 'France',
+ 'CH' => 'Suisse',
+ ];
+
+ const REQUIRED_COLUMNS = ['code', 'label', 'description', 'position', 'bookmark'];
+
+ const COLUMNS = [
+ 'code' => 'Numéro',
+ 'label' => 'Libellé',
+ 'description' => 'Description',
+ 'position' => 'Position',
+ 'added' => 'Ajouté',
+ 'bookmark' => 'Favori',
+ ];
+
+ public function selfCheck(): void
+ {
+ $this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide.');
+ $this->assert(strlen($this->label) <= 200, 'Le libellé ne peut faire plus de 200 caractères.');
+ $this->assert(null === $this->country || array_key_exists($this->country, self::COUNTRY_LIST), 'Pays inconnu');
+ parent::selfCheck();
+ }
+
+ public function accounts()
+ {
+ return new Accounts($this->id());
+ }
+
+ public function importForm(array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ // Don't allow to change country
+ if (isset($this->code)) {
+ unset($source['country']);
+ }
+
+ unset($source['code']);
+
+ return Entity::importForm($source);
+ }
+
+ public function canDelete()
+ {
+ return !DB::getInstance()->firstColumn(sprintf('SELECT 1 FROM %s WHERE id_chart = ? LIMIT 1;', Year::TABLE), $this->id());
+ }
+
+ public function importCSV(string $file, bool $update = false): void
+ {
+ $db = DB::getInstance();
+ $positions = array_flip(Account::POSITIONS_NAMES);
+ $types = array_flip(Account::TYPES_NAMES);
+
+ $db->begin();
+
+ try {
+ foreach (CSV::import($file, self::COLUMNS, self::REQUIRED_COLUMNS) as $line => $row) {
+ $account = null;
+
+ if ($update) {
+ $account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE code = ? AND id_chart = ?;', $row['code'], $this->id());
+ }
+
+ if (!$account) {
+ $account = new Account;
+ $account->id_chart = $this->id();
+ }
+
+ try {
+ if (!isset($positions[$row['position']])) {
+ throw new ValidationException('Position inconnue : ' . $row['position']);
+ }
+ // Don't update user-set values
+ if ($account->exists()) {
+ unset($row['bookmark'], $row['description']);
+ }
+ else {
+ $row['user'] = !empty($row['added']);
+ $row['bookmark'] = !empty($row['bookmark']);
+ }
+
+ $row['position'] = $positions[$row['position']];
+
+ $account->importForm($row);
+ $account->save();
+ }
+ catch (ValidationException $e) {
+ throw new UserException(sprintf('Ligne %d : %s', $line, $e->getMessage()));
+ }
+ }
+
+ $db->commit();
+ }
+ catch (\Exception $e) {
+ $db->rollback();
+ throw $e;
+ }
+ }
+
+
+ /**
+ * Return all accounts from current chart
+ */
+ public function export(): \Generator
+ {
+ $res = DB::getInstance()->iterate('SELECT
+ code, label, description, position, user, bookmark
+ FROM acc_accounts WHERE id_chart = ? ORDER BY code COLLATE NOCASE;',
+ $this->id);
+
+ foreach ($res as $row) {
+ $row->position = Account::POSITIONS_NAMES[$row->position];
+ $row->user = $row->user ? 'Ajouté' : '';
+ $row->bookmark = $row->bookmark ? 'Favori' : '';
+ yield $row;
+ }
+ }
+
+ public function resetAccountsRules(): void
+ {
+ $db = DB::getInstance();
+ $db->begin();
+
+ try {
+ foreach ($this->accounts()->listAll() as $account) {
+ $account->setLocalRules($this->country);
+ $account->save();
+ }
+ }
+ catch (UserException $e) {
+ $db->rollback();
+ throw $e;
+ }
+
+ $db->commit();
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ $country_modified = $this->isModified('country');
+ $exists = $this->exists();
+
+ $ok = parent::save($selfcheck);
+
+ // Change account types
+ if ($ok && $exists && $country_modified) {
+ $this->resetAccountsRules();
+ }
+
+ return $ok;
+ }
+
+ public function country_code(): ?string
+ {
+ if (!$this->code) {
+ return null;
+ }
+
+ return strtolower($this->country . '_' . $this->code);
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Accounting/Line.php b/src/include/lib/Paheko/Entities/Accounting/Line.php
new file mode 100644
index 0000000..caca76d
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/Line.php
@@ -0,0 +1,80 @@
+id_account = $id_account;
+ $line->credit = $credit;
+ $line->debit = $debit;
+ $line->label = $label;
+ $line->reference = $reference;
+
+ return $line;
+ }
+
+ public function filterUserValue(string $type, $value, string $key)
+ {
+ if ($key == 'credit' || $key == 'debit') {
+ $value = Utils::moneyToInteger($value);
+ }
+ elseif ($key == 'id_project' && $value == 0) {
+ $value = null;
+ }
+
+ $value = parent::filterUserValue($type, $value, $key);
+
+ return $value;
+ }
+
+ public function selfCheck(): void
+ {
+ // We don't check that the account exists here
+ // The fact that the account is in the right chart is checked in Transaction::selfCheck
+
+ $this->assert($this->reference === null || strlen($this->reference) < 200, 'La référence doit faire moins de 200 caractères.');
+ $this->assert($this->label === null || strlen($this->label) < 200, 'La référence doit faire moins de 200 caractères.');
+ $this->assert($this->id_account !== null, 'Aucun compte n\'a été indiqué.');
+ $this->assert($this->credit || $this->debit, 'Aucun montant au débit ou au crédit');
+ $this->assert($this->credit >= 0 && $this->debit >= 0, 'Le montant ne peut être négatif');
+ $this->assert($this->credit + $this->debit < 100000000000, 'Le montant ne peut être supérieur à un milliard');
+ $this->assert(($this->credit * $this->debit) === 0 && ($this->credit + $this->debit) > 0, 'Ligne non équilibrée : crédit ou débit doit valoir zéro.');
+
+ $this->assert(null === $this->id_project || DB::getInstance()->test(Project::TABLE, 'id = ?', $this->id_project), 'Le projet analytique indiqué n\'existe pas.');
+ $this->assert(!empty($this->id_transaction), 'Aucune écriture n\'a été indiquée pour cette ligne.');
+ parent::selfCheck();
+ }
+
+ public function asDetailsArray(): array
+ {
+ return [
+ 'Compte' => $this->id_account ? Accounts::getCodeAndLabel($this->id_account) : null,
+ 'Libellé' => $this->label,
+ 'Référence' => $this->reference,
+ 'Crédit' => Utils::money_format($this->credit),
+ 'Débit' => Utils::money_format($this->debit),
+ ];
+ }
+
+}
diff --git a/src/include/lib/Paheko/Entities/Accounting/Project.php b/src/include/lib/Paheko/Entities/Accounting/Project.php
new file mode 100644
index 0000000..a5b679e
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/Project.php
@@ -0,0 +1,66 @@
+code) {
+ $this->assert(trim($this->code) !== '', 'Le numéro de projet est invalide.');
+ $this->assert(strlen($this->code) <= 100, 'Le numéro de projet est trop long.');
+ $this->assert(preg_match('/^[A-Z0-9_]+$/', $this->code), 'Le numéro de projet ne peut comporter que des lettres majuscules et des chiffres.');
+
+ $db = DB::getInstance();
+
+ if ($this->exists()) {
+ $this->assert(!$db->test(self::TABLE, 'code = ? AND id != ?', $this->code, $this->id()), 'Ce code est déjà utilisé par un autre projet.');
+ }
+ else {
+ $this->assert(!$db->test(self::TABLE, 'code = ?', $this->code), 'Ce code est déjà utilisé par un autre projet.');
+ }
+ }
+
+ $this->assert(trim($this->label) !== '', 'L\'intitulé de projet ne peut rester vide.');
+ $this->assert(strlen($this->label) <= 200, 'L\'intitulé de compte ne peut faire plus de 200 caractères.');
+
+ if (null !== $this->description) {
+ $this->assert(trim($this->description) !== '', 'L\'intitulé de projet est invalide.');
+ $this->assert(strlen($this->description) <= 2000, 'L\'intitulé de compte ne peut faire plus de 2000 caractères.');
+ }
+
+
+ parent::selfCheck();
+ }
+
+ public function importForm(?array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ if (!empty($source['code'])) {
+ $source['code'] = strtoupper($source['code']);
+ }
+
+ $source['archived'] = !empty($source['archived']);
+
+ parent::importForm($source);
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Accounting/Transaction.php b/src/include/lib/Paheko/Entities/Accounting/Transaction.php
new file mode 100644
index 0000000..2a650a0
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/Transaction.php
@@ -0,0 +1,1626 @@
+ 'En attente de règlement',
+ 2 => 'Réglé',
+ 4 => 'Déposé en banque',
+ ];
+
+ const TYPES_NAMES = [
+ 'Avancé',
+ 'Recette',
+ 'Dépense',
+ 'Virement',
+ 'Dette',
+ 'Créance',
+ ];
+
+ const LOCKED_PROPERTIES = [
+ 'label',
+ 'reference',
+ 'date',
+ 'id_year',
+ 'prev_id',
+ 'prev_hash',
+ ];
+
+ const LOCKED_LINE_PROPERTIES = [
+ 'id_account',
+ 'debit',
+ 'credit',
+ 'label',
+ 'reference',
+ ];
+
+ protected ?int $id;
+ protected ?int $type = null;
+ protected int $status = 0;
+ protected string $label;
+ protected ?string $notes = null;
+ protected ?string $reference = null;
+
+ protected Date $date;
+
+ protected ?string $hash = null;
+ protected ?int $prev_id = null;
+ protected ?string $prev_hash = null;
+
+ protected int $id_year;
+ protected ?int $id_creator = null;
+
+ protected $_lines;
+ protected $_old_lines = [];
+
+ protected $_accounts = [];
+ protected $_default_selector = [];
+
+ static public function getTypeFromAccountType(int $account_type)
+ {
+ switch ($account_type) {
+ case Account::TYPE_REVENUE:
+ return self::TYPE_REVENUE;
+ case Account::TYPE_EXPENSE:
+ return self::TYPE_EXPENSE;
+ case Account::TYPE_THIRD_PARTY:
+ return self::TYPE_DEBT;
+ case Account::TYPE_BANK:
+ case Account::TYPE_CASH:
+ case Account::TYPE_OUTSTANDING:
+ return self::TYPE_TRANSFER;
+ default:
+ return self::TYPE_ADVANCED;
+ }
+ }
+
+ public function findTypeFromAccounts(): int
+ {
+ if (count($this->getLines()) != 2) {
+ return self::TYPE_ADVANCED;
+ }
+
+ $types = [];
+
+ foreach ($this->getLinesWithAccounts() as $line) {
+ if ($line->account_position == Account::REVENUE && $line->credit) {
+ $types[] = self::TYPE_REVENUE;
+ }
+ elseif ($line->account_position == Account::EXPENSE && $line->debit) {
+ $types[] = self::TYPE_EXPENSE;
+ }
+ }
+
+ // Did not find a expense/revenue account: fall back to advanced
+ // (or if one line is expense and the other is revenue)
+ if (count($types) != 1) {
+ return self::TYPE_ADVANCED;
+ }
+
+ return current($types);
+ }
+
+ public function getLinesWithAccounts(bool $as_array = false, bool $amount_as_int = true): array
+ {
+ $db = EntityManager::getInstance(Line::class)->DB();
+
+ // Merge data from accounts with lines
+ $accounts = [];
+ $projects = [];
+ $lines_with_accounts = [];
+
+ foreach ($this->getLines() as $line) {
+ if (!array_key_exists($line->id_account, $this->_accounts)) {
+ $accounts[] = $line->id_account;
+ }
+
+ if ($line->id_project) {
+ $projects[] = $line->id_project;
+ }
+ }
+
+ // Remove NULL accounts
+ $accounts = array_filter($accounts);
+
+ if (count($accounts)) {
+ $sql = sprintf('SELECT id, label, code, position FROM acc_accounts WHERE %s;', $db->where('id', 'IN', $accounts));
+ // Don't use array_merge here or keys will be lost
+ $this->_accounts = $this->_accounts + $db->getGrouped($sql);
+ }
+
+ if (count($projects)) {
+ $projects = $db->getAssoc(sprintf('SELECT id, label FROM acc_projects WHERE %s;', $db->where('id', $projects)));
+ }
+
+ foreach ($this->getLines() as &$line) {
+ $l = $line->asArray();
+ $l['account_code'] = $this->_accounts[$line->id_account]->code ?? null;
+ $l['account_label'] = $this->_accounts[$line->id_account]->label ?? null;
+ $l['account_position'] = $this->_accounts[$line->id_account]->position ?? null;
+ $l['project_name'] = $projects[$line->id_project] ?? null;
+ $l['account_selector'] = [$line->id_account => sprintf('%s — %s', $l['account_code'], $l['account_label'])];
+ $l['line'] =& $line;
+
+ if (!$as_array) {
+ $l = (object) $l;
+ }
+
+ if (!$amount_as_int) {
+ $l['debit'] = CommonModifiers::money_raw($l['debit']);
+ $l['credit'] = CommonModifiers::money_raw($l['credit']);
+ }
+
+ $lines_with_accounts[] = $l;
+ }
+
+ unset($line);
+
+ return $lines_with_accounts;
+ }
+
+ public function getLines(): array
+ {
+ if (null === $this->_lines && $this->exists()) {
+ $em = EntityManager::getInstance(Line::class);
+ $this->_lines = $em->all('SELECT * FROM @TABLE WHERE id_transaction = ? ORDER BY id;', $this->id);
+ }
+ elseif (null === $this->_lines) {
+ $this->_lines = [];
+ }
+
+ return $this->_lines;
+ }
+
+ public function countLines(): int
+ {
+ return count($this->getLines());
+ }
+
+ public function removeLine(Line $remove)
+ {
+ $new = [];
+
+ foreach ($this->getLines() as $line) {
+ if ($line->id === $remove->id) {
+ $this->_old_lines[] = $remove;
+ }
+ else {
+ $new[] = $line;
+ }
+ }
+
+ $this->_lines = $new;
+ }
+
+ public function resetLines()
+ {
+ $this->_old_lines = $this->getLines();
+ $this->_lines = [];
+ }
+
+ public function getLine(int $id)
+ {
+ foreach ($this->getLines() as $line) {
+ if ($line->id === $id) {
+ return $line;
+ }
+ }
+
+ return null;
+ }
+
+ public function getCreditLine(): ?Line
+ {
+ if ($this->type == self::TYPE_ADVANCED) {
+ return null;
+ }
+
+ foreach ($this->getLines() as $line) {
+ if ($line->credit) {
+ return $line;
+ }
+ }
+
+ return null;
+ }
+
+ public function getDebitLine(): ?Line
+ {
+ if ($this->type == self::TYPE_ADVANCED) {
+ return null;
+ }
+
+ foreach ($this->getLines() as $line) {
+ if ($line->debit) {
+ return $line;
+ }
+ }
+
+ return null;
+ }
+
+ public function getFirstLine()
+ {
+ $lines = $this->getLines();
+
+ if (!count($lines)) {
+ return null;
+ }
+
+ return reset($lines);
+ }
+
+ public function getLinesCreditSum()
+ {
+ $sum = 0;
+
+ foreach ($this->getLines() as $line) {
+ $sum += $line->credit;
+ }
+
+ return $sum;
+ }
+
+ public function getLinesDebitSum()
+ {
+ $sum = 0;
+
+ foreach ($this->getLines() as $line) {
+ $sum += $line->debit;
+ }
+
+ return $sum;
+ }
+
+ static public function getFormLines(?array $source = null): array
+ {
+ if (null === $source) {
+ $source = $_POST['lines'] ?? [];
+ }
+
+ if (empty($source) || !is_array($source)) {
+ return [];
+ }
+
+ $lines = Utils::array_transpose($source);
+
+ foreach ($lines as &$line) {
+ if (isset($line['credit'])) {
+ $line['credit'] = Utils::moneyToInteger($line['credit']);
+ }
+ if (isset($line['debit'])) {
+ $line['debit'] = Utils::moneyToInteger($line['debit']);
+ }
+ }
+
+ unset($line);
+
+ return $lines;
+ }
+
+ public function hasReconciledLines(): bool
+ {
+ foreach ($this->getLines() as $line) {
+ if (!empty($line->reconciled)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function getProjectId(): ?int
+ {
+ $lines = $this->getLines();
+
+ if (!count($lines)) {
+ return null;
+ }
+
+ $id_project = null;
+
+ foreach ($lines as $line) {
+ if ($line->id_project != $id_project) {
+ $id_project = $line->id_project;
+ break;
+ }
+ }
+
+ return $id_project;
+ }
+
+ /**
+ * Creates a new Transaction entity (not saved) from an existing one,
+ * trying to adapt to a different chart if possible
+ * @param int $id
+ * @param Year $year Target year
+ * @return Transaction
+ */
+ public function duplicate(Year $year): Transaction
+ {
+ $new = new Transaction;
+
+ $copy = ['type', 'label', 'notes', 'reference'];
+
+ foreach ($copy as $field) {
+ $new->$field = $this->$field;
+ }
+
+ $copy = ['credit', 'debit', 'id_account', 'label', 'reference', 'id_project'];
+ $lines = DB::getInstance()->get('SELECT
+ l.credit, l.debit, l.label, l.reference, b.id AS id_account, c.id AS id_project
+ FROM acc_transactions_lines l
+ INNER JOIN acc_accounts a ON a.id = l.id_account
+ LEFT JOIN acc_accounts b ON b.code = a.code AND b.id_chart = ?
+ LEFT JOIN acc_projects c ON c.id = l.id_project
+ WHERE l.id_transaction = ?;',
+ $year->chart()->id,
+ $this->id()
+ );
+
+ foreach ($lines as $l) {
+ $line = new Line;
+ foreach ($copy as $field) {
+ // Do not copy id_account when it is null, as it will trigger an error (invalid entity)
+ if ($field == 'id_account' && !isset($l->$field)) {
+ continue;
+ }
+
+ $line->$field = $l->$field;
+ }
+
+ $new->addLine($line);
+ }
+
+ // Only set date if valid
+ if ($this->date >= $year->start_date && $this->date <= $year->end_date) {
+ $new->date = clone $this->date;
+ }
+
+ return $new;
+ }
+
+ public function payment_reference(): ?string
+ {
+ $line = current($this->getLines());
+
+ if (!$line) {
+ return null;
+ }
+
+ return $line->reference;
+ }
+
+
+ public function getHash(): string
+ {
+ if (!$this->id_year) {
+ throw new \LogicException('Il n\'est pas possible de hasher un mouvement qui n\'est pas associé à un exercice');
+ }
+
+ $hash = hash_init('sha256');
+ $values = $this->asArray(true);
+ $values = array_intersect_key($values, array_flip(self::LOCKED_PROPERTIES));
+
+ hash_update($hash, implode(',', array_keys($values)));
+ hash_update($hash, implode(',', $values));
+
+ foreach ($this->getLines() as $line) {
+ $values = $line->asArray(true);
+ $values = array_intersect_key($values, array_flip(self::LOCKED_LINE_PROPERTIES));
+
+ hash_update($hash, implode(',', array_keys($values)));
+ hash_update($hash, implode(',', $values));
+ }
+
+ return hash_final($hash, false);
+ }
+
+ public function isVerified(): bool
+ {
+ if (!$this->prev_id) {
+ return false;
+ }
+
+ if (!$this->prev_hash) {
+ return false;
+ }
+
+ return $this->verify();
+ }
+
+ public function isLocked(): bool
+ {
+ // locking just got set
+ if ($this->hash && array_key_exists('hash', $this->_modified) && $this->_modified['hash'] === null) {
+ return false;
+ }
+
+ return $this->hash === null ? false : true;
+ }
+
+ public function canSaveChanges(): bool
+ {
+ if (!$this->isLocked()) {
+ return true;
+ }
+
+ if ($this->isModified('hash')) {
+ return false;
+ }
+
+ foreach (self::LOCKED_PROPERTIES as $prop) {
+ if ($this->isModified($prop)) {
+ return false;
+ }
+ }
+
+ foreach ($this->getLines() as $line) {
+ foreach (self::LOCKED_LINE_PROPERTIES as $prop) {
+ if ($line->isModified($prop)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ public function assertCanBeModified(): void
+ {
+ // Allow to change the status
+ if (count($this->_modified) === 1 && array_key_exists('status', $this->_modified)) {
+ return;
+ }
+
+ // We allow to change notes and id_project in a locked transaction
+ if (!$this->canSaveChanges()) {
+ throw new ValidationException('Il n\'est pas possible de modifier une écriture qui a été verrouillée');
+ }
+
+ $db = DB::getInstance();
+
+ if (isset($this->id_year) && $db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) {
+ throw new ValidationException('Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé');
+ }
+ }
+
+ public function verify(): bool
+ {
+ return hash_equals($this->getHash(), $this->hash);
+ }
+
+ public function lock(): void
+ {
+ // Select last locked transaction
+ $prev = DB::getInstance()->first('SELECT MAX(id) AS id, hash FROM acc_transactions WHERE hash IS NOT NULL AND id_year = ?;', $this->id_year);
+
+ $this->set('prev_id', $prev->id ?? null);
+ $this->set('prev_hash', $prev->hash ?? null);
+ $this->set('hash', $this->getHash());
+ $this->save();
+ }
+
+ public function addLine(Line $line)
+ {
+ $this->_lines[] = $line;
+ }
+
+ public function sum(): int
+ {
+ $sum = 0;
+
+ foreach ($this->getLines() as $line) {
+ $sum += $line->credit;
+ // Because credit == debit, we only use credit
+ }
+
+ return $sum;
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ if ($this->type == self::TYPE_DEBT || $this->type == self::TYPE_CREDIT) {
+ // Debts and credits add a waiting status
+ // Don't use if ($this->exists()) here as the type can be changed on an existing transaction
+ if (!$this->hasStatus(self::STATUS_PAID)) {
+ $this->addStatus(self::STATUS_WAITING);
+ }
+ }
+ else {
+ $this->removeStatus(self::STATUS_WAITING);
+ }
+
+ $this->assertCanBeModified();
+ $this->selfCheck();
+
+ $lines = $this->getLinesWithAccounts();
+
+ // Self check lines before saving Transaction
+ foreach ($lines as $i => $l) {
+ $line = $l->line;
+ $line->id_transaction = -1; // Get around validation of id_transaction being not null
+
+ if (empty($l->account_code)) {
+ throw new ValidationException('Le compte spécifié n\'existe pas.');
+ }
+
+ if ($this->type == self::TYPE_EXPENSE && $l->account_position == Account::REVENUE) {
+ throw new ValidationException(sprintf('Line %d : il n\'est pas possible d\'attribuer un compte de produit (%s) à une dépense', $i+1, $l->account_code));
+ }
+
+ if ($this->type == self::TYPE_REVENUE && $l->account_position == Account::EXPENSE) {
+ throw new ValidationException(sprintf('Line %d : il n\'est pas possible d\'attribuer un compte de charge (%s) à une recette', $i+1, $l->account_code));
+ }
+
+ try {
+ $line->selfCheck();
+ }
+ catch (ValidationException $e) {
+ // Add line number to message
+ throw new ValidationException(sprintf('Ligne %d : %s', $i+1, $e->getMessage()), 0, $e);
+ }
+ }
+
+ if ($this->exists() && $this->status & self::STATUS_ERROR) {
+ // Remove error status when changed
+ $this->removeStatus(self::STATUS_ERROR);
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ if (!parent::save()) {
+ return false;
+ }
+
+ foreach ($lines as $line) {
+ $line = $line->line; // Fetch real object
+ $line->id_transaction = $this->id();
+ $line->save(false);
+ }
+
+ foreach ($this->_old_lines as $line) {
+ if ($line->exists()) {
+ $line->delete();
+ }
+ }
+
+ $db->commit();
+
+ return true;
+ }
+
+ public function removeStatus(int $property) {
+ $this->set('status', $this->status & ~$property);
+ }
+
+ public function addStatus(int $property) {
+ $this->set('status', $this->status | $property);
+ }
+
+ public function hasStatus(int $property): bool
+ {
+ return $this->status & $property;
+ }
+
+ public function isPaid(): bool
+ {
+ return $this->hasStatus(self::STATUS_PAID);
+ }
+
+ public function markPaid() {
+ $this->removeStatus(self::STATUS_WAITING);
+ $this->addStatus(self::STATUS_PAID);
+ }
+
+ public function isWaiting(): bool
+ {
+ if ($this->type !== self::TYPE_DEBT && $this->type !== self::TYPE_CREDIT) {
+ return false;
+ }
+
+ return $this->hasStatus(self::STATUS_WAITING);
+ }
+
+ public function delete(): bool
+ {
+ if ($this->validated) {
+ throw new ValidationException('Il n\'est pas possible de supprimer une écriture qui a été validée');
+ }
+
+ $db = DB::getInstance();
+
+ if ($db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) {
+ throw new ValidationException('Il n\'est pas possible de supprimer une écriture qui fait partie d\'un exercice clôturé');
+ }
+
+ // FIXME when lettering is properly implemented: mark parent transaction non-deposited when deleting a deposit transaction
+
+ Files::delete($this->getAttachementsDirectory());
+
+ return parent::delete();
+ }
+
+ public function selfCheck(): void
+ {
+ $db = DB::getInstance();
+
+ $this->assert(!empty($this->id_year), 'L\'ID de l\'exercice doit être renseigné.');
+
+ $this->assert(!empty($this->label) && trim((string)$this->label) !== '', 'Le champ libellé ne peut rester vide.');
+ $this->assert(strlen($this->label) <= 200, 'Le champ libellé ne peut faire plus de 200 caractères.');
+ $this->assert(!isset($this->reference) || strlen($this->reference) <= 200, 'Le champ numéro de pièce comptable ne peut faire plus de 200 caractères.');
+ $this->assert(!isset($this->notes) || strlen($this->notes) <= 2000, 'Le champ remarques ne peut faire plus de 2000 caractères.');
+ $this->assert(!empty($this->date), 'Le champ date ne peut rester vide.');
+
+ $this->assert(null !== $this->id_year, 'Aucun exercice spécifié.');
+ $this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type d\'écriture inconnu : ' . $this->type);
+ $this->assert(null === $this->id_creator || $db->test('users', 'id = ?', $this->id_creator), 'Le membre créateur de l\'écriture n\'existe pas ou plus');
+
+ $is_in_year = $db->test(Year::TABLE, 'id = ? AND start_date <= ? AND end_date >= ?', $this->id_year, $this->date->format('Y-m-d'), $this->date->format('Y-m-d'));
+
+ if (!$is_in_year) {
+ $year = Years::get($this->id_year);
+ throw new ValidationException(sprintf('La date (%s) de l\'écriture ne correspond pas à l\'exercice "%s" : la date doit être entre le %s et le %s.',
+ Utils::shortDate($this->date),
+ $year->label ?? '',
+ Utils::shortDate($year->start_date),
+ Utils::shortDate($year->end_date)
+ ));
+ }
+
+ $total = 0;
+
+ $lines = $this->getLines();
+ $count = count($lines);
+
+ $this->assert($count > 0, 'Cette écriture ne comporte aucune ligne.');
+ $this->assert($count >= 2, 'Cette écriture comporte moins de deux lignes.');
+ $this->assert($count == 2 || $this->type == self::TYPE_ADVANCED, sprintf('Une écriture de type "%s" ne peut comporter que deux lignes au maximum.', self::TYPES_NAMES[$this->type]));
+
+ $accounts_ids = [];
+ $chart_id = $db->firstColumn('SELECT id_chart FROM acc_years WHERE id = ?;', $this->id_year);
+
+ foreach ($lines as $k => $line) {
+ $k = $k+1;
+ $this->assert(!empty($line->id_account), sprintf('Ligne %d: aucun compte n\'est défini', $k));
+ $this->assert($line->credit || $line->debit, sprintf('Ligne %d: Aucun montant au débit ou au crédit', $k));
+ $this->assert($line->credit >= 0 && $line->debit >= 0, sprintf('Ligne %d: Le montant ne peut être négatif', $k));
+ $this->assert(($line->credit * $line->debit) === 0 && ($line->credit + $line->debit) > 0, sprintf('Ligne %d: non équilibrée, crédit ou débit doit valoir zéro.', $k));
+ $this->assert($db->test(Account::TABLE, 'id = ? AND id_chart = ?', $line->id_account, $chart_id), sprintf('Ligne %d: le compte spécifié n\'est pas lié au bon plan comptable', $k));
+
+ $total += $line->credit;
+ $total -= $line->debit;
+ }
+
+ // check that transaction type is respected, or fall back to advanced
+ if ($this->type != self::TYPE_ADVANCED) {
+ $details = $this->getDetails();
+
+ foreach ($details as $detail) {
+ $line = $detail->direction == 'credit' ? $this->getCreditLine() : $this->getDebitLine();
+ $ok = $db->test(Account::TABLE, 'id = ? AND ' . $db->where('type', $detail->targets), $line->id_account);
+
+ if (!$ok) {
+ $this->set('type', self::TYPE_ADVANCED);
+ break;
+ }
+ }
+ }
+
+ $this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total)));
+
+ // Foreign keys constraints will check for validity of id_creator and id_year
+
+ parent::selfCheck();
+ }
+
+ public function importFromDepositForm(?array $source = null): void
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ if (empty($source['amount'])) {
+ throw new UserException('Montant non précisé');
+ }
+
+ $this->type = self::TYPE_ADVANCED;
+ $amount = $source['amount'];
+
+ $account = Form::getSelectorValue($source['account_transfer']);
+
+ if (!$account) {
+ throw new ValidationException('Aucun compte de dépôt n\'a été sélectionné');
+ }
+
+ $line = new Line;
+ $line->importForm([
+ 'debit' => $amount,
+ 'credit' => 0,
+ 'id_account' => $account,
+ ]);
+
+ $this->addLine($line);
+
+ $this->importForm($source);
+ }
+
+ public function importForm(array $source = null)
+ {
+ $source ??= $_POST;
+
+ // Transpose lines (HTML transaction forms)
+ if (!empty($source['lines']) && is_array($source['lines']) && is_string(key($source['lines']))) {
+ try {
+ $source['lines'] = Utils::array_transpose($source['lines']);
+ }
+ catch (\InvalidArgumentException $e) {
+ throw new ValidationException('Aucun compte sélectionné pour certaines lignes.');
+ }
+
+ unset($source['_lines']);
+ }
+
+ if (isset($source['type'])) {
+ $this->set('type', (int)$source['type']);
+ }
+
+ // Simple two-lines transaction
+ if (isset($source['amount']) && $this->type != self::TYPE_ADVANCED && isset($this->type)) {
+ if (empty($source['amount'])) {
+ throw new ValidationException('Montant non précisé');
+ }
+
+ $accounts = $this->getTypesDetails($source)[$this->type]->accounts;
+
+ // either supply debit/credit keys or simple accounts
+ if (!isset($source['debit'], $source['credit'])) {
+ foreach ($accounts as $account) {
+ if (empty($account->selector_value)) {
+ throw new ValidationException(sprintf('%s : aucun compte n\'a été sélectionné', $account->label));
+ }
+ }
+ }
+
+ $line = [
+ 'reference' => $source['payment_reference'] ?? null,
+ ];
+
+ $source['lines'] = [
+ $line + [
+ $accounts[0]->direction => $source['amount'],
+ $accounts[1]->direction => 0,
+ 'account_selector' => $accounts[0]->selector_value,
+ 'account' => $source[$accounts[0]->direction] ?? null,
+ ],
+ $line + [
+ $accounts[1]->direction => $source['amount'],
+ $accounts[0]->direction => 0,
+ 'account_selector' => $accounts[1]->selector_value,
+ 'account' => $source[$accounts[1]->direction] ?? null,
+ ],
+ ];
+
+ if ($this->type != self::TYPE_TRANSFER || Config::getInstance()->analytical_set_all) {
+ $source['lines'][0]['id_project'] = $source['id_project'] ?? null;
+ }
+
+ if (Config::getInstance()->analytical_set_all) {
+ $source['lines'][1]['id_project'] = $source['lines'][0]['id_project'];
+ }
+
+ unset($line, $accounts, $account, $source['simple']);
+ }
+
+ // Add/update lines objects
+ if (isset($source['lines']) && is_array($source['lines'])) {
+ $lines = $this->getLines();
+ $db = DB::getInstance();
+
+ foreach ($source['lines'] as $i => $line) {
+ if (isset($line['account_selector'])) {
+ $line['id_account'] = Form::getSelectorValue($line['account_selector']);
+ }
+ elseif (isset($line['account'])) {
+ if (empty($this->id_year) && empty($source['id_year'])) {
+ throw new ValidationException('L\'identifiant de l\'exercice comptable n\'est pas précisé.');
+ }
+
+ $id_chart = $id_chart ?? $db->firstColumn('SELECT id_chart FROM acc_years WHERE id = ?;', $source['id_year'] ?? $this->id_year);
+ $line['id_account'] = $db->firstColumn('SELECT id FROM acc_accounts WHERE code = ? AND id_chart = ?;', $line['account'], $id_chart);
+
+ if (empty($line['id_account'])) {
+ throw new ValidationException(sprintf('Le compte avec le code "%s" sur la ligne %d n\'existe pas.', $line['account'], $i+1));
+ }
+ }
+
+ if (empty($line['id_account'])) {
+ throw new ValidationException(sprintf('Ligne %d : aucun compte n\'a été sélectionné', $i + 1));
+ }
+
+
+ if (array_key_exists($i, $lines)) {
+ $new = false;
+ $l = $lines[$i];
+ }
+ else {
+ $new = true;
+ $l = new Line;
+ }
+
+ $l->importForm($line);
+
+ if ($l->isModified('debit') || $l->isModified('credit') || $l->isModified('id_account')) {
+ $l->set('reconciled', false);
+ }
+
+ if ($new) {
+ $this->addLine($l);
+ }
+ }
+
+ // Remove extra lines
+ if (count($lines) > count($source['lines'])) {
+ $max = count($source['lines']);
+
+ foreach ($lines as $j => $line) {
+ if ($j >= $max) {
+ $this->_old_lines[] = $line;
+ unset($this->_lines[$j]);
+ }
+ }
+
+ // reset array indexes
+ $this->_lines = array_values($this->_lines);
+ }
+ }
+
+ return parent::importForm($source);
+ }
+
+ public function importFromNewForm(?array $source = null): void
+ {
+ $source ??= $_POST;
+
+ $type = $source['type'] ?? ($this->type ?? self::TYPE_ADVANCED);
+
+ if (self::TYPE_ADVANCED != $type && !isset($source['amount'])) {
+ throw new UserException('Montant non précisé');
+ }
+
+ $this->importForm($source);
+ }
+
+ public function importFromAPI(?array $source = null): void
+ {
+ $source ??= $_POST;
+
+ if (isset($source['type']) && ctype_alpha($source['type']) && defined(self::class . '::TYPE_' . strtoupper($source['type']))) {
+ $source['type'] = constant(self::class . '::TYPE_' . strtoupper($source['type']));
+ }
+
+ if (isset($source['id_year'])) {
+ $y = $source['id_year'];
+
+ if ($source['id_year'] === 'current') {
+ $source['id_year'] = Years::getCurrentOpenYearId();
+ }
+ elseif ($source['id_year'] === 'match') {
+ if (isset($source['date'])) {
+ $date = self::filterUserDateValue($source['date']);
+ }
+ else {
+ $date = null;
+ }
+
+ $source['id_year'] = Years::getMatchingOpenYearId($date);
+ }
+
+ if (!$source['id_year']) {
+ throw new UserException(sprintf('Cannot find a valid open year matching "%s"', $y));
+ }
+ }
+
+ $this->importFromNewForm($source);
+ }
+
+ public function importFromPayoffForm(\stdClass $payoff, ?array $source = null): void
+ {
+ $source ??= $_POST;
+
+ if ($source['type'] == 99) {
+ // Just make sure we can't trigger importFromNewForm
+ unset($source['lines']);
+
+ $id_project = isset($source['id_project']) ? intval($source['id_project']) : null;
+ $source['type'] = self::TYPE_ADVANCED;
+
+ if (!$payoff->multiple) {
+ if (empty($source['amount'])) {
+ throw new ValidationException('Montant non précisé');
+ }
+
+ $amount = Utils::moneyToInteger($source['amount']);
+
+ foreach ($this->getLines() as $line) {
+ if ($line->debit != 0) {
+ $line->set('debit', $amount);
+ }
+ else {
+ $line->set('credit', $amount);
+ }
+ }
+ }
+
+ if (empty($source['payoff_account']) || !is_array($source['payoff_account'])) {
+ throw new ValidationException('Aucun compte de règlement sélectionné.');
+ }
+
+ $payoff->payment_line->set('id_account', (int)key($source['payoff_account']));
+ $payoff->payment_line->set('reference', $source['payment_reference'] ?? null);
+ $payoff->payment_line->set('id_project', $id_project);
+
+ if (Config::getInstance()->analytical_set_all) {
+ foreach ($this->getLines() as $line) {
+ $line->set('id_project', $id_project);
+ }
+ }
+
+ $source['lines'] = $this->getLinesWithAccounts(true, false);
+ }
+
+ $this->importFromNewForm($source);
+ }
+
+ public function importFromBalanceForm(Year $year, ?array $source = null): void
+ {
+ $source ??= $_POST;
+
+ $this->label = 'Balance d\'ouverture';
+ $this->date = $year->start_date;
+ $this->id_year = $year->id();
+ $this->type = self::TYPE_ADVANCED;
+ $this->addStatus(self::STATUS_OPENING_BALANCE);
+
+ $this->importFromNewForm($source);
+
+ $diff = $this->getLinesCreditSum() - $this->getLinesDebitSum();
+
+ if (!$diff) {
+ return;
+ }
+
+ // Add final balance line
+ $line = new Line;
+
+ if ($diff > 0) {
+ $line->debit = $diff;
+ }
+ else {
+ $line->credit = abs($diff);
+ }
+
+ $open_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $year->id_chart, Account::TYPE_OPENING);
+
+ if (!$open_account) {
+ throw new ValidationException('Aucun compte de bilan d\'ouverture n\'existe dans le plan comptable');
+ }
+
+ $line->id_account = $open_account->id();
+
+ $this->addLine($line);
+ }
+
+ public function year()
+ {
+ return EntityManager::findOneById(Year::class, $this->id_year);
+ }
+
+ public function listFiles()
+ {
+ return Files::list($this->getAttachementsDirectory());
+ }
+
+ public function getAttachementsDirectory(): string
+ {
+ return File::CONTEXT_TRANSACTION . '/' . $this->id();
+ }
+
+ public function setDefaultAccount(int $type, string $direction, int $id): void
+ {
+ $this->_default_selector[$type][$direction] = Accounts::getSelector($id);
+ }
+
+ /**
+ * Return tuples of accounts selectors according to each "simplified" type
+ */
+ public function getTypesDetails(?array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ $details = [
+ self::TYPE_REVENUE => [
+ 'accounts' => [
+ [
+ 'label' => 'Type de recette',
+ 'targets' => [Account::TYPE_REVENUE],
+ 'direction' => 'credit',
+ 'defaults' => [
+ self::TYPE_CREDIT => 'credit',
+ ],
+ ],
+ [
+ 'label' => 'Compte d\'encaissement',
+ 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
+ 'direction' => 'debit',
+ 'defaults' => [
+ self::TYPE_EXPENSE => 'credit',
+ self::TYPE_TRANSFER => 'credit',
+ ],
+ ],
+ ],
+ 'label' => self::TYPES_NAMES[self::TYPE_REVENUE],
+ ],
+ self::TYPE_EXPENSE => [
+ 'accounts' => [
+ [
+ 'label' => 'Type de dépense',
+ 'targets' => [Account::TYPE_EXPENSE],
+ 'direction' => 'debit',
+ 'defaults' => [
+ self::TYPE_DEBT => 'debit',
+ ],
+ ],
+ [
+ 'label' => 'Compte de décaissement',
+ 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
+ 'direction' => 'credit',
+ 'defaults' => [
+ self::TYPE_REVENUE => 'debit',
+ self::TYPE_TRANSFER => 'credit',
+ ],
+ ],
+ ],
+ 'label' => self::TYPES_NAMES[self::TYPE_EXPENSE],
+ 'help' => null,
+ ],
+ self::TYPE_TRANSFER => [
+ 'accounts' => [
+ [
+ 'label' => 'De',
+ 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_TEMPORARY_TRANSFER],
+ 'direction' => 'credit',
+ 'defaults' => [
+ self::TYPE_EXPENSE => 'credit',
+ self::TYPE_REVENUE => 'debit',
+ ],
+ ],
+ [
+ 'label' => 'Vers',
+ 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_TEMPORARY_TRANSFER],
+ 'direction' => 'debit',
+ ],
+ ],
+ 'label' => self::TYPES_NAMES[self::TYPE_TRANSFER],
+ 'help' => 'Dépôt en banque, virement interne, etc.',
+ ],
+ self::TYPE_DEBT => [
+ 'accounts' => [
+ [
+ 'label' => 'Type de dette (dépense)',
+ 'targets' => [Account::TYPE_EXPENSE],
+ 'direction' => 'debit',
+ 'defaults' => [
+ self::TYPE_EXPENSE => 'debit',
+ ],
+ ],
+ [
+ 'label' => 'Compte de tiers',
+ 'targets' => [Account::TYPE_THIRD_PARTY],
+ 'direction' => 'credit',
+ 'defaults' => [
+ self::TYPE_CREDIT => 'debit',
+ ],
+ ],
+ ],
+ 'label' => self::TYPES_NAMES[self::TYPE_DEBT],
+ 'help' => 'Quand l\'association doit de l\'argent à un membre ou un fournisseur',
+ ],
+ self::TYPE_CREDIT => [
+ 'accounts' => [
+ [
+ 'label' => 'Type de créance (recette)',
+ 'targets' => [Account::TYPE_REVENUE],
+ 'direction' => 'credit',
+ 'defaults' => [
+ self::TYPE_REVENUE => 'credit',
+ ],
+ ],
+ [
+ 'label' => 'Compte de tiers',
+ 'targets' => [Account::TYPE_THIRD_PARTY],
+ 'direction' => 'debit',
+ 'defaults' => [
+ self::TYPE_DEBT => 'credit',
+ ],
+ ],
+ ],
+ 'label' => self::TYPES_NAMES[self::TYPE_CREDIT],
+ 'help' => 'Quand un membre ou un client doit de l\'argent à l\'association',
+ ],
+ self::TYPE_ADVANCED => [
+ 'accounts' => [],
+ 'label' => self::TYPES_NAMES[self::TYPE_ADVANCED],
+ 'help' => 'Choisir les comptes du plan comptable, ventiler une écriture sur plusieurs comptes, etc.',
+ ],
+ ];
+
+ // Find out which lines are credit and debit
+ $current_accounts = [];
+
+ foreach ($this->getLinesWithAccounts() as $i => $l) {
+ if ($l->debit) {
+ $current_accounts['debit'] = $l->account_selector;
+ }
+ elseif ($l->credit) {
+ $current_accounts['credit'] = $l->account_selector;
+ }
+
+ if (count($current_accounts) == 2) {
+ break;
+ }
+ }
+
+ foreach ($details as $key => &$type) {
+ $type = (object) $type;
+ $type->id = $key;
+ foreach ($type->accounts as &$account) {
+ $account = (object) $account;
+ $account->targets_string = implode(':', $account->targets);
+ $account->selector_name = sprintf('simple[%s][%s]', $key, $account->direction);
+
+ $d = null;
+
+ // Copy selector value for current type
+ if ($type->id == $this->type) {
+ $d = $account->direction;
+ }
+ else {
+ $d = $account->defaults[$this->type] ?? null;
+ }
+
+ if ($d) {
+ $account->selector_value = $source['simple'][$key][$d] ?? ($current_accounts[$d] ?? null);
+ }
+
+ if (empty($account->selector_value) && isset($this->_default_selector[$key][$account->direction])) {
+ $account->selector_value = $this->_default_selector[$key][$account->direction];
+ }
+
+ $account->id = isset($account->selector_value) ? key($account->selector_value) : null;
+ $account->name = isset($account->selector_value) ? current($account->selector_value) : null;
+ }
+ }
+
+ unset($account, $type);
+
+ return $details;
+ }
+
+ public function getDetails(): ?array
+ {
+ if ($this->type == self::TYPE_ADVANCED) {
+ return null;
+ }
+
+ $details = $this->getTypesDetails();
+
+ return [
+ 'left' => $details[$this->type]->accounts[0],
+ 'right' => $details[$this->type]->accounts[1],
+ ];
+ }
+
+ public function payOffFrom(Transaction $transaction): ?\stdClass
+ {
+ $this->_related = $transaction;
+ $this->id_related = $this->_related->id();
+ $this->label = ($this->_related->type == Transaction::TYPE_DEBT ? 'Règlement de dette : ' : 'Règlement de créance : ') . $this->_related->label;
+ $this->type = self::TYPE_ADVANCED;
+
+ $out = (object) [
+ 'id' => $this->_related->id,
+ 'amount' => $this->_related->sum(),
+ 'id_project' => $this->_related->getProjectId(),
+ 'type' => $this->_related->type,
+ ];
+
+ return $out;
+ }
+
+ public function getTypeName(): string
+ {
+ return self::TYPES_NAMES[$this->type];
+ }
+
+ public function asDetailsArray(bool $modified = false): array
+ {
+ $lines = [];
+ $debit = 0;
+ $credit = 0;
+
+ foreach ($this->getLines() as $i => $line) {
+ $lines[$i+1] = $line->asDetailsArray();
+
+ $debit += $line->debit;
+ $credit +=$line->credit;
+ }
+
+ $src = $this->asArray();
+
+ return [
+ 'Numéro' => $src['id'] ?? '--',
+ 'Type' => self::TYPES_NAMES[$src['type'] ?? self::TYPE_ADVANCED],
+ 'Libellé' => $src['label'] ?? null,
+ 'Date' => isset($src['date']) ? $src['date']->format('d/m/Y') : null,
+ 'Pièce comptable' => $src['reference'] ?? null,
+ 'Remarques' => $src['notes'] ?? null,
+ 'Total crédit' => Utils::money_format($debit),
+ 'Total débit' => Utils::money_format($credit),
+ 'Lignes' => $lines,
+ ];
+ }
+
+ public function asJournalArray(): array
+ {
+ $out = $this->asArray();
+
+ if ($this->exists()) {
+ $out['url'] = $this->url();
+ }
+
+ $out['lines'] = $this->getLinesWithAccounts();
+ foreach ($out['lines'] as &$line) {
+ unset($line->line);
+ }
+ unset($line);
+ return $out;
+ }
+
+ /**
+ * Compare transaction, to see if something has changed
+ */
+ public function diff(): ?array
+ {
+ $out = [
+ 'transaction' => [],
+ 'lines' => [],
+ 'lines_new' => [],
+ 'lines_removed' => [],
+ ];
+
+ foreach ($this->_modified as $key => $old) {
+ $out['transaction'][$key] = [$old, $this->$key];
+ }
+
+ static $keys = [
+ 'id_account' => 'Numéro de compte',
+ 'label' => 'Libellé ligne',
+ 'reference' => 'Référence ligne',
+ 'credit' => 'Crédit',
+ 'debit' => 'Débit',
+ 'id_project' => 'Projet',
+ ];
+
+ $new_lines = [];
+ $old_lines = [];
+
+ foreach ($this->getLines() as $i => $line) {
+ if ($line->exists()) {
+ $diff = [];
+
+ foreach ($keys as $key => $label) {
+ if ($line->isModified($key)) {
+ $diff[$key] = [$line->getModifiedProperty($key), $line->$key];
+ }
+ }
+
+ if (count($diff)) {
+ if (isset($diff['id_project'])) {
+ $diff['project'] = [Projects::getName($diff['id_project'][0]), Projects::getName($diff['id_project'][1])];
+ }
+
+ if (isset($diff['id_account'])) {
+ $diff['account'] = [Accounts::getCodeAndLabel($diff['id_account'][0]), Accounts::getCodeAndLabel($diff['id_account'][1])];
+ }
+ }
+
+ $l = array_merge($line->asArray(), compact('diff'));
+
+ $l['account'] = Accounts::getCodeAndLabel($l['id_account']);
+ $l['project'] = Projects::getName($l['id_project']);
+
+ $out['lines'][$i] = $l;
+ }
+ else {
+ $new_line = [];
+
+ foreach ($keys as $key => $label) {
+ $new_line[$key] = $line->$key;
+ }
+
+ $new_lines[] = $new_line;
+ }
+ }
+
+ foreach ($this->_old_lines as $line) {
+ $old_line = [];
+
+ foreach ($keys as $key => $label) {
+ $old_line[$key] = $line->$key;
+ }
+
+ $old_lines[] = $old_line;
+ }
+
+ // Append new lines and changed lines
+ foreach ($new_lines as $i => $new_line) {
+ if (!in_array($new_line, $old_lines)) {
+ $new_line['account'] = Accounts::getCodeAndLabel($new_line['id_account']);
+ $new_line['project'] = Projects::getName($new_line['id_project']);
+ $out['lines_new'][] = $new_line;
+ }
+ }
+
+ // Append removed lines
+ foreach ($old_lines as $i => $old_line) {
+ if (!in_array($old_line, $new_lines)) {
+ $old_line['account'] = Accounts::getCodeAndLabel($old_line['id_account']);
+ $old_line['project'] = Projects::getName($old_line['id_project']);
+ $out['lines_removed'][] = $old_line;
+ }
+ }
+
+ if (!count($out['transaction']) && !count($out['lines']) && !count($out['lines_new']) && !count($out['lines_removed'])) {
+ return null;
+ }
+
+ return $out;
+ }
+
+ public function url(): string
+ {
+ return Utils::getLocalURL('!acc/transactions/details.php?id=' . $this->id());
+ }
+
+ public function getProject(): ?array
+ {
+ $id = $this->getProjectId();
+
+ if (!$id) {
+ return null;
+ }
+
+ $name = Projects::getName($id);
+ return compact('id', 'name');
+ }
+
+ public function getPaymentReference(): ?string
+ {
+ foreach ($this->getLines() as $line) {
+ if ($line->reference) {
+ return $line->reference;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Quick-fill transaction from query parameters
+ */
+ public function setDefaultsFromQueryString(Accounts $accounts): ?array
+ {
+ if (!empty($_POST)) {
+ return null;
+ }
+
+ $amount = null;
+ $id_project = null;
+ $lines = [[], []];
+ $linked_users = null;
+
+ // a = amount, in single currency units
+ if (isset($_GET['a'])) {
+ $amount = Utils::moneyToInteger($_GET['a']);
+ }
+
+ // a00 = Amount, in cents
+ if (isset($_GET['a00'])) {
+ $amount = (int)$_GET['a00'];
+ }
+
+ // l = label
+ if (isset($_GET['l'])) {
+ $this->set('label', $_GET['l']);
+ }
+
+ // r = reference
+ if (isset($_GET['r'])) {
+ $this->set('reference', $_GET['r']);
+ }
+
+ // dt = date
+ if (isset($_GET['dt'])) {
+ $date = Entity::filterUserDateValue($_GET['dt']);
+
+ if (null !== $date && $date instanceof Date) {
+ $this->set('date', $date);
+ }
+ }
+
+ // t = type
+ if (isset($_GET['t'])) {
+ $this->set('type', (int) $_GET['t']);
+ }
+
+ if (isset($_GET['p'])) {
+ $id_project = (int) $_GET['p'];
+ }
+
+ static $bank_types = [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING];
+
+ // ab = Bank/cash account
+ if (isset($_GET['ab'])
+ && ($a = $accounts->getWithCode($_GET['ab']))
+ && in_array($a->type, $bank_types)) {
+ $this->setDefaultAccount(self::TYPE_REVENUE, 'debit', $a->id);
+ $this->setDefaultAccount(self::TYPE_EXPENSE, 'credit', $a->id);
+ $this->setDefaultAccount(self::TYPE_TRANSFER, 'credit', $a->id);
+ }
+
+ // ar = Revenue account
+ if (isset($_GET['ar'])
+ && ($a = $accounts->getWithCode($_GET['ar']))
+ && $a->type == $a::TYPE_REVENUE) {
+ $this->setDefaultAccount(self::TYPE_REVENUE, 'credit', $a->id);
+ $this->setDefaultAccount(self::TYPE_CREDIT, 'credit', $a->id);
+ }
+
+ // ae = Expense account
+ if (isset($_GET['ae'])
+ && ($a = $accounts->getWithCode($_GET['ae']))
+ && $a->type == $a::TYPE_EXPENSE) {
+ $this->setDefaultAccount(self::TYPE_EXPENSE, 'debit', $a->id);
+ $this->setDefaultAccount(self::TYPE_DEBT, 'debit', $a->id);
+ }
+
+ // at = Transfer account
+ if (isset($_GET['at'])
+ && ($a = $accounts->getWithCode($_GET['at']))
+ && in_array($a->type, $bank_types)) {
+ $this->setDefaultAccount(self::TYPE_TRANSFER, 'debit', $a->id);
+ }
+
+ // a3 = Third-party account
+ if (isset($_GET['a3'])
+ && ($a = $accounts->getWithCode($_GET['a3']))
+ && $a->type == $a::TYPE_THIRD_PARTY) {
+ $this->setDefaultAccount(self::TYPE_CREDIT, 'debit', $a->id);
+ $this->setDefaultAccount(self::TYPE_DEBT, 'credit', $a->id);
+ }
+
+ // Pre-fill from lllines
+ if (isset($_GET['ll']) && is_array($_GET['ll'])) {
+ $lines = [];
+ foreach ($_GET['ll'] as $l) {
+ $lines[] = [
+ 'debit' => $l['d0'] ?? Utils::moneyToInteger($l['d'] ?? ''),
+ 'credit' => $l['c0'] ?? Utils::moneyToInteger($l['c'] ?? ''),
+ 'account_selector' => $accounts->getSelectorFromCode($l['a'] ?? null),
+ 'label' => $l['l'] ?? null,
+ 'reference' => $l['r'] ?? null,
+ ];
+ }
+
+ // Make sure we have at least two lines
+ $lines = array_merge($lines, array_fill(0, max(0, 2 - count($lines)), []));
+ }
+
+ if (isset($_GET['u'])) {
+ $linked_users = [];
+ $i = 0;
+
+ foreach ((array) $_GET['u'] as $key => $value) {
+ if ($key != $i++ && $value) {
+ $id = (int) $key;
+ $linked_services[$id] = (int) $value;
+ }
+ else {
+ $id = (int) $value;
+ }
+
+ $name = Users::getName($id);
+
+ if ($name) {
+ $linked_users[$id] = $name;
+ }
+ }
+ }
+
+ if (isset($_GET['pr'])) {
+ $_POST['payment_reference'] = trim($_GET['pr']);
+ }
+
+ return compact('lines', 'id_project', 'amount', 'linked_users');
+ }
+
+ public function saveLinks(?array $source = null): void
+ {
+ $source ??= $_POST;
+
+ if (empty($source['users'])) {
+ $this->deleteLinkedUsers();
+ }
+ elseif (is_array($source['users']) && count($source['users'])) {
+ $this->updateLinkedUsers(array_keys($source['users']));
+ }
+
+ if (empty($source['linked'])) {
+ $this->deleteLinkedTransactions();
+ }
+ elseif (is_array($source['linked']) && count($source['linked'])) {
+ $this->updateLinkedTransactions(array_keys($source['linked']));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Entities/Accounting/TransactionLinksTrait.php b/src/include/lib/Paheko/Entities/Accounting/TransactionLinksTrait.php
new file mode 100644
index 0000000..d694693
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/TransactionLinksTrait.php
@@ -0,0 +1,60 @@
+DB();
+ $db->delete('acc_transactions_links', 'id_transaction = ? OR id_related = ?', $this->id(), $this->id());
+ }
+
+ public function updateLinkedTransactions(array $ids): void
+ {
+ $ids = array_values($ids);
+ $ids = array_map('intval', $ids);
+
+ $db = EntityManager::getInstance(self::class)->DB();
+
+ $db->begin();
+ $this->deleteLinkedTransactions();
+
+ foreach ($ids as $id) {
+ $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_links (id_transaction, id_related) VALUES (?, ?);', $this->id(), (int)$id);
+ }
+
+ $db->commit();
+ }
+
+ public function linkToTransaction(int $id): void
+ {
+ $db = EntityManager::getInstance(self::class)->DB();
+
+ if ($db->test(self::TABLE, 'id_transaction = ? OR id_related = ?', $this->id, $this->id)) {
+ return;
+ }
+
+ $params = ['id_transaction' => $this->id(), 'id_related' => $this->id()];
+
+ $db->insert(self::TABLE, $params);
+ }
+
+ public function listLinkedTransactions()
+ {
+ return EntityManager::getInstance(self::class)->all('SELECT t.*
+ FROM @TABLE AS t
+ INNER JOIN acc_transactions_links AS l ON (l.id_transaction = t.id OR l.id_related = t.id)
+ WHERE (l.id_transaction = ? OR l.id_related = ?) AND t.id != ? ORDER BY t.id;', $this->id(), $this->id(), $this->id());
+ }
+
+ public function listLinkedTransactionsAssoc()
+ {
+ return EntityManager::getInstance(self::class)->DB()->getAssoc('SELECT t.id, t.id
+ FROM acc_transactions AS t
+ INNER JOIN acc_transactions_links AS l ON (l.id_transaction = t.id OR l.id_related = t.id)
+ WHERE (l.id_transaction = ? OR l.id_related = ?) AND t.id != ? GROUP BY t.id ORDER BY t.id;', $this->id(), $this->id(), $this->id());
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php b/src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php
new file mode 100644
index 0000000..ef7b26a
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php
@@ -0,0 +1,74 @@
+DB();
+
+ return $db->preparedQuery('REPLACE INTO acc_transactions_users (id_transaction, id_user, id_service_user)
+ SELECT ?, id_user, id FROM services_users WHERE id = ?;',
+ $this->id(),
+ $id_subscription
+ );
+ }
+
+ public function deleteAllSubscriptionLinks(): void
+ {
+ $db = EntityManager::getInstance(self::class)->DB();
+ $db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user IS NOT NULL', $this->id());
+ }
+
+ public function deleteSubscriptionLink(int $id): void
+ {
+ $db = EntityManager::getInstance(self::class)->DB();
+ $db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user = ?', $this->id(), $id);
+ }
+
+ public function listSubscriptionLinks(): array
+ {
+ $db = EntityManager::getInstance(self::class)->DB();
+ $identity_column = DynamicFields::getNameFieldsSQL('u');
+ $number_column = DynamicFields::getNumberFieldSQL('u');
+ $sql = sprintf('SELECT s.*, %s AS user_identity, %s AS user_number, l.id_service_user AS id_subscription
+ FROM users u
+ INNER JOIN acc_transactions_users l ON l.id_user = u.id
+ INNER JOIN services_users s ON s.id = l.id_service_user
+ WHERE l.id_transaction = ? AND l.id_service_user IS NOT NULL;', $identity_column, $number_column);
+ return $db->get($sql, $this->id());
+ }
+
+ public function updateSubscriptionLinks(array $subscriptions): void
+ {
+ $subscriptions = array_values($subscriptions);
+
+ foreach ($subscriptions as $i => $subscription) {
+ if (!(is_int($subscription) || (is_string($subscription) && ctype_digit($subscription)))) {
+ throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid subscription ID', $i, $subscription));
+ }
+ }
+
+ $db = EntityManager::getInstance(self::class)->DB();
+
+ $db->begin();
+ $this->deleteAllSubscriptionLinks();
+
+ foreach ($subscriptions as $id) {
+ $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_service_user)
+ SELECT ?, id_user, id FROM services_users WHERE id = ?;',
+ $this->id(),
+ (int)$id
+ );
+ }
+
+ $db->commit();
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php b/src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php
new file mode 100644
index 0000000..1b405bf
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php
@@ -0,0 +1,61 @@
+DB();
+ $db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user IS NULL', $this->id());
+ }
+
+ public function updateLinkedUsers(array $users): void
+ {
+ $users = array_values($users);
+
+ foreach ($users as $i => $user) {
+ if (!(is_int($user) || (is_string($user) && ctype_digit($user)))) {
+ throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid user ID', $i, $user));
+ }
+ }
+
+ $db = EntityManager::getInstance(self::class)->DB();
+
+ $db->begin();
+ $this->deleteLinkedUsers();
+
+ foreach ($users as $id) {
+ $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_service_user) VALUES (?, ?, NULL);', $this->id(), (int)$id);
+ }
+
+ $db->commit();
+ }
+
+ public function listLinkedUsers(): array
+ {
+ $db = EntityManager::getInstance(self::class)->DB();
+ $identity_column = DynamicFields::getNameFieldsSQL('u');
+ $number_column = DynamicFields::getNumberFieldSQL('u');
+ $sql = sprintf('SELECT u.id, %s AS identity, %s AS number
+ FROM users u
+ INNER JOIN acc_transactions_users l ON l.id_user = u.id
+ WHERE l.id_transaction = ? AND l.id_service_user IS NULL
+ ORDER BY id;', $identity_column, $number_column);
+ return $db->get($sql, $this->id());
+ }
+
+ public function listLinkedUsersAssoc(): array
+ {
+ $db = EntityManager::getInstance(self::class)->DB();
+ $identity_column = DynamicFields::getNameFieldsSQL('u');
+ $sql = sprintf('SELECT u.id, %s AS identity
+ FROM users u
+ INNER JOIN acc_transactions_users l ON l.id_user = u.id
+ WHERE l.id_transaction = ? AND l.id_service_user IS NULL;', $identity_column);
+ return $db->getAssoc($sql, $this->id());
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Accounting/Year.php b/src/include/lib/Paheko/Entities/Accounting/Year.php
new file mode 100644
index 0000000..b9019b5
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Accounting/Year.php
@@ -0,0 +1,259 @@
+assert(trim($this->label) !== '', 'Le libellé ne peut rester vide.');
+ $this->assert(strlen($this->label) <= 200, 'Le libellé ne peut faire plus de 200 caractères.');
+ $this->assert($this->start_date instanceof \DateTime, 'La date de début de l\'exercice n\'est pas définie.');
+ $this->assert($this->end_date instanceof \DateTime, 'La date de début de l\'exercice n\'est pas définie.');
+
+ $this->assert($this->start_date < $this->end_date, 'La date de fin doit être postérieure à la date de début');
+
+ $db = DB::getInstance();
+
+ $this->assert($this->id_chart !== null);
+ parent::selfCheck();
+
+ if ($this->exists()) {
+ $this->assert(
+ !$db->test(Transaction::TABLE, 'id_year = ? AND date < ?', $this->id(), $this->start_date->format('Y-m-d')),
+ 'Des écritures de cet exercice ont une date antérieure à la date de début de l\'exercice.'
+ );
+
+ $this->assert(
+ !$db->test(Transaction::TABLE, 'id_year = ? AND date > ?', $this->id(), $this->end_date->format('Y-m-d')),
+ 'Des écritures de cet exercice ont une date postérieure à la date de fin de l\'exercice.'
+ );
+ }
+ }
+
+ public function close(int $user_id): void
+ {
+ if ($this->closed) {
+ throw new \LogicException('Cet exercice est déjà clôturé');
+ }
+
+ $this->set('closed', true);
+ $this->save();
+ }
+
+ public function reopen(int $user_id): void
+ {
+ if (!$this->closed) {
+ throw new \LogicException('This year is already open');
+ }
+
+ $closing_id = $this->accounts()->getClosingAccountId();
+
+ if (!$closing_id) {
+ throw new UserException('Aucun compte n\'est indiqué comme compte de clôture dans le plan comptable');
+ }
+
+ $this->set('closed', false);
+ $this->save();
+
+ Log::add(Log::MESSAGE, [
+ 'message' => sprintf('Réouverture de l\'exercice', $this->label),
+ 'entity' => self::class,
+ 'id' => $this->id(),
+ ]);
+
+ // Create validated transaction to show that someone has reopened the year
+ $t = new Transaction;
+ $t->import([
+ 'id_year' => $this->id(),
+ 'label' => sprintf('Exercice réouvert le %s', date('d/m/Y à H:i:s')),
+ 'type' => Transaction::TYPE_ADVANCED,
+ 'date' => $this->end_date->format('d/m/Y'),
+ 'id_creator' => $user_id,
+ 'notes' => 'Écriture automatique créée lors de la réouverture, à des fins de transparence. Cette écriture ne peut pas être supprimée ni modifiée.',
+ ]);
+
+ $line = new Line;
+ $line->import([
+ 'debit' => 0,
+ 'credit' => 1,
+ 'id_account' => $closing_id,
+ ]);
+ $t->addLine($line);
+
+ $line = new Line;
+ $line->import([
+ 'debit' => 1,
+ 'credit' => 0,
+ 'id_account' => $closing_id,
+ ]);
+ $t->addLine($line);
+
+ // Lock transaction
+ $t->lock();
+
+ $t->save();
+ }
+
+ /**
+ * Splits an accounting year between the current year and another one, at a given date
+ * Any transaction after the given date will be moved to the target year.
+ */
+ public function split(\DateTime $date, Year $target): void
+ {
+ if ($this->closed) {
+ throw new \LogicException('Cet exercice est déjà clôturé');
+ }
+
+ if ($target->closed) {
+ throw new \LogicException('L\'exercice cible est déjà clôturé');
+ }
+
+ DB::getInstance()->preparedQuery('UPDATE acc_transactions SET id_year = ? WHERE id_year = ? AND date > ?;',
+ $target->id(), $this->id(), $date->format('Y-m-d'));
+ }
+
+ public function delete(): bool
+ {
+ $db = DB::getInstance();
+ $ids = $db->getAssoc('SELECT id, id FROM acc_transactions WHERE id_year = ?;', $this->id());
+
+
+ // Delete all files
+ foreach ($ids as $id) {
+ Files::delete(File::CONTEXT_TRANSACTION . '/' . $id);
+ }
+
+ // Manual delete of transactions, as there is a voluntary safeguard in SQL: no cascade
+ $db->preparedQuery('DELETE FROM acc_transactions WHERE id_year = ?;', $this->id());
+
+ return parent::delete();
+ }
+
+ public function countTransactions(): int
+ {
+ $db = DB::getInstance();
+ return $db->count(Transaction::TABLE, $db->where('id_year', $this->id()));
+ }
+
+ public function chart()
+ {
+ return EntityManager::findOneById(Chart::class, $this->id_chart);
+ }
+
+ public function accounts()
+ {
+ return new Accounts($this->id_chart);
+ }
+
+ public function label_years()
+ {
+ $start = Utils::date_fr($this->start_date, 'Y');
+ $end = Utils::date_fr($this->end_date, 'Y');
+ return $start == $end ? $start : sprintf('%s-%s', $start, substr($end, -2));
+ }
+
+
+ /**
+ * List common accounts used in this year, grouped by type
+ * @return array
+ */
+ public function listCommonAccountsGrouped(array $types = null, bool $hide_empty = false): array
+ {
+ if (null === $types) {
+ // If we want all types, then we will get used or bookmarked accounts in common types
+ // and only bookmarked accounts for other types, grouped in "Others"
+ $target = Account::COMMON_TYPES;
+ }
+ else {
+ $target = $types;
+ }
+
+ $out = [];
+
+ foreach ($target as $type) {
+ $out[$type] = (object) [
+ 'label' => Account::TYPES_NAMES[$type],
+ 'type' => $type,
+ 'accounts' => [],
+ ];
+ }
+
+ if (null === $types) {
+ $out[0] = (object) [
+ 'label' => 'Autres',
+ 'type' => 0,
+ 'accounts' => [],
+ ];
+ }
+
+ $db = DB::getInstance();
+
+ $sql = sprintf('SELECT a.* FROM acc_accounts a
+ LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
+ LEFT JOIN acc_transactions c ON c.id = b.id_transaction AND c.id_year = %d
+ WHERE a.id_chart = %d AND ((a.%s AND (a.bookmark = 1 OR a.user = 1 OR c.id IS NOT NULL)) %s)
+ GROUP BY a.id
+ ORDER BY type, code COLLATE NOCASE;',
+ $this->id(),
+ $this->id_chart,
+ $db->where('type', $target),
+ (null === $types) ? 'OR (a.bookmark = 1)' : ''
+ );
+
+ $query = $db->iterate($sql);
+
+ foreach ($query as $row) {
+ $t = in_array($row->type, $target, true) ? $row->type : 0;
+ $out[$t]->accounts[] = $row;
+ }
+
+ if ($hide_empty) {
+ foreach ($out as $key => $v) {
+ if (!count($v->accounts)) {
+ unset($out[$key]);
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ public function hasOpeningBalance(): bool
+ {
+ return DB::getInstance()->test(Transaction::TABLE, 'id_year = ? AND status & ?', $this->id(), Transaction::STATUS_OPENING_BALANCE);
+ }
+
+ public function deleteOpeningBalance(): void
+ {
+ $em = EntityManager::getInstance(Transaction::class);
+ $list = $em->iterate('SELECT * FROM @TABLE WHERE id_year = ? AND status & ?', $this->id(), Transaction::STATUS_OPENING_BALANCE);
+
+ foreach ($list as $t) {
+ $t->delete();
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Email/Email.php b/src/include/lib/Paheko/Entities/Email/Email.php
new file mode 100644
index 0000000..beba7ad
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Email/Email.php
@@ -0,0 +1,280 @@
+hash . SECRET_KEY);
+ return substr($code, 0, 10);
+ }
+
+ public function sendVerification(string $email): void
+ {
+ if (self::getHash($email) !== $this->hash) {
+ throw new UserException('Adresse email inconnue');
+ }
+
+ $verify_url = self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode();
+ EmailsTemplates::verifyAddress($email, $verify_url);
+ }
+
+ public function canSendVerificationAfterFail(): bool
+ {
+ $limit_date = new \DateTime($this->optout ? self::RESEND_VERIFICATION_DELAY_OPTOUT : self::RESEND_VERIFICATION_DELAY);
+ return isset($this->last_sent) ? $this->last_sent < $limit_date : false;
+ }
+
+ public function verify(string $code): bool
+ {
+ if ($code !== $this->getVerificationCode()) {
+ return false;
+ }
+
+ $this->set('verified', true);
+ $this->set('optout', false);
+ $this->set('invalid', false);
+ $this->set('fail_count', 0);
+ $this->set('fail_log', null);
+ return true;
+ }
+
+ public function validate(string $email): bool
+ {
+ if (!$this->canSend()) {
+ return false;
+ }
+
+ try {
+ self::validateAddress($email);
+ }
+ catch (UserException $e) {
+ $this->setFailedValidation($e->getMessage());
+ return false;
+ }
+
+ return true;
+ }
+
+ public function setFailedValidation(string $message): void
+ {
+ $this->hasFailed(['type' => 'permanent', 'message' => $message]);
+ }
+
+ static public function isAddressValid(string $email, bool $mx_check = true): bool
+ {
+ try {
+ self::validateAddress($email);
+ return true;
+ }
+ catch (UserException $e) {
+ return false;
+ }
+ }
+
+ static public function validateAddress(string $email, bool $mx_check = true): void
+ {
+ $pos = strrpos($email, '@');
+
+ if ($pos === false) {
+ throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
+ }
+
+ $user = substr($email, 0, $pos);
+ $host = substr($email, $pos+1);
+
+ // Ce domaine n'existe pas (MX inexistant), erreur de saisie courante
+ if ($host == 'gmail.fr') {
+ throw new UserException('Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"');
+ }
+
+ if (preg_match('![/@]!', $user)) {
+ throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
+ }
+
+ if (!SMTP::checkEmailIsValid($email, false)) {
+ if (!trim($host)) {
+ throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
+ }
+
+ foreach (self::COMMON_DOMAINS as $common_domain) {
+ similar_text($common_domain, $host, $percent);
+
+ if ($percent > 90) {
+ throw new UserException(sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain));
+ }
+ }
+
+ throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
+ }
+
+ // Windows does not support MX lookups
+ if (PHP_OS_FAMILY == 'Windows' || !$mx_check) {
+ return;
+ }
+
+ self::checkMX($host);
+ }
+
+ static public function checkMX(string $host)
+ {
+ if (PHP_OS_FAMILY == 'Windows') {
+ return;
+ }
+
+ getmxrr($host, $mx_list);
+
+ if (empty($mx_list)) {
+ throw new UserException('Adresse e-mail invalide (le domaine indiqué n\'a pas de service e-mail) : vérifiez que vous n\'avez pas fait une faute de frappe.');
+ }
+
+ foreach ($mx_list as $mx) {
+ if (preg_match(self::BLACKLIST_MANUAL_VALIDATION_MX, $mx)) {
+ throw new UserException('Adresse e-mail invalide : impossible d\'envoyer des mails à un service (de type mailinblack ou spamenmoins) qui demande une validation manuelle de l\'expéditeur. Merci de choisir une autre adresse e-mail.');
+ }
+ }
+ }
+
+ public function canSend(): bool
+ {
+ if (!empty($this->optout)) {
+ return false;
+ }
+
+ if (!empty($this->invalid)) {
+ return false;
+ }
+
+ if ($this->hasReachedFailLimit()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function hasReachedFailLimit(): bool
+ {
+ return !empty($this->fail_count) && ($this->fail_count >= Emails::FAIL_LIMIT);
+ }
+
+ public function incrementSentCount(): void
+ {
+ $this->set('sent_count', $this->sent_count+1);
+ }
+
+ public function setOptout(string $message = null): void
+ {
+ $this->set('optout', true);
+ $this->appendFailLog($message ?? 'Demande de désinscription');
+ }
+
+ public function appendFailLog(string $message): void
+ {
+ $log = $this->fail_log ?? '';
+
+ if ($log) {
+ $log .= "\n";
+ }
+
+ $log .= date('d/m/Y H:i:s - ') . trim($message);
+ $this->set('fail_log', $log);
+ }
+
+ public function hasFailed(array $return): void
+ {
+ if (!isset($return['type'])) {
+ throw new \InvalidArgumentException('Bounce email type not supplied in argument.');
+ }
+
+ // Treat complaints as opt-out
+ if ($return['type'] == 'complaint') {
+ $this->set('optout', true);
+ $this->appendFailLog("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']);
+ }
+ elseif ($return['type'] == 'permanent') {
+ $this->set('invalid', true);
+ $this->set('fail_count', $this->fail_count+1);
+ $this->appendFailLog($return['message']);
+ }
+ elseif ($return['type'] == 'temporary') {
+ $this->set('fail_count', $this->fail_count+1);
+ $this->appendFailLog($return['message']);
+ }
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ $optout = false;
+
+ if ($this->isModified('optout')) {
+ $optout = true;
+ }
+
+ $return = parent::save($selfcheck);
+
+ if ($return && $optout) {
+ // Delete all specific optouts when opting out of everything
+ DB::getInstance()->preparedQuery('DELETE FROM mailings_optouts WHERE email_hash = ?;', $this->hash);
+ }
+
+ return $return;
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Email/Mailing.php b/src/include/lib/Paheko/Entities/Email/Mailing.php
new file mode 100644
index 0000000..666b4f4
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Email/Mailing.php
@@ -0,0 +1,401 @@
+ 'Tous les membres (sauf catégories cachées)',
+ 'field' => 'Champ de la fiche membre',
+ 'category' => 'Catégorie',
+ 'service' => 'Inscrits à jour d\'une activité',
+ 'search' => 'Recherche enregistrée',
+ ];
+
+ protected ?int $id = null;
+ protected string $subject;
+ protected ?string $body;
+
+ /**
+ * We need to store these in order to have opt-out per-target
+ */
+ protected ?string $target_type;
+ protected ?string $target_value;
+ protected ?string $target_label;
+
+ /**
+ * Leave sender name and email NULL to use org name + email
+ */
+ protected ?string $sender_name;
+ protected ?string $sender_email;
+
+ /**
+ * NULL when the mailing has not been sent yet
+ */
+ protected ?DateTime $sent;
+
+ /**
+ * TRUE when the list of recipients has been anonymized
+ * @var boolean
+ */
+ protected bool $anonymous = false;
+
+ public function selfCheck(): void
+ {
+ parent::selfCheck();
+
+ $this->assert(trim($this->subject) !== '', 'Le sujet ne peut rester vide.');
+ $this->assert(!isset($this->body) || trim($this->body) !== '', 'Le corps du message ne peut rester vide.');
+
+ if (isset($this->sender_name) || isset($this->sender_email)) {
+ $this->assert(trim($this->sender_name) !== '', 'Le nom d\'expéditeur est vide.');
+ $this->assert(trim($this->sender_email) !== '', 'L\'adresse e-mail de l\'expéditeur est manquante.');
+ $this->assert(Email::isAddressValid($this->sender_email), 'L\'adresse e-mail de l\'expéditeur est invalide.');
+ }
+ }
+
+ public function getTargetTypeLabel(): string
+ {
+ return self::TARGETS_TYPES[$this->target_type] ?? '';
+ }
+
+ public function populate(): void
+ {
+ if ($this->target_type !== 'all' && empty($this->target_value)) {
+ throw new \InvalidArgumentException('Missing target ID');
+ }
+
+ if ($this->target_type === 'field') {
+ $recipients = Users::iterateEmailsByField($this->target_value, true);
+ }
+ elseif ($this->target_type === 'all') {
+ $recipients = Users::iterateEmailsByCategory(null);
+ }
+ elseif ($this->target_type === 'category') {
+ $recipients = Users::iterateEmailsByCategory((int) $this->target_value);
+ }
+ elseif ($this->target_type === 'search') {
+ $recipients = Users::iterateEmailsBySearch((int) $this->target_value);
+ }
+ elseif ($this->target_type === 'service') {
+ $recipients = Users::iterateEmailsByActiveService((int) $this->target_value);
+ }
+ else {
+ throw new \InvalidArgumentException('Invalid target');
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+ $count = 0;
+
+ foreach ($recipients as $email => $data) {
+ // Ignore empty emails, normally NULL emails are already discarded in WHERE clauses
+ // But, just to be sure
+ if (empty($email)) {
+ continue;
+ }
+
+ $this->addRecipient($email, $data);
+ $count++;
+ }
+
+ if (!$count) {
+ $db->rollback();
+ throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.');
+ }
+
+ $this->cleanupRecipients();
+
+ $db->commit();
+ }
+
+ /**
+ * Remove opt-out recipients from list
+ */
+ public function cleanupRecipients(): void
+ {
+
+ }
+
+ public function addRecipient(string $email, ?stdClass $data = null): void
+ {
+ if (!$this->exists()) {
+ throw new \LogicException('Mailing does not exist');
+ }
+
+ $email = strtolower(trim($email));
+ $e = Emails::getEmail($email);
+
+ if ($e && !$e->canSend()) {
+ $data = null;
+ }
+ else {
+ try {
+ // Validate e-mail address, but not MX (quick check)
+ Email::validateAddress($email, false);
+ }
+ catch (UserException $ex) {
+ $e = Emails::createEmail($email);
+ $e->setFailedValidation($ex->getMessage());
+ $data = null;
+ }
+ }
+
+ $this->cleanExtraData($data);
+
+ DB::getInstance()->insert('mailings_recipients', [
+ 'id_mailing' => $this->id,
+ 'id_email' => $e ? $e->id : null,
+ 'email' => $email,
+ 'extra_data' => $data ? json_encode($data) : null,
+ ]);
+ }
+
+ protected function cleanExtraData(?stdClass &$data): void
+ {
+ if (null === $data) {
+ return;
+ }
+
+ // Clean up users, just in case password/PGP key/etc. are included
+ foreach (DynamicField::SYSTEM_FIELDS as $key => $type) {
+ unset($data->$key);
+ }
+
+ // Just in case the password has another column name
+ foreach ($data as $key => $value) {
+ if (is_string($value) && substr($value, 0, 2) === '$2') {
+ unset($data->$key);
+ }
+ }
+ }
+
+ public function listRecipients(): \Generator
+ {
+ $db = DB::getInstance();
+ $sql = sprintf('SELECT email, extra_data AS data, %s AS _name FROM mailings_recipients WHERE id_mailing = %d ORDER BY id;',
+ $this->getNameFieldsSQL(),
+ $this->id()
+ );
+
+ foreach ($db->iterate($sql) as $row) {
+ $data = $row->data ? json_decode($row->data) : null;
+ yield $row->email => [
+ 'email' => $row->email,
+ 'data' => $data,
+ '_name' => $row->_name ?? null,
+ 'pgp_key' => $data->pgp_key ?? null,
+ ];
+ }
+ }
+
+ protected function getNameFieldsSQL(string $prefix = ''): string
+ {
+ $prefix = $prefix ? $prefix . '.' : $prefix;
+ $fields = DynamicFields::getNameFields();
+ $db = DB::getInstance();
+ $out = [];
+
+ foreach ($fields as $field) {
+ $field = $db->quote('$.' . $field);
+ $out[] = sprintf('json_extract(%sextra_data, %s)', $prefix, $field);
+ }
+
+ $out = implode(' || \' \' || ', $out);
+ return $out;
+ }
+
+ public function getRecipientsList(): DynamicList
+ {
+ $db = DB::getInstance();
+ $columns = [
+ 'id' => [
+ 'select' => 'r.id',
+ ],
+ 'id_user' => [
+ 'select' => sprintf('json_extract(r.extra_data, %s)', $db->quote('$.id')),
+ ],
+ 'id_email' => [
+ 'select' => 'r.id_email',
+ ],
+ 'user_number' => [
+ 'label' => 'Numéro de membre',
+ 'select' => sprintf('json_extract(r.extra_data, %s)', $db->quote('$.' . DynamicFields::getNumberField())),
+ 'export' => true,
+ ],
+ 'email' => [
+ 'label' => 'Adresse',
+ 'order' => 'r.email COLLATE NOCASE %s',
+ 'select' => 'r.email',
+ ],
+ 'name' => [
+ 'label' => 'Nom',
+ 'select' => $this->getNameFieldsSQL('r'),
+ ],
+ 'status' => [
+ 'label' => 'Erreur',
+ 'select' => sprintf('CASE WHEN o.email_hash IS NOT NULL THEN \'Désinscription de cet envoi\' ELSE (%s) END', Emails::getRejectionStatusClause('e')),
+ ],
+ 'has_extra_data' => [
+ 'select' => 'r.extra_data IS NOT NULL',
+ ],
+ ];
+
+ $tables = 'mailings_recipients AS r
+ LEFT JOIN emails e ON e.id = r.id_email
+ LEFT JOIN mailings_optouts o ON e.hash = o.email_hash AND o.target_type = :target_type AND o.target_value = :target_value';
+ $conditions = 'id_mailing = ' . $this->id;
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->setParameter(':target_type', $this->target_type);
+ $list->setParameter(':target_value', $this->target_value);
+ $list->orderBy('email', false);
+ $list->setTitle('Liste des destinataires');
+ return $list;
+ }
+
+ public function countRecipients(): int
+ {
+ return DB::getInstance()->count('mailings_recipients', 'id_mailing = ?', $this->id);
+ }
+
+ public function anonymize(): void
+ {
+ DB::getInstance()->preparedQuery('UPDATE mailings_recipients SET email = NULL, extra_data = NULL WHERE id_mailing = ?;', $this->id);
+ }
+
+ public function deleteRecipient(int $id): void
+ {
+ DB::getInstance()->delete('mailings_recipients', 'id = ? AND id_mailing = ?', $id, $this->id);
+ }
+
+ public function getRecipientExtraData(int $id): ?stdClass
+ {
+ $value = DB::getInstance()->firstColumn('SELECT extra_data FROM mailings_recipients WHERE id = ?;', $id);
+ $value = !$value ? null : json_decode($value, false);
+
+ $this->cleanExtraData($value);
+ return $value;
+ }
+
+ public function getFrom(): string
+ {
+ $config = Config::getInstance();
+ return sprintf('"%s" <%s>', $this->sender_name ?? $config->org_name, $this->sender_email ?? $config->org_email);
+ }
+
+ /**
+ * @return UserTemplate|string
+ */
+ public function getBody()
+ {
+ if (!isset($this->body)) {
+ return '';
+ }
+
+ if (false !== strpos($this->body, '{{')) {
+ return UserTemplate::createFromUserString($this->body);
+ }
+ else {
+ return $this->body;
+ }
+ }
+
+ public function getPreview(int $id = null): string
+ {
+ $db = DB::getInstance();
+
+ $where = $id ? 'id = ?' : '1 ORDER BY RANDOM()';
+ $sql = sprintf('SELECT extra_data FROM mailings_recipients WHERE id_mailing = %d AND %s LIMIT 1;', $this->id(), $where);
+ $args = $id ? (array)$id : [];
+
+ $r = $db->firstColumn($sql, ...$args);
+
+ if (!$r) {
+ throw new UserException('Cette adresse ne fait pas partie des destinataires');
+ }
+
+ $r = json_decode($r, true);
+
+ $body = $this->getBody();
+
+ if ($body instanceof UserTemplate) {
+ $body->assignArray($r, null, false);
+
+ try {
+ $body = $body->fetch();
+ }
+ catch (\KD2\Brindille_Exception $e) {
+ throw new UserException('Erreur de syntaxe dans le corps du message :' . PHP_EOL . $e->getPrevious()->getMessage(), 0, $e);
+ }
+ }
+
+ $render = Render::FORMAT_MARKDOWN;
+ return Render::render($render, null, $body);
+ }
+
+ public function getHTMLPreview(string $address = null, bool $append_footer = false): string
+ {
+ $html = $this->getPreview($address);
+ $tpl = new UserTemplate('web/email.html');
+ $tpl->assignArray(compact('html'), null, false);
+
+ $out = $tpl->fetch();
+
+ if ($append_footer) {
+ $out = Emails::appendHTMLOptoutFooter($out, 'javascript:alert(\'--\');');
+ }
+
+ return $out;
+ }
+
+ public function send(): void
+ {
+ $this->selfCheck();
+
+ if (!isset($this->body)) {
+ throw new UserException('Le corps du message est vide.');
+ }
+
+ $sender = null;
+
+ if (isset($this->sender_name, $this->sender_email)) {
+ $sender = Emails::getFromHeader($this->sender_name, $this->sender_email);
+ }
+
+ Emails::queue(Emails::CONTEXT_BULK,
+ $this->listRecipients(),
+ $sender,
+ $this->subject,
+ $this->getBody()
+ );
+
+ $this->set('sent', new DateTime);
+
+ $this->save();
+
+ Log::add(Log::SENT, ['entity' => get_class($this), 'id' => $this->id()]);
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Email/Message.php b/src/include/lib/Paheko/Entities/Email/Message.php
new file mode 100644
index 0000000..c7bb357
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Email/Message.php
@@ -0,0 +1,206 @@
+assignArray((array) $data, null, false);
+
+ // Disable HTML escaping for plaintext emails
+ $template->setEscapeDefault(null);
+ $this->body = $template->fetch();
+
+ if ($markdown) {
+ // Use Markdown rendering for HTML emails
+ $this->body_html = Render::render(Render::FORMAT_MARKDOWN, null, $this->body);
+ }
+ }
+
+ public function createHTMLFromMarkdownBody(?string $body = null): void
+ {
+ if (null !== $body) {
+ $this->body = $body;
+ }
+
+ $this->body_html = Render::render(Render::FORMAT_MARKDOWN, null, $this->body);
+ }
+
+ public function wrapHTML(): ?string
+ {
+ if ($this->context === self::CONTEXT_SYSTEM) {
+ return null;
+ }
+
+ if (null === self::$main_tpl) {
+ self::$main_tpl = new UserTemplate('web/email.html');
+ }
+
+ // Wrap HTML content in the email skeleton
+ $main_tpl->assignArray([
+ 'html' => $this->body_html,
+ 'address' => $this->recipient,
+ 'context' => $this->context,
+ 'sender' => $this->sender,
+ 'message' => $this,
+ ]);
+
+ return $main_tpl->fetch();
+ }
+
+ public function getOptoutText(): string
+ {
+ $out = "Vous recevez ce message car vous êtes dans nos contacts.\n";
+
+ if (isset($this->context_optout)) {
+ $out .= "Pour vous désinscrire uniquement de ces envois, cliquez ici :\n";
+ $out .= "[context_optout_url]\n\n";
+ }
+
+ $out .= "Pour ne plus jamais recevoir aucun message de notre part cliquez ici :\n";
+ $out .= "[optout_url]\n\n";
+ return $out;
+
+ }
+
+ public function getOptoutFooter(): string
+ {
+ return strtr($this->getOptoutText(), [
+ '[context_optout_url]' => $this->getContextSpecificOptoutURL(),
+ '[optout_url]' => $this->getOptoutURL(),
+ ]);
+ }
+
+ public function appendHTMLOptoutFooter(): string
+ {
+ $text = nl2br(htmlspecialchars($this->getOptoutText()));
+
+ if (isset($this->context_optout)) {
+ $button = sprintf('Me désinscrire de ces envois uniquement ', $this->getContextSpecificOptoutURL());
+ $text = str_replace('[context_optout_url]', $button, $text);
+ }
+
+ $button = sprintf('Me désinscrire de tous les envois ', $this->getOptoutURL());
+ $text = str_replace('[optout_url]', $button, $text);
+
+ $footer = '';
+ $footer .= $text;
+
+ $html = $this->body_html;
+
+ if (stripos($html, '') !== false) {
+ $html = str_ireplace('', $footer . '', $html);
+ }
+ else {
+ $html .= $footer;
+ }
+
+ return $html;
+ }
+
+ public function getOptoutURL(): string
+ {
+ return Email::getOptoutURL($this->recipient_hash);
+ }
+
+ public function getContextSpecificOptoutURL(): ?string
+ {
+ if (!isset($this->context_optout)) {
+ return null;
+ }
+
+ return Email::getOptoutURL() . '&c=' . $this->context_optout;
+ }
+
+
+ public function save(bool $selfcheck = true): bool
+ {
+
+ }
+
+ public function send(): bool
+ {
+ $config = Config::getInstance();
+ $message = new Mail_Message;
+
+ $message->setHeader('From', $this->sender ?? self::getDefaultFromHeader());
+ $message->setHeader('To', $this->recipient);
+ $message->setHeader('Subject', $this->subject);
+
+ if (!$message->getFrom()) {
+ $message->setHeader('From', self::getFromHeader());
+ }
+
+ if (MAIL_SENDER) {
+ $message->setHeader('Reply-To', $message->getFromAddress());
+ $message->setHeader('From', self::getFromHeader($message->getFromName(), MAIL_SENDER));
+ }
+
+ $message->setMessageId();
+
+ $text = $this->body;
+ $html = $this->body_html;
+
+ // Append unsubscribe, except for password reminders
+ if ($this->context != self::CONTEXT_SYSTEM) {
+ $url = $this->getContextSpecificOptoutURL() ?? $this->getOptoutURL();
+
+ // RFC 8058
+ $message->setHeader('List-Unsubscribe', sprintf('<%s>', $url));
+ $message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes');
+
+ $text .= sprintf("\n\n-- \n%s\n\n%s", $config->org_name, $this->getOptoutText());
+
+ if (null !== $html) {
+ $html = $this->appendHTMLOptoutFooter();
+ }
+ }
+
+ $message->setBody($text);
+
+ if (null !== $html) {
+ $message->addPart('text/html', $html);
+ }
+
+ $message->setHeader('Return-Path', MAIL_RETURN_PATH ?? $config->org_email);
+ $message->setHeader('X-Auto-Response-Suppress', 'All'); // This is to avoid getting auto-replies from Exchange servers
+
+ foreach ($attachments as $path) {
+ $file = Files::get($path);
+
+ if (!$file) {
+ continue;
+ }
+
+ $message->addPart($file->mime, $file->fetch(), $file->name);
+ }
+
+ static $can_use_encryption = null;
+
+ if (null === $can_use_encryption) {
+ $can_use_encryption = Security::canUseEncryption();
+ }
+
+ if ($this->recipient_pgp_key && $can_use_encryption) {
+ $message->encrypt($this->recipient_pgp_key);
+ }
+
+ return Emails::sendMessage($context, $message);
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Entities/Files/File.php b/src/include/lib/Paheko/Entities/Files/File.php
new file mode 100644
index 0000000..126adfa
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Files/File.php
@@ -0,0 +1,1530 @@
+ [['trim'], ['resize', 150]],
+ '250px' => [['trim'], ['resize', 250]],
+ '500px' => [['resize', 500]],
+ '750px' => [['resize', 750]],
+ 'crop-256px' => [['trim'], ['cropResize', 256, 256]],
+ ];
+
+ const THUMB_CACHE_ID = 'file.thumb.%s.%s';
+
+ const THUMB_SIZE_TINY = '250px';
+ const THUMB_SIZE_SMALL = '500px';
+ const THUMB_SIZE_LARGE = '750px';
+
+ const CONTEXT_TRASH = 'trash';
+ const CONTEXT_DOCUMENTS = 'documents';
+ const CONTEXT_USER = 'user';
+ const CONTEXT_TRANSACTION = 'transaction';
+ const CONTEXT_CONFIG = 'config';
+ const CONTEXT_WEB = 'web';
+ const CONTEXT_MODULES = 'modules';
+ const CONTEXT_ATTACHMENTS = 'attachments';
+ const CONTEXT_VERSIONS = 'versions';
+ const CONTEXT_EXTENSIONS = 'ext';
+
+ const CONTEXTS_NAMES = [
+ self::CONTEXT_TRASH => 'Corbeille',
+ self::CONTEXT_DOCUMENTS => 'Documents',
+ self::CONTEXT_USER => 'Fiches des membres',
+ self::CONTEXT_TRANSACTION => 'Écritures comptables',
+ self::CONTEXT_CONFIG => 'Configuration',
+ self::CONTEXT_WEB => 'Site web',
+ self::CONTEXT_MODULES => 'Code des modules',
+ self::CONTEXT_ATTACHMENTS => 'Fichiers joints aux messages',
+ self::CONTEXT_VERSIONS => 'Versions',
+ self::CONTEXT_EXTENSIONS => 'Extensions',
+ ];
+
+ const VERSIONED_CONTEXTS = [
+ self::CONTEXT_DOCUMENTS,
+ self::CONTEXT_TRANSACTION,
+ self::CONTEXT_USER,
+ ];
+
+ const IMAGE_TYPES = [
+ 'image/png',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/webp',
+ ];
+
+ const PREVIEW_TYPES = [
+ // We expect modern browsers to be able to preview a PDF file
+ // even if the user has disabled PDF opening in browser
+ // (something we cannot detect)
+ 'application/pdf',
+ 'audio/mpeg',
+ 'audio/ogg',
+ 'audio/wave',
+ 'audio/wav',
+ 'audio/x-wav',
+ 'audio/x-pn-wav',
+ 'audio/webm',
+ 'video/webm',
+ 'video/ogg',
+ 'application/ogg',
+ 'video/mp4',
+ 'image/png',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/webp',
+ 'image/svg+xml',
+ 'text/plain',
+ 'text/html',
+ ];
+
+ const FORBIDDEN_CHARACTERS = [
+ '..', // double dot
+ "\0", // NUL
+ '/', // slash
+ // invalid characters in Windows
+ '\\', ':', '*', '?', '"', '<', '>', '|',
+ ];
+
+ // https://book.hacktricks.xyz/pentesting-web/file-upload
+ const FORBIDDEN_EXTENSIONS = '!^(?:cgi|exe|sh|bash|com|pif|jspx?|jar|js[wxv]|action|do|php(?:s|\d+)?|pht|phtml?|shtml|phar|htaccess|inc|cfml?|cfc|dbm|swf|pl|perl|py|pyc|asp|so)$!i';
+
+ public function selfCheck(): void
+ {
+ $this->assert($this->type === self::TYPE_DIRECTORY || $this->type === self::TYPE_FILE, 'Unknown file type');
+ $this->assert($this->type === self::TYPE_DIRECTORY || $this->size !== null, 'File size must be set');
+ $this->assert(trim($this->name) !== '', 'Le nom de fichier ne peut rester vide');
+ $this->assert(strlen($this->path), 'Le chemin ne peut rester vide');
+ $this->assert(null === $this->parent || strlen($this->parent), 'Le chemin ne peut rester vide');
+ $this->assert(false === strpos($this->path, '//'));
+ $this->assert(null === $this->parent || false === strpos($this->parent, '//'));
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ if ($this->parent) {
+ Files::ensureDirectoryExists($this->parent);
+ }
+
+ $ok = parent::save();
+
+ $context = $this->context();
+
+ // Link file to transaction/user
+ if ($ok && $this->type === self::TYPE_FILE && in_array($context, [self::CONTEXT_USER, self::CONTEXT_TRANSACTION])) {
+ // Only insert if ID exists in table
+ $db = DB::getInstance();
+
+ if ($context == self::CONTEXT_USER) {
+ $id = (int)Utils::basename(Utils::dirname($this->parent));
+ $field = Utils::basename($this->parent);
+
+ if (!$id || !$field) {
+ return $ok;
+ }
+
+ // The field does not exist anymore, don't link
+ if (!DynamicFields::get($field)) {
+ return $ok;
+ }
+
+ $sql = sprintf('INSERT OR IGNORE INTO %s_files (id_file, id_user, field) SELECT %d, %d, %s FROM %1$s WHERE id = %3$d;',
+ 'users',
+ $this->id(),
+ $id,
+ $db->quote($field)
+ );
+ }
+ else {
+ $id = (int)Utils::basename($this->parent);
+
+ if (!$id) {
+ return $ok;
+ }
+
+ $sql = sprintf('INSERT OR IGNORE INTO %s_files (id_file, id_transaction) SELECT %d, %d FROM %1$s WHERE id = %3$d;',
+ 'acc_transactions',
+ $this->id(),
+ $id
+ );
+ }
+
+ $db->exec($sql);
+ }
+
+ return $ok;
+ }
+
+ public function context(): string
+ {
+ $value = strtok($this->path, '/');
+ strtok('');
+ return $value;
+ }
+
+ public function parent(): File
+ {
+ return Files::get($this->parent);
+ }
+
+ public function getLocalFilePath(): ?string
+ {
+ $path = Files::callStorage('getLocalFilePath', $this);
+
+ if (null === $path || !file_exists($path)) {
+ return null;
+ }
+
+ return $path;
+ }
+
+ public function etag(): string
+ {
+ if (isset($this->md5)) {
+ return $this->md5;
+ }
+ elseif (!$this->isDir()) {
+ return md5($this->path . $this->size . $this->modified->getTimestamp());
+ }
+ else {
+ return md5($this->path . $this->getRecursiveSize() . $this->getRecursiveLastModified());
+ }
+ }
+
+ public function rehash($pointer = null): void
+ {
+ if ($this->isDir()) {
+ return;
+ }
+
+ $path = !$pointer ? $this->getLocalFilePath() : null;
+
+ if ($path) {
+ $hash = md5_file($path);
+ }
+ else {
+ $p = $pointer ?? $this->getReadOnlyPointer();
+
+ if (!$p) {
+ return;
+ }
+
+ $hash = hash_init('md5');
+
+ while (!feof($p)) {
+ hash_update($hash, fread($p, 8192));
+ }
+
+ $hash = hash_final($hash);
+
+ if (null === $pointer) {
+ fclose($p);
+ }
+ else {
+ fseek($pointer, 0, SEEK_SET);
+ }
+ }
+
+ $this->set('md5', $hash);
+ }
+
+ /**
+ * Return TRUE if the file can be previewed natively in a browser
+ * @return bool
+ */
+ public function canPreview(): bool
+ {
+ if (in_array($this->mime, self::PREVIEW_TYPES)) {
+ return true;
+ }
+
+ if (!WOPI_DISCOVERY_URL) {
+ return false;
+ }
+
+ if ($this->getWopiURL()) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function moveToTrash(bool $mark_as_trash = true): void
+ {
+ if ($this->context() === self::CONTEXT_TRASH) {
+ return;
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ // We need to put files in a specific subdirectory
+ // or we might overwrite files previously put in trash
+ // (for example you move "accounting/report.ods" to "trash/accounting/reports.ods",
+ // then you move "accounting/" to "trash/accounting/", this would delete the previously
+ // trashed "reports.ods" file)
+ $hash = md5($this->path);
+ $hash = substr(date('Y-m-d.His.') . $hash, 0, 40);
+
+ // Only mark the root folder as trashed, but still move everything else
+ $this->set('trash', new \DateTime);
+
+ // Move versions as well
+ if ($v = Files::get(self::CONTEXT_VERSIONS . '/' . $this->path)) {
+ $v->rename(self::CONTEXT_TRASH . '/' . $hash . '/' . $v->path, false);
+ }
+
+ // ->rename() will ->save()
+ $this->rename(self::CONTEXT_TRASH . '/' . $hash . '/' . $this->path, false);
+
+ Plugins::fire('file.trash', false, ['file' => $this]);
+
+ $db->commit();
+ }
+
+ public function restoreFromTrash(): void
+ {
+ if ($this->context() !== self::CONTEXT_TRASH) {
+ return;
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ $root = strtok($this->path, '/') . '/' . strtok('/');
+ $orig_path = strtok('');
+
+ $this->set('trash', null);
+
+ $v = Files::get($root . '/' . self::CONTEXT_VERSIONS . '/' . $orig_path);
+
+ // Restore versions
+ if ($v) {
+ $v->rename(self::CONTEXT_VERSIONS . '/' . $orig_path, false);
+ }
+
+ // rename() will do the save()
+ $this->rename($orig_path, false);
+
+ Plugins::fire('file.restore', false, ['file' => $this]);
+
+ $db->commit();
+ }
+
+ public function deleteCache(): void
+ {
+ // This also deletes thumbnail links
+ Web_Cache::delete($this->uri());
+ $this->deleteThumbnails();
+ }
+
+ /**
+ * Delete file from local database, but not the file from the storage itself
+ */
+ public function deleteSafe(): bool
+ {
+ $this->deleteCache();
+ return parent::delete();
+ }
+
+ public function delete(): bool
+ {
+ Files::assertStorageIsUnlocked();
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ // Delete actual file content
+ $ok = Files::callStorage('delete', $this);
+
+ // Also delete sub-directories and files, if the storage backend is not able to do it
+ // (eg. object storage)
+ if (!$ok && $this->type == self::TYPE_DIRECTORY) {
+ foreach (Files::list($this->path) as $file) {
+ if (!$file->delete()) {
+ $db->rollback();
+ return false;
+ }
+ }
+ }
+ elseif (!$ok) {
+ throw new \LogicException('Storage backend couldn\'t delete a file');
+ }
+
+ Plugins::fire('file.delete', false, ['file' => $this]);
+
+ $this->deleteCache();
+ $this->deleteVersions();
+
+ $r = parent::delete();
+
+ $db->commit();
+
+ return $r;
+ }
+
+
+ /**
+ * Copy the current file to a new location
+ * @param string $target Target path
+ * @return self
+ */
+ public function copy(string $new_path): self
+ {
+ if ($this->isDir()) {
+ throw new \LogicException('Cannot copy a directory');
+ }
+
+ $path = $this->getLocalFilePath();
+ $pointer = $path ? null : $this->getReadOnlyPointer();
+
+ return Files::createFrom($new_path, compact('path', 'pointer'));
+ }
+
+ /**
+ * Change ONLY the file name, not the parent path
+ * @param string $new_name New file name
+ * @return bool
+ */
+ public function changeFileName(string $new_name, bool $check_session = true, bool $check_exists = false): bool
+ {
+ self::validateFileName($new_name);
+
+ $v = $this->getVersionsDirectory();
+
+ $r = $this->rename(ltrim($this->parent . '/' . $new_name, '/'), $check_session, $check_exists);
+
+ // Rename versions directory as well
+ if ($v && $r) {
+ $v->changeFileName($new_name);
+ }
+
+ return $r;
+ }
+
+ /**
+ * Change ONLY the directory where the file is
+ * @param string $target New directory path
+ * @return bool
+ */
+ public function move(string $target, bool $check_session = true, bool $check_exists = false): bool
+ {
+ $v = $this->getVersionsDirectory();
+
+ $r = $this->rename($target . '/' . $this->name, $check_session);
+
+ if ($r && $v) {
+ $v->rename(self::CONTEXT_VERSIONS . '/' . $this->path);
+ }
+
+ return $r;
+ }
+
+ /**
+ * Rename a file, this can include moving it (the UNIX way)
+ * @param string $new_path Target path
+ * @return bool
+ */
+ public function rename(string $new_path, bool $check_session = true, bool $check_exists = false): bool
+ {
+ $name = Utils::basename($new_path);
+
+ self::validatePath($new_path);
+ self::validateFileName($name);
+
+ if ($check_session) {
+ self::validateCanHTML($name, $new_path);
+ }
+
+ if ($new_path == $this->path) {
+ throw new UserException(sprintf('Impossible de renommer "%s" lui-même', $this->path));
+ }
+
+ if (0 === strpos($new_path . '/', $this->path . '/')) {
+ if ($this->type != self::TYPE_DIRECTORY) {
+ throw new UserException(sprintf('Impossible de renommer "%s" vers "%s"', $this->path, $new_path));
+ }
+ }
+
+ $parent = Utils::dirname($new_path);
+ $is_dir = $this->isDir();
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ // Does the target already exist?
+ $exists = Files::get($new_path);
+
+ if ($exists && $check_exists) {
+ throw new UserException('Un fichier de ce nom existe déjà.');
+ }
+
+ // List sub-files and sub-directories now, before they are changed
+ $list = $is_dir ? Files::list($this->path) : [];
+
+ // Make sure parent target directory exists
+ Files::ensureDirectoryExists($parent);
+
+ // Save current object for storage use
+ $old = clone $this;
+
+ // Update internal values
+ $this->set('parent', $parent);
+ $this->set('path', $new_path);
+ $this->set('name', $name);
+
+ // If the target does not exist already, move the current file now
+ // this will avoid ensureDirectoryExists to create a duplicate
+ if (!$exists) {
+ $r = $this->save();
+ }
+
+ // Move each file to the new target
+ if ($is_dir) {
+ foreach ($list as $file) {
+ $file->move($new_path . trim(substr($file->parent, strlen($old->path)), '/'), $check_session);
+ }
+ }
+
+ if ($exists) {
+ if ($is_dir) {
+ // Make sure trash state is transmitted to target path
+ $exists->set('trash', $this->trash);
+ $r = $exists->save();
+
+ // We assume that at this point, everything inside the source directory
+ // has been moved to the existing target directory
+ // So we can delete the source directory from the database
+ // (both $this and $exists point to the same path, so we can't save $this)
+ parent::delete();
+ $db->commit();
+
+ return $r;
+ }
+ else {
+ // Overwrite existing file
+ $exists->deleteSafe();
+ }
+
+ unset($exists);
+ $r = $this->save();
+ }
+
+ if (!$is_dir) {
+ // Actually move the file
+ Files::callStorage('rename', $old, $new_path);
+ }
+
+ Plugins::fire('file.rename', false, ['file' => $this, 'new_path' => $new_path]);
+
+ $db->commit();
+
+ return $r;
+ }
+
+ public function setContent(string $content): self
+ {
+ $this->store(['content' => rtrim($content)]);
+ return $this;
+ }
+
+ /**
+ * Store contents in file, either from a local path, from a binary string or from a pointer
+ *
+ * @param array $source [path, content or pointer]
+ * @param string $source_content
+ * @param bool $index_search Set to FALSE if you don't want the document to be indexed in the file search
+ * @return self
+ */
+ public function store(array $source): self
+ {
+ if (!$this->path || !$this->name) {
+ throw new \LogicException('Cannot store a file that does not have a target path and name');
+ }
+
+ if ($this->type == self::TYPE_DIRECTORY) {
+ throw new \LogicException('Cannot store a directory');
+ }
+
+ if (!isset($source['path']) && !isset($source['content']) && !isset($source['pointer'])) {
+ throw new \InvalidArgumentException('Unknown source type');
+ }
+ elseif (count(array_filter($source, fn($a) => !is_null($a))) != 1) {
+ throw new \InvalidArgumentException('Invalid source type');
+ }
+
+ Files::assertStorageIsUnlocked();
+
+ $delete_after = false;
+ $path = $source['path'] ?? null;
+ $content = $source['content'] ?? null;
+ $pointer = $source['pointer'] ?? null;
+ $new = !$this->exists();
+
+ if ($path) {
+ $this->set('size', filesize($path));
+ Files::checkQuota($this->size);
+ $this->set('md5', md5_file($path));
+ }
+ elseif (null !== $content) {
+ $this->set('size', strlen($content));
+ Files::checkQuota($this->size);
+ $this->set('md5', md5($content));
+ }
+ elseif ($pointer) {
+ // See https://github.com/php/php-src/issues/9441
+ $meta = stream_get_meta_data($pointer);
+
+ if (isset($meta['uri']) && $meta['uri'] == 'php://input') {
+ while (!feof($pointer)) {
+ fread($pointer, 8192);
+ }
+ }
+ elseif (0 !== fseek($pointer, 0, SEEK_END)) {
+ throw new \RuntimeException('Stream is not seekable');
+ }
+
+ $this->set('size', ftell($pointer));
+ fseek($pointer, 0, SEEK_SET);
+ Files::checkQuota($this->size);
+
+ $this->rehash($pointer);
+ }
+
+ // File hasn't changed
+ if (!$new && !$this->isModified('md5')) {
+ return $this;
+ }
+
+ // Check that it's a real image
+ if ($this->image) {
+ if ($path) {
+ $blob = file_get_contents($path, false, null, 0, 1000);
+ }
+ elseif ($pointer) {
+ $blob = fread($pointer, 1000);
+ fseek($pointer, 0, SEEK_SET);
+ }
+ else {
+ $blob = substr($content, 0, 1000);
+ }
+
+ if ($size = Blob::getSize($blob)) {
+ // This is to avoid pixel flood attacks
+ if ($size[0] > 8000 || $size[1] > 8000) {
+ throw new ValidationException('Cette image est trop grande (taille max 8000 x 8000 pixels)');
+ }
+
+ // Recompress PNG files from base64, assuming they are coming
+ // from JS canvas which doesn't know how to gzip (d'oh!)
+ if ($size[2] == 'image/png' && null !== $content) {
+ $i = Image::createFromBlob($content);
+ $content = $i->output('png', true);
+ $this->set('size', strlen($content));
+ unset($i);
+ }
+ }
+ elseif ($type = Blob::getType($blob)) {
+ // WebP is fine, but we cannot get its size
+ }
+ else {
+ // Not an image
+ $this->set('image', false);
+ }
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ // Set modified time if not already set before
+ if (!$this->isModified('modified')) {
+ $this->set('modified', new \DateTime);
+ }
+
+ // Only archive previous version if it was more than 0 bytes
+ if (!$new && $this->getModifiedProperty('size') !== 0 && $this->size > 0) {
+ $this->createVersion();
+ $this->pruneVersions();
+ }
+
+ // Save metadata now, and rollback if required
+ $this->save();
+
+ try {
+ if (null !== $path) {
+ $return = Files::callStorage('storePath', $this, $path);
+ }
+ elseif (null !== $content) {
+ $return = Files::callStorage('storeContent', $this, $content);
+ }
+ else {
+ $return = Files::callStorage('storePointer', $this, $pointer);
+ }
+
+ if (!$return) {
+ throw new UserException('Le fichier n\'a pas pu être enregistré.');
+ }
+
+ Plugins::fire('file.store', false, ['file' => $this]);
+
+ if (!$new) {
+ Plugins::fire('file.overwrite', false, ['file' => $this]);
+ }
+ else {
+ Plugins::fire('file.create', false, ['file' => $this]);
+ }
+
+ $this->deleteCache();
+
+ // Index regular files, not directories
+ if ($this->type == self::TYPE_FILE) {
+ $this->indexForSearch(compact('content', 'path', 'pointer'));
+ }
+
+ $db->commit();
+
+ return $this;
+ }
+ catch (\Exception $e) {
+ $db->rollback();
+ throw $e;
+ }
+ finally {
+ if (null !== $pointer) {
+ fclose($pointer);
+ }
+ }
+ }
+
+ public function indexForSearch(?array $source = null, ?string $title = null, ?string $forced_mime = null): void
+ {
+ $mime = $forced_mime ?? $this->mime;
+ $ext = $this->extension();
+ $content = null;
+
+ if ($this->isDir() && (!$mime || !isset($source['content']))) {
+ return;
+ }
+
+ // Store content in search table
+ if (substr($mime, 0, 5) == 'text/') {
+ $content = $source['content'] ?? $this->fetch();
+
+ if ($mime === 'text/html' || $mime == 'text/xml') {
+ $content = html_entity_decode(strip_tags($content), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401);
+ }
+ }
+ elseif ($ext == 'pdf' && PDFTOTEXT_COMMAND === 'mupdf') {
+ if (empty($source)) {
+ // Prefer path to pointer
+ $source['path'] = $this->getLocalFilePath();
+ $source['pointer'] = $source['path'] ? null : $this->getReadOnlyPointer();
+ }
+
+ $tmpfile = null;
+
+ try {
+ // mutool convert doesn't handle stdin/stdout :(
+ if (isset($source['pointer'])) {
+ fseek($source['pointer'], 0, SEEK_END);
+ $size = ftell($source['pointer']);
+ rewind($source['pointer']);
+
+ if ($size >= 500*1024*1024) {
+ throw new \OverflowException('PDF file is too large');
+ }
+
+ $tmpfile = tempnam(STATIC_CACHE_ROOT, 'pdftotext-');
+ $fp = fopen($tmpfile, 'wb');
+
+ while (!feof($source['pointer'])) {
+ fwrite($fp, fread($source['pointer'], 8192));
+ }
+
+ fclose($fp);
+ }
+ elseif (isset($source['content'])) {
+ $tmpfile = tempnam(STATIC_CACHE_ROOT, 'pdftotext-');
+ file_put_contents($tmpfile, $source['content']);
+ }
+
+ $tmpdest = tempnam(STATIC_CACHE_ROOT, 'pdftotext-out-');
+
+ $cmd = sprintf('mutool convert -F text -o %s %s',
+ Utils::escapeshellarg($tmpdest),
+ Utils::escapeshellarg($tmpfile ?? $source['path'])
+ );
+
+ Utils::exec($cmd, 2, null, null);
+ $content = file_get_contents($tmpdest);
+ }
+ catch (\OverflowException $e) {
+ // PDF extraction was longer than 2 seconds: PDF file is likely too large
+ $content = null;
+ }
+
+ if ($tmpfile) {
+ Utils::safe_unlink($tmpfile);
+ }
+
+ Utils::safe_unlink($tmpdest);
+ }
+ elseif (in_array($ext, self::EXTENSIONS_TEXT_CONVERT) && is_array($source)) {
+
+ if (empty($source)) {
+ $source['pointer'] = $this->getReadOnlyPointer();
+ $source['path'] = $source['pointer'] ? null : $this->getLocalFilePath();
+ }
+
+ $content = ToText::from($source);
+ }
+ else {
+ $content = null;
+ }
+
+ // Only index valid UTF-8
+ if (isset($content) && preg_match('//u', $content)) {
+ // Truncate text at 150KB
+ $content = substr(trim($content), 0, 150*1024);
+ }
+ else {
+ $content = null;
+ }
+
+ if (null === $content && null === $title) {
+ // This is already the same as what has been inserted by SQLite
+ return;
+ }
+
+ $db = DB::getInstance();
+ $db->preparedQuery('REPLACE INTO files_search (docid, path, title, content) VALUES (?, ?, ?, ?);',
+ $this->id(), $this->path, $title ?? $this->name, $content);
+ }
+
+ /**
+ * Returns true if this is a vector or bitmap image
+ * as 'image' property is only for bitmaps
+ * @return boolean
+ */
+ public function isImage(): bool
+ {
+ if ($this->image || $this->mime == 'image/svg+xml') {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function isDir(): bool
+ {
+ return $this->type == self::TYPE_DIRECTORY;
+ }
+
+ public function iconShape(): ?string
+ {
+ if ($this->isImage()) {
+ return 'image';
+ }
+ elseif ($this->isDir()) {
+ return 'directory';
+ }
+
+ return Files::getIconShape($this->name);
+ }
+
+ /**
+ * Full URL with https://...
+ */
+ public function url(bool $download = false): string
+ {
+ $base = in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_MODULES, self::CONTEXT_CONFIG]) ? WWW_URL : BASE_URL;
+ $url = $base . $this->uri();
+
+ if ($download) {
+ $url .= '?download';
+ }
+
+ return $url;
+ }
+
+ /**
+ * Returns local URI, eg. user/1245/file.jpg
+ */
+ public function uri(): string
+ {
+ if ($this->context() == self::CONTEXT_WEB) {
+ if ($this->isDir()) {
+ $parts = [$this->name];
+ }
+ else {
+ $parts = [Utils::basename($this->parent), $this->name];
+ }
+ }
+ else {
+ $parts = explode('/', $this->path);
+ }
+
+ $parts = array_map('rawurlencode', $parts);
+
+ return implode('/', $parts);
+ }
+
+ /**
+ * Return a HTML link to the file
+ */
+ public function link(Session $session, ?string $thumb = null, bool $allow_edit = false, ?string $url = null)
+ {
+ if ($thumb == 'auto') {
+ if ($this->hasThumbnail()) {
+ $thumb = '150px';
+ }
+ else {
+ $thumb = 'icon';
+ }
+ }
+
+ if ($thumb === 'icon') {
+ $label = sprintf(' ', Utils::iconUnicode($this->iconShape()));
+ }
+ elseif ($thumb) {
+ $label = sprintf(' ', htmlspecialchars($this->thumb_url($thumb)), htmlspecialchars($this->name));
+ }
+ else {
+ $label = preg_replace('/[_.-]/', '$0', htmlspecialchars($this->name));
+ }
+
+ $editor = $this->editorType();
+
+ if ($url) {
+ $attrs = sprintf('href="%s"', Utils::getLocalURL($url));
+ }
+ elseif ($editor && ($allow_edit || $editor == 'wopi') && $this->canWrite($session)) {
+ $attrs = sprintf('href="%s" target="_dialog" data-dialog-class="fullscreen"',
+ Utils::getLocalURL('!common/files/edit.php?p=') . rawurlencode($this->path));
+ }
+ elseif ($this->canPreview($session)) {
+ $attrs = sprintf('href="%s" target="_dialog" data-mime="%s" data-caption="%s"',
+ $this->isImage() ? $this->url() : Utils::getLocalURL('!common/files/preview.php?p=') . rawurlencode($this->path),
+ $this->mime,
+ $this->name
+ );
+ }
+ else {
+ $attrs = sprintf('href="%s" target="_blank"', $this->url(true));
+ }
+
+ return sprintf('%s ', $attrs, $label);
+ }
+
+ /**
+ * Envoie le fichier au client HTTP
+ */
+ public function serve($download = null): void
+ {
+ // Only simple files can be served, not directories
+ if ($this->type != self::TYPE_FILE) {
+ header('HTTP/1.1 404 Not Found', true, 404);
+ throw new UserException('Page non trouvée', 404);
+ }
+
+ $this->_serve(null, $download);
+
+ if (($path = $this->getLocalFilePath()) && in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_CONFIG])) {
+ Web_Cache::link($this->uri(), $path);
+ }
+ }
+
+ protected function _serve(?string $path = null, $download = null): void
+ {
+ if ($this->isPublic()) {
+ Utils::HTTPCache($this->etag(), $this->modified->getTimestamp());
+ }
+ else {
+ // Disable browser cache
+ header('Pragma: private');
+ header('Expires: -1');
+ header('Cache-Control: private, must-revalidate, post-check=0, pre-check=0');
+ }
+
+ header('X-Powered-By: Paheko/PHP');
+
+ if (null === $path) {
+ $type = $this->mime;
+
+ // Force CSS mimetype
+ if (substr($this->name, -4) == '.css') {
+ $type = 'text/css';
+ }
+ elseif (substr($this->name, -3) == '.js') {
+ $type = 'text/javascript';
+ }
+
+ if (substr($type, 0, 5) == 'text/') {
+ $type .= ';charset=utf-8';
+ }
+
+ header(sprintf('Content-Type: %s', $type));
+ header(sprintf('Content-Disposition: %s; filename="%s"', $download ? 'attachment' : 'inline', is_string($download) ? $download : $this->name));
+ }
+ elseif (!file_exists($path)) {
+ throw new UserException('Le contenu du fichier est introuvable.');
+ }
+
+ // Use X-SendFile, if available, and storage has a local copy
+ if (Router::isXSendFileEnabled()) {
+ $local_path = $path ?? Files::callStorage('getLocalFilePath', $this);
+
+ if ($local_path) {
+ Router::xSendFile($local_path);
+ return;
+ }
+ }
+
+ // Disable gzip, against buffering issues
+ if (function_exists('apache_setenv')) {
+ @apache_setenv('no-gzip', 1);
+ }
+
+ @ini_set('zlib.output_compression', 'Off');
+
+ // Don't return Content-Length on OVH, as their HTTP 2.0 proxy is buggy
+ // @see https://fossil.kd2.org/paheko/tktview/8b342877cda6ef7023b16277daa0ec8e39d949f8
+ if (HOSTING_PROVIDER !== 'OVH') {
+ header(sprintf('Content-Length: %d', $path ? filesize($path) : $this->size));
+ }
+
+ if (@ob_get_length()) {
+ @ob_clean();
+ }
+
+ if (null === $path) {
+ $pointer = $this->getReadOnlyPointer();
+
+ if (null === $pointer) {
+ $path = $this->getLocalFilePath();
+ }
+ }
+
+ if (null !== $path) {
+ readfile($path);
+ }
+ elseif (null !== $pointer) {
+ while (!feof($pointer)) {
+ echo fread($pointer, 32*1024);
+ }
+
+ fclose($pointer);
+ }
+ else {
+ header('HTTP/1.1 404 Not Found', true, 404);
+ header('Content-Type: text/html', true);
+ throw new UserException('Le contenu de ce fichier est introuvable', 404);
+ }
+ }
+
+ public function fetch()
+ {
+ if ($this->type == self::TYPE_DIRECTORY) {
+ throw new \LogicException('Cannot fetch a directory');
+ }
+
+ $p = $this->getReadOnlyPointer();
+
+ if (null === $p) {
+ $path = Files::callStorage('getLocalFilePath', $this);
+
+ if (!$path || !file_exists($path)) {
+ return '';
+ }
+
+ return file_get_contents($path);
+ }
+
+ $out = '';
+
+ while (!feof($p)) {
+ $out .= fread($p, 8192);
+ }
+
+ fclose($p);
+ return $out;
+ }
+
+ public function render(?string $user_prefix = null)
+ {
+ $editor_type = $this->renderFormat();
+
+ if ($editor_type == 'skriv' || $editor_type == 'markdown') {
+ return Render::render($editor_type, $this->path, $this->fetch(), $user_prefix);
+ }
+ elseif ($editor_type == 'text') {
+ return sprintf('
%s ', htmlspecialchars($this->fetch()));
+ }
+ else {
+ throw new \LogicException('Cannot render file of this type');
+ }
+ }
+
+ public function pathHash(): string
+ {
+ return sha1($this->path);
+ }
+
+ public function isPublic(): bool
+ {
+ $context = $this->context();
+
+ if ($context == self::CONTEXT_MODULES || $context == self::CONTEXT_WEB) {
+ return true;
+ }
+
+ if ($context == self::CONTEXT_CONFIG) {
+ $file = array_search($this->path, Config::FILES);
+
+ if ($file && in_array($file, Config::FILES_PUBLIC)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function path_uri(): string
+ {
+ return rawurlencode($this->path);
+ }
+
+ public function parent_uri(): string
+ {
+ return $this->parent ? rawurlencode($this->parent) : '';
+ }
+
+ public function getFormatDescription(): string
+ {
+ switch ($this->extension()) {
+ case 'odt': return 'Document LibreOffice';
+ case 'ods': return 'Tableur LibreOffice';
+ case 'odp': return 'Présentation LibreOffice';
+ case 'odg': return 'Dessin LibreOffice';
+ case 'doc':
+ case 'docx': return 'Document Microsoft Office';
+ case 'xls':
+ case 'xlsx': return 'Tableur Microsoft Office';
+ case 'ppt':
+ case 'pptx': return 'Présentation Microsoft Office';
+ case 'pdf': return 'Document PDF';
+ case 'png':
+ case 'webp':
+ case 'jpeg':
+ case 'jpg':
+ case 'gif':
+ return 'Image';
+ case 'epub':
+ case 'mobi':
+ return 'Livre électronique';
+ case 'md': return 'Texte MarkDown';
+ case 'txt': return 'Texte';
+ case 'mp3':
+ case 'ogg':
+ case 'aac':
+ case 'flac':
+ case 'opus':
+ case 'wav':
+ case 'wma':
+ return 'Fichier audio';
+ case 'mkv':
+ case 'mp4':
+ case 'avi':
+ case 'mov':
+ case 'webm':
+ return 'Fichier vidéo';
+ default: return 'Fichier';
+ }
+ }
+
+ public function extension(): ?string
+ {
+ $pos = strrpos($this->name, '.');
+
+ if (false === $pos) {
+ return null;
+ }
+
+ return strtolower(substr($this->name, $pos+1));
+ }
+
+ static public function filterName(string $name): string
+ {
+ foreach (self::FORBIDDEN_CHARACTERS as $char) {
+ $name = str_replace($char, '', $name);
+ }
+
+ return $name;
+ }
+
+ static public function validateFileName(string $name): void
+ {
+ if (0 === strpos($name, '.ht') || $name == '.user.ini') {
+ throw new ValidationException('Nom de fichier interdit');
+ }
+
+ if (strlen($name) > 250) {
+ throw new ValidationException('Nom de fichier trop long');
+ }
+
+ foreach (self::FORBIDDEN_CHARACTERS as $char) {
+ if (strpos($name, $char) !== false) {
+ throw new ValidationException('Nom de fichier invalide, le caractère suivant est interdit : ' . $char);
+ }
+ }
+
+ $extension = strtolower(substr($name, strrpos($name, '.')+1));
+
+ if (preg_match(self::FORBIDDEN_EXTENSIONS, $extension)) {
+ throw new ValidationException(sprintf('Extension de fichier "%s" non autorisée, merci de renommer le fichier avant envoi.', $extension));
+ }
+ }
+
+ static public function validatePath(string $path): array
+ {
+ if (false != strpos($path, '..')) {
+ throw new ValidationException('Chemin invalide: ' . $path);
+ }
+
+ $parts = explode('/', trim($path, '/'));
+
+ if (count($parts) < 1) {
+ throw new ValidationException('Chemin invalide: ' . $path);
+ }
+
+ $context = array_shift($parts);
+
+ if (!array_key_exists($context, self::CONTEXTS_NAMES)) {
+ throw new ValidationException('Contexte invalide: ' . $context);
+ }
+
+ $name = array_pop($parts);
+ $ref = implode('/', $parts);
+ return [$context, $ref ?: null, $name];
+ }
+
+ /**
+ * Only admins can create or rename files to .html / .js
+ * This is to avoid XSS attacks from a non-admin user
+ */
+ static public function validateCanHTML(string $name, string $path, ?Session $session = null): void
+ {
+ if (!preg_match('/\.(?:htm|js|xhtm)/', $name)) {
+ return;
+ }
+
+ $session ??= Session::getInstance();
+
+ if (0 === strpos($path, self::CONTEXT_MODULES . '/web') && $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)) {
+ return;
+ }
+
+ if ($session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) {
+ return;
+ }
+
+ throw new ValidationException('Seuls les administrateurs peuvent créer des fichiers de ce type.');
+ }
+
+ public function renderFormat(): ?string
+ {
+ if (substr($this->name, -6) == '.skriv') {
+ $format = Render::FORMAT_SKRIV;
+ }
+ elseif (substr($this->name, -3) == '.md') {
+ $format = Render::FORMAT_MARKDOWN;
+ }
+ elseif (substr($this->mime, 0, 5) == 'text/' && $this->mime != 'text/html') {
+ $format = 'text';
+ }
+ else {
+ $format = null;
+ }
+
+ return $format;
+ }
+
+ public function editorType(): ?string
+ {
+ static $text_extensions = ['css', 'txt', 'xml', 'html', 'htm', 'tpl', 'ini'];
+
+ $ext = $this->extension();
+
+ $format = $this->renderFormat();
+
+ if ($format == Render::FORMAT_SKRIV || $format == Render::FORMAT_MARKDOWN) {
+ return 'web';
+ }
+ elseif ($format == 'text' || in_array($ext, $text_extensions)) {
+ return 'code';
+ }
+ elseif (!WOPI_DISCOVERY_URL) {
+ return null;
+ }
+
+ if ($this->getWopiURL('edit')) {
+ return 'wopi';
+ }
+
+ return null;
+ }
+
+ public function getWopiURL(?string $action = null): ?string
+ {
+ if (!WOPI_DISCOVERY_URL) {
+ return null;
+ }
+
+ $cache_file = SHARED_CACHE_ROOT . '/wopi.json';
+ static $data = null;
+
+ if (null === $data) {
+ // We are caching discovery for 15 days, there is no need to request the server all the time
+ if (file_exists($cache_file) && filemtime($cache_file) >= 3600*24*15) {
+ $data = json_decode(file_get_contents($cache_file), true);
+ }
+
+ if (!$data) {
+ try {
+ $data = WOPI::discover(WOPI_DISCOVERY_URL);
+ file_put_contents($cache_file, json_encode($data));
+ }
+ catch (\RuntimeException $e) {
+ return null;
+ }
+ }
+ }
+
+ $ext = $this->extension();
+ $url = null;
+
+ if ($action) {
+ $url = $data['extensions'][$ext][$action] ?? null;
+ $url ??= $data['mimetypes'][$this->mime][$action] ?? null;
+ }
+ elseif (isset($data['extensions'][$ext])) {
+ $url = current($data['extensions'][$ext]);
+ }
+ elseif (isset($data['mimetypes'][$this->mime])) {
+ $url = current($data['mimetypes'][$this->mime]);
+ }
+
+ return $url;
+ }
+
+ public function editorHTML(bool $readonly = false): ?string
+ {
+ $url = $this->getWopiURL('edit');
+
+ if (!$url) {
+ return null;
+ }
+
+ $wopi = new WOPI;
+ $url = $wopi->setEditorOptions($url, [
+ // Undocumented editor parameters
+ // see https://github.com/nextcloud/richdocuments/blob/2338e2ff7078040d54fc0c70a96c8a1b860f43a0/src/helpers/url.js#L49
+ 'lang' => 'fr',
+ //'closebutton' => 1,
+ //'revisionhistory' => 1,
+ //'title' => 'Test',
+ 'permission' => $readonly || !$this->canWrite() ? 'readonly' : '',
+ ]);
+ $wopi->setStorage(new Storage(Session::getInstance()));
+ return $wopi->getEditorHTML($url, $this->path, $this->name);
+ }
+
+ public function export(): array
+ {
+ return $this->asArray(true) + ['url' => $this->url()];
+ }
+
+ /**
+ * Returns a sharing link for a file, valid
+ * @param int $expiry Expiry, in hours
+ * @param string|null $password
+ * @return string
+ */
+ public function createShareLink(int $expiry = 24, ?string $password = null): string
+ {
+ $expiry = intval(time() / 3600) + $expiry;
+
+ $hash = $this->_createShareHash($expiry, $password);
+
+ $expiry -= intval(gmmktime(0, 0, 0, 8, 1, 2022) / 3600);
+ $expiry = base_convert($expiry, 10, 36);
+
+ return sprintf('%s%s?s=%s%s:%s', WWW_URL, $this->uri(), $password ? ':' : '', $hash, $expiry);
+ }
+
+ protected function _createShareHash(int $expiry, ?string $password): string
+ {
+ $password = trim((string)$password) ?: null;
+
+ $str = sprintf('%s:%s:%s:%s', SECRET_KEY, $this->path, $expiry, $password);
+
+ $hash = hash('sha256', $str, true);
+ $hash = substr($hash, 0, 10);
+ $hash = Security::base64_encode_url_safe($hash);
+ return $hash;
+ }
+
+ public function checkShareLinkRequiresPassword(string $str): bool
+ {
+ return substr($str, 0, 1) == ':';
+ }
+
+ public function checkShareLink(string $str, ?string $password): bool
+ {
+ $str = ltrim($str, ':');
+
+ $hash = strtok($str, ':');
+ $expiry = strtok('');
+
+ if (!ctype_alnum($expiry)) {
+ return false;
+ }
+
+ $expiry = (int)base_convert($expiry, 36, 10);
+ $expiry += intval(gmmktime(0, 0, 0, 8, 1, 2022) / 3600);
+
+ if ($expiry < time()/3600) {
+ return false;
+ }
+
+ $hash_check = $this->_createShareHash($expiry, $password);
+
+ return hash_equals($hash, $hash_check);
+ }
+
+ public function touch($date = null): void
+ {
+ if (null === $date) {
+ $date = new \DateTime;
+ }
+ elseif (!($date instanceof \DateTimeInterface) && ctype_digit($date)) {
+ $date = new \DateTime('@' . $date);
+ }
+ elseif (!($date instanceof \DateTimeInterface)) {
+ throw new \InvalidArgumentException('Invalid date string: ' . $date);
+ }
+
+ Files::assertStorageIsUnlocked();
+ Files::callStorage('touch', $this, $date);
+ $this->set('modified', $date);
+ $this->save();
+ }
+
+ public function getReadOnlyPointer()
+ {
+ return Files::callStorage('getReadOnlyPointer', $this);
+ }
+
+ public function getRecursiveSize(): int
+ {
+ if ($this->type == self::TYPE_FILE) {
+ return $this->size;
+ }
+
+ $db = DB::getInstance();
+ return $db->firstColumn('SELECT SUM(size) FROM files
+ WHERE type = ? AND path LIKE ? ESCAPE \'!\';',
+ File::TYPE_FILE,
+ $db->escapeLike($this->path, '!') . '/%'
+ ) ?: 0;
+ }
+
+ public function getRecursiveLastModified(): int
+ {
+ if ($this->type == self::TYPE_FILE) {
+ return $this->modified;
+ }
+
+ $db = DB::getInstance();
+ return $db->firstColumn('SELECT strftime(\'%s\', MAX(modified)) FROM files
+ WHERE type = ? AND path LIKE ? ESCAPE \'!\';',
+ File::TYPE_FILE,
+ $db->escapeLike($this->path, '!') . '/%'
+ ) ?: 0;
+ }
+
+ public function webdav_root_url(): string
+ {
+ return BASE_URL . 'dav/' . $this->context() . '/';
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Files/FilePermissionsTrait.php b/src/include/lib/Paheko/Entities/Files/FilePermissionsTrait.php
new file mode 100644
index 0000000..c723fa5
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Files/FilePermissionsTrait.php
@@ -0,0 +1,163 @@
+checkShareLink($share_hash, $share_password)) {
+ return;
+ }
+
+ if ($this->checkShareLinkRequiresPassword($share_hash)) {
+ $tpl = Template::getInstance();
+ $has_password = (bool) $share_password;
+
+ $tpl->assign(compact('has_password'));
+ $tpl->display('ask_share_password.tpl');
+ return;
+ }
+ }
+
+ if (!$this->canRead($session)) {
+ header('HTTP/1.1 403 Forbidden', true, 403);
+ throw new UserException('Vous n\'avez pas accès à ce fichier.', 403);
+ return;
+ }
+ }
+
+ public function canRead(Session $session = null): bool
+ {
+ // Web pages and config files are always public
+ if ($this->isPublic()) {
+ return true;
+ }
+
+ $session ??= Session::getInstance();
+
+ return $session->checkFilePermission($this->path, 'read');
+ }
+
+ public function canShare(Session $session = null): bool
+ {
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ return $session->checkFilePermission($this->path, 'share');
+ }
+
+ public function canWrite(Session $session = null): bool
+ {
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ return $session->checkFilePermission($this->path, 'write');
+ }
+
+ public function canDelete(Session $session = null): bool
+ {
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ // Deny delete of directories in web context
+ if ($this->isDir() && $this->context() == self::CONTEXT_WEB) {
+ return false;
+ }
+
+ return $session->checkFilePermission($this->path, 'delete');
+ }
+
+ public function canMoveTo(string $destination, Session $session = null): bool
+ {
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ return $session->checkFilePermission($this->path, 'move') && $this->canDelete() && self::canCreate($destination);
+ }
+
+ public function canCopyTo(string $destination, Session $session = null): bool
+ {
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ return $this->canRead() && self::canCreate($destination);
+ }
+
+ public function canCreateDirHere(Session $session = null)
+ {
+ if (!$this->isDir()) {
+ return false;
+ }
+
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ return $session->checkFilePermission($this->path, 'mkdir');
+ }
+
+ static public function canCreateDir(string $path, Session $session = null)
+ {
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ return $session->checkFilePermission($path, 'mkdir');
+ }
+
+ public function canCreateHere(Session $session = null): bool
+ {
+ if (!$this->isDir()) {
+ return false;
+ }
+
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ return $session->checkFilePermission($this->path, 'create');
+ }
+
+ public function canRename(Session $session = null): bool
+ {
+ return $this->canCreate($this->parent ?? '', $session);
+ }
+
+ static public function canCreate(string $path, Session $session = null): bool
+ {
+ $session ??= Session::getInstance();
+
+ if (!$session->isLogged()) {
+ return false;
+ }
+
+ return $session->checkFilePermission($path, 'create');
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Files/FileThumbnailTrait.php b/src/include/lib/Paheko/Entities/Files/FileThumbnailTrait.php
new file mode 100644
index 0000000..4d55d03
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Files/FileThumbnailTrait.php
@@ -0,0 +1,503 @@
+image && !$this->hasThumbnail()) {
+ return;
+ }
+
+ if (!isset($this->md5)) {
+ return;
+ }
+
+ // clean up thumbnails
+ foreach (self::ALLOWED_THUMB_SIZES as $size => $operations) {
+ Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->md5, $size));
+
+ $uri = $this->thumb_uri($size, false);
+
+ if ($uri) {
+ Web_Cache::delete($uri);
+ }
+ }
+
+ if (!$this->image && $this->hasThumbnail()) {
+ Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->md5, 'document'));
+ }
+ }
+
+ public function asImageObject(): ?Image
+ {
+ if (!$this->image) {
+ $path = $this->createDocumentThumbnail();
+
+ if (!$path) {
+ throw new \RuntimeException('Cannot get image object as document thumbnail does not exist');
+ }
+ }
+ else {
+ $path = $this->getLocalFilePath();
+ $pointer = $path !== null ? null : $this->getReadOnlyPointer();
+ }
+
+ if ($path) {
+ $i = new Image($path);
+ }
+ elseif ($pointer) {
+ $i = Image::createFromPointer($pointer, null, true);
+ }
+ else {
+ return null;
+ }
+
+ return $i;
+ }
+
+ public function thumb_uri($size = null, bool $with_hash = true): ?string
+ {
+ // Don't try to generate thumbnails for large files (> 25 MB), except if it's a video
+ if ($this->size > 1024*1024*25 && substr($this->mime ?? '', 0, 6) !== 'video/') {
+ return null;
+ }
+
+ $ext = $this->extension();
+
+ if ($this->image) {
+ $ext = 'webp';
+ }
+ elseif ($ext === 'md' || $ext === 'txt') {
+ $ext = 'svg';
+ }
+ // We expect opendocument files to have an embedded thumbnail
+ elseif (in_array($ext, self::$_opendocument_extensions)) {
+ $ext = 'webp';
+ }
+ elseif (null !== $this->getDocumentThumbnailCommand()) {
+ $ext = 'webp';
+ }
+ else {
+ return null;
+ }
+
+ if (is_int($size)) {
+ $size .= 'px';
+ }
+
+ $size = isset(self::ALLOWED_THUMB_SIZES[$size]) ? $size : key(self::ALLOWED_THUMB_SIZES);
+ $uri = sprintf('%s.%s.%s', $this->uri(), $size, $ext);
+
+ if ($with_hash) {
+ $uri .= '?h=' . substr($this->etag(), 0, 10);
+ }
+
+ return $uri;
+ }
+
+ public function thumb_url($size = null, bool $with_hash = true): ?string
+ {
+ $uri = $this->thumb_uri($size, $with_hash);
+
+ if (!$uri) {
+ return $uri;
+ }
+
+ $base = in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_MODULES, self::CONTEXT_CONFIG]) ? WWW_URL : BASE_URL;
+ return $base . $uri;
+ }
+
+ public function hasThumbnail(): bool
+ {
+ return $this->thumb_url() !== null;
+ }
+
+ protected function getDocumentThumbnailCommand(): ?string
+ {
+ if (!DOCUMENT_THUMBNAIL_COMMANDS || !is_array(DOCUMENT_THUMBNAIL_COMMANDS)) {
+ return null;
+ }
+
+ static $libreoffice_extensions = ['doc', 'docx', 'ods', 'xls', 'xlsx', 'odp', 'odt', 'ppt', 'pptx', 'odg'];
+ static $mupdf_extensions = ['pdf', 'xps', 'cbz', 'epub', 'svg'];
+ static $collabora_extensions = ['doc', 'docx', 'ods', 'xls', 'xlsx', 'odp', 'odt', 'ppt', 'pptx', 'odg', 'pdf', 'svg'];
+
+ $ext = $this->extension();
+
+ if (in_array('mupdf', DOCUMENT_THUMBNAIL_COMMANDS) && in_array($ext, $mupdf_extensions)) {
+ return 'mupdf';
+ }
+ elseif (in_array('unoconvert', DOCUMENT_THUMBNAIL_COMMANDS) && in_array($ext, $libreoffice_extensions)) {
+ return 'unoconvert';
+ }
+ elseif (in_array('collabora', DOCUMENT_THUMBNAIL_COMMANDS)
+ && class_exists('CurlFile')
+ && in_array($ext, $collabora_extensions)
+ && $this->getWopiURL()) {
+ return 'collabora';
+ }
+ // Generate video thumbnails, for up to 1GB in size
+ elseif (in_array('ffmpeg', DOCUMENT_THUMBNAIL_COMMANDS)
+ && $this->mime
+ && substr($this->mime, 0, 6) === 'video/'
+ && $this->size < 1024*1024*1024) {
+ return 'ffmpeg';
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract PNG thumbnail from odt/ods/odp/odg ZIP archives.
+ * This is the most efficient way to get a thumbnail.
+ */
+ protected function extractOpenDocumentThumbnail(string $destination): bool
+ {
+ $zip = new ZipReader;
+
+ // We are not going to extract the archive, so it does not matter
+ $zip->enableSecurityCheck(false);
+
+ $pointer = $this->getReadOnlyPointer();
+
+ try {
+ if ($pointer) {
+ $zip->setPointer($pointer);
+ }
+ else {
+ $zip->open($this->getLocalFilePath());
+ }
+
+ $i = 0;
+ $found = false;
+
+ foreach ($zip->iterate() as $path => $entry) {
+ // There should not be more than 100 files in an opendocument archive, surely?
+ if (++$i > 100) {
+ break;
+ }
+
+ // We only care about the thumbnail
+ if ($path !== 'Thumbnails/thumbnail.png') {
+ continue;
+ }
+
+ // Thumbnail is larger than 500KB, abort, it's probably too weird
+ if ($entry['size'] > 1024*500) {
+ break;
+ }
+
+ $zip->extract($entry, $destination);
+ $found = true;
+ break;
+ }
+ }
+ catch (\RuntimeException $e) {
+ // Invalid archive
+ $found = false;
+ }
+
+ unset($zip);
+
+ if ($pointer) {
+ fclose($pointer);
+ }
+
+ return $found;
+ }
+
+ /**
+ * Create a document thumbnail using external commands or Collabora Online API
+ */
+ protected function createDocumentThumbnail(): ?string
+ {
+ $cache_id = sprintf(self::THUMB_CACHE_ID, $this->md5, 'document');
+ $destination = Static_Cache::getPath($cache_id);
+
+ if (in_array($this->extension(), self::$_opendocument_extensions) && $this->extractOpenDocumentThumbnail($destination)) {
+ return $destination;
+ }
+
+ $command = $this->getDocumentThumbnailCommand();
+
+ if (!$command) {
+ return null;
+ }
+
+ if (file_exists($destination) && filesize($destination)) {
+ return $destination;
+ }
+
+ $local_path = $this->getLocalFilePath();
+ $tmpfile = null;
+
+ if (!$local_path) {
+ $p = $this->getReadOnlyPointer();
+
+ if (!$p) {
+ throw new \LogicException('The file cannot be found in storage, unable to create thumbnail: ' . $this->path);
+ }
+
+ $tmpfile = tempnam(CACHE_ROOT, 'thumb-');
+ $fp = fopen($tmpfile, 'wb');
+
+ while (!feof($p)) {
+ fwrite($fp, fread($p, 8192));
+ }
+
+ fclose($p);
+ fclose($fp);
+ unset($p, $fp);
+ }
+
+ try {
+ if ($command === 'collabora') {
+ $url = parse_url(WOPI_DISCOVERY_URL);
+ $url = sprintf('%s://%s:%s/lool/convert-to', $url['scheme'], $url['host'], $url['port'] ?? ($url['scheme'] === 'https' ? 443 : 80));
+
+ // see https://vmiklos.hu/blog/pdf-convert-to.html
+ // but does not seem to be working right now (limited to PDF export?)
+ /*
+ $options = [
+ 'PageRange' => ['type' => 'string', 'value' => '1'],
+ 'PixelWidth' => ['type' => 'int', 'value' => 10],
+ 'PixelHeight' => ['type' => 'int', 'value' => 10],
+ ];
+ */
+
+ $curl = \curl_init($url);
+ curl_setopt($curl, CURLOPT_POST, 1);
+ curl_setopt($curl, CURLOPT_POSTFIELDS, [
+ 'format' => 'png',
+ //'options' => json_encode($options),
+ 'file' => new \CURLFile($tmpfile ?? $local_path, $this->mime, $this->name),
+ ]);
+
+ $fp = fopen($destination, 'wb');
+ curl_setopt($curl, CURLOPT_FILE, $fp);
+
+ curl_exec($curl);
+ $info = curl_getinfo($curl);
+
+ if ($error = curl_error($curl)) {
+ Utils::safe_unlink($destination);
+ throw new \RuntimeException(sprintf('cURL error on "%s": %s', $url, $error));
+ }
+
+ curl_close($curl);
+ fclose($fp);
+ unset($curl);
+
+ if (($code = $info['http_code']) != 200) {
+ Utils::safe_unlink($destination);
+ throw new \RuntimeException('Cannot fetch thumbnail from Collabora: code ' . $code . "\n" . json_encode($info));
+ }
+ }
+ else {
+ if ($command === 'mupdf') {
+ // The single '1' at the end is to tell only to render the first page
+ $cmd = sprintf('mutool draw -F png -o %s -w 500 -h 500 -r 72 %s 1 2>&1',
+ Utils::escapeshellarg($destination),
+ Utils::escapeshellarg($tmpfile ?? $local_path)
+ );
+ }
+ elseif ($command === 'unoconvert') {
+ // --filter-options PixelWidth=500 --filter-options PixelHeight=500
+ // see https://github.com/unoconv/unoserver/issues/85
+ // see https://github.com/unoconv/unoserver/issues/86
+ $cmd = sprintf('unoconvert --convert-to png %s %s 2>&1',
+ Utils::escapeshellarg($tmpfile ?? $local_path),
+ Utils::escapeshellarg($destination)
+ );
+ }
+ elseif ($command === 'ffmpeg') {
+ $cmd = sprintf('ffmpeg -ss 00:00:02 -i %s -frames:v 1 -f image2 -c png -vf scale="min(900\, iw)":-1 %s 2>&1',
+ Utils::escapeshellarg($tmpfile ?? $local_path),
+ Utils::escapeshellarg($destination)
+ );
+ }
+
+ $output = '';
+ $code = Utils::exec($cmd, 5, null, function($data) use (&$output) { $output .= $data; });
+
+ // Don't trust code as it can return != 0 even if generation was OK
+
+ if (!file_exists($destination) || filesize($destination) < 10) {
+ Utils::safe_unlink($destination);
+ throw new \RuntimeException($command . ' execution failed with code: ' . $code . "\n" . $output);
+ }
+ }
+ }
+ finally {
+ if ($tmpfile) {
+ Utils::safe_unlink($tmpfile);
+ }
+ }
+
+ return $destination;
+ }
+
+ /**
+ * Create a SVG thumbnail of a text/markdown file
+ * It's easy, we just transform it to HTML and embed the HTML in the SVG!
+ */
+ protected function createSVGThumbnail(array $operations, string $destination): void
+ {
+ $width = 150;
+
+ foreach ($operations as $operation) {
+ if ($operation[0] === 'resize') {
+ $width = (int) $operation[1];
+ break;
+ }
+ }
+
+ $text = substr($this->fetch(), 0, 1200);
+ $text = Markdown::instance()->text($text);
+
+ $out = '
+
+
+ ' . $text . '
+
+ ';
+
+ file_put_contents($destination, $out);
+ }
+
+ protected function createThumbnail(string $size, string $destination): void
+ {
+ $operations = self::ALLOWED_THUMB_SIZES[$size];
+
+ if ($this->extension() === 'md' || $this->extension() === 'txt') {
+ $this->createSVGThumbnail($operations, $destination);
+ return;
+ }
+
+ $i = $this->asImageObject();
+
+ if (!$i) {
+ return;
+ }
+
+ // Always autorotate first
+ $i->autoRotate();
+
+ $allowed_operations = ['resize', 'cropResize', 'flip', 'rotate', 'crop', 'trim'];
+
+ if (!$this->image) {
+ array_unshift($operations, ['trim']);
+ }
+
+ foreach ($operations as $operation) {
+ $arguments = array_slice($operation, 1);
+ $operation = $operation[0];
+
+ if (!in_array($operation, $allowed_operations)) {
+ throw new \InvalidArgumentException('Opération invalide: ' . $operation);
+ }
+
+ $i->$operation(...$arguments);
+ }
+
+ $format = null;
+
+ if ($i->format() !== 'gif') {
+ $format = ['webp', null];
+ }
+
+ $i->save($destination, $format);
+ }
+
+ /**
+ * Envoie une miniature à la taille indiquée au client HTTP
+ */
+ public function serveThumbnail(string $size = null): void
+ {
+ if (!$this->hasThumbnail()) {
+ throw new UserException('Il n\'est pas possible de fournir une miniature pour ce fichier.', 404);
+ }
+
+ if (!array_key_exists($size, self::ALLOWED_THUMB_SIZES)) {
+ throw new UserException('Cette taille de miniature n\'est pas autorisée.');
+ }
+
+ $cache_id = sprintf(self::THUMB_CACHE_ID, $this->md5, $size);
+ $destination = Static_Cache::getPath($cache_id);
+
+ if (!Static_Cache::exists($cache_id)) {
+ try {
+ $this->createThumbnail($size, $destination);
+ }
+ catch (\RuntimeException $e) {
+ ErrorManager::reportExceptionSilent($e);
+ header('Content-Type: image/svg+xml', true);
+ echo $this->getMissingThumbnail($size);
+ return;
+ }
+ }
+
+ $ext = $this->extension();
+
+ if ($ext === 'md' || $ext === 'txt') {
+ $type = 'image/svg+xml';
+ }
+ else {
+ // We can lie here, it might be something else, it does not matter
+ $type = 'image/webp';
+ }
+
+ header('Content-Type: ' . $type, true);
+ $this->_serve($destination, false);
+
+ if (in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_CONFIG])) {
+ $uri = $this->thumb_uri($size, false);
+ Web_Cache::link($uri, $destination);
+ }
+ }
+
+ protected function getMissingThumbnail(string $size): string
+ {
+ $w = preg_replace('/[^\d]+/', '', $size) ?: 150;
+ $h = intval($w / (2/3));
+ return sprintf('
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ', $w, $h);
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Files/FileVersionsTrait.php b/src/include/lib/Paheko/Entities/Files/FileVersionsTrait.php
new file mode 100644
index 0000000..7b34de1
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Files/FileVersionsTrait.php
@@ -0,0 +1,248 @@
+getModifiedProperty('size')) {
+ return;
+ }
+
+ if (!in_array($this->context(), self::VERSIONED_CONTEXTS)) {
+ return;
+ }
+
+ $policy = Files::getVersioningPolicy();
+
+ // Versioning is disabled
+ if ('none' === $policy) {
+ return;
+ }
+
+ $config = Config::getInstance();
+ $max_size = FILE_VERSIONING_MAX_SIZE ?? $config->file_versioning_max_size;
+
+ // Don't version large files
+ if ($this->size > $max_size*1024*1024) {
+ return;
+ }
+
+ $last = EM::getInstance(File::class)->col('SELECT name FROM @TABLE WHERE parent = ? ORDER BY name DESC LIMIT 1;',
+ self::CONTEXT_VERSIONS . '/' . $this->path);
+
+ if ($last) {
+ $v = (int) strtok($last, '.');
+ strtok('');
+ $v++;
+ }
+ else {
+ $v = 1;
+ }
+
+ if ($ts = $this->getModifiedProperty('modified')) {
+ $ts = clone $ts;
+ }
+ else {
+ $ts = $this->modified;
+ }
+
+ // file pattern: versions/ORIGINAL_PATH/000001.TIMESTAMP.NAME
+ $name = $this->createVersionName($v, $ts->getTimestamp());
+ $this->copy(sprintf('%s/%s/%s', self::CONTEXT_VERSIONS, $this->path, $name));
+ }
+
+ /**
+ * Delete versions linked to this file
+ */
+ public function deleteVersions(): bool
+ {
+ $parent = Files::get(self::CONTEXT_VERSIONS . '/' . $this->path);
+
+ if ($parent) {
+ return $parent->delete();
+ }
+
+ return true;
+ }
+
+ public function pruneVersions(): void
+ {
+ $now = time();
+
+ $policy = Files::getVersioningPolicy();
+
+ // Versioning is disabled, but keep old versions
+ if ('none' === $policy) {
+ return;
+ }
+
+ $versions_policy = Config::VERSIONING_POLICIES[$policy]['intervals'];
+ ksort($versions_policy);
+
+ // last step
+ $max_step = $versions_policy[-1] ?? null;
+ unset($versions_policy[-1]);
+
+ $versions = $this->listVersions();
+ // Sort by timestamp, not by version
+ uasort($versions, fn($a, $b) => $a->timestamp == $b->timestamp ? 0 : ($a->timestamp > $b->timestamp ? -1 : 1));
+
+ $delete = [];
+ reset($versions_policy);
+ $step = current($versions_policy);
+ $last_timestamp = null;
+
+ foreach ($versions as $v) {
+ if ($v->name) {
+ continue;
+ }
+
+ $version_diff = $now - $v->timestamp;
+
+ while ($version_diff > key($versions_policy) && $step !== false) {
+ // Skip to next interval by fetching next step
+ $step = next($versions_policy);
+ $last_timestamp = null;
+ }
+
+ // Use max step value if we have reached the max interval
+ if (false === $step) {
+ $step = $max_step;
+ }
+
+ // Keep named versions, but make them count for next step
+ if ($v->name) {
+ $keep = true;
+ }
+ elseif (!$last_timestamp) {
+ $keep = true;
+ }
+ elseif (($last_timestamp - $v->timestamp) > $step) {
+ $keep = true;
+ }
+ else {
+ // This version interval is already filled, delete
+ $delete[] = $v;
+ $keep = false;
+ }
+
+ if ($keep) {
+ // Keep this version
+ $last_timestamp = $v->timestamp;
+ }
+ }
+
+ unset($v);
+
+ foreach ($delete as $v) {
+ $v->file->delete();
+ }
+ }
+
+ public function getVersion(int $v): ?self
+ {
+ $v = EM::getInstance(File::class)->one(
+ 'SELECT * FROM @TABLE WHERE parent = ? AND name LIKE ? LIMIT 1;',
+ self::CONTEXT_VERSIONS . '/' . $this->path,
+ sprintf('%08d.%%', $v)
+ );
+
+ if (!$v) {
+ throw new \InvalidArgumentException('Version not found');
+ }
+
+ return $v;
+ }
+
+ public function getVersionMetadata(File $v): \stdClass
+ {
+ $out = (object) [
+ 'version' => (int) strtok($v->name, '.'),
+ 'timestamp' => (int) strtok('.'),
+ 'name' => strtok(''),
+ 'size' => $v->size,
+ 'file' => $v,
+ ];
+
+ $out->date = \DateTime::createFromFormat('U', $out->timestamp);
+
+ // Set to local timezone
+ $out->date->setTimeZone((new \DateTime)->getTimeZone());
+
+ return $out;
+ }
+
+ public function renameVersion(int $v, string $name): void
+ {
+ $v = $this->getVersion($v);
+
+ $meta = $this->getVersionMetadata($v);
+ $new_name = $this->createVersionName($meta->version, $meta->timestamp, trim($name));
+
+ $v->changeFileName($new_name);
+ }
+
+ public function restoreVersion(int $v): void
+ {
+ $v = $this->getVersion($v);
+
+ $this->createVersion();
+ $v->rename($this->path);
+ }
+
+ public function deleteVersion(int $v): void
+ {
+ $v = $this->getVersion($v);
+ $v->delete();
+ }
+
+ public function downloadVersion(int $v): void
+ {
+ $v = $this->getVersion($v);
+ $v->serve($this->name);
+ }
+
+ public function listVersions(): array
+ {
+ $out = [];
+ $i = 1;
+
+ foreach (Files::list(self::CONTEXT_VERSIONS . '/' . $this->path) as $v) {
+ $out[$v->name] = $this->getVersionMetadata($v);
+ }
+
+ krsort($out);
+ return $out;
+ }
+
+ public function getVersionsDirectory(): ?File
+ {
+ if ($this->context() === self::CONTEXT_VERSIONS) {
+ return null;
+ }
+
+ return Files::get(self::CONTEXT_VERSIONS . '/' . $this->path);
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Module.php b/src/include/lib/Paheko/Entities/Module.php
new file mode 100644
index 0000000..9f86abe
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Module.php
@@ -0,0 +1,763 @@
+ 'icône sur la page d\'accueil',
+ self::SNIPPET_USER => 'en bas de la fiche d\'un membre',
+ self::SNIPPET_TRANSACTION => 'en bas de la fiche d\'une écriture',
+ self::SNIPPET_MY_SERVICES => 'sur la page "Mes activités"',
+ self::SNIPPET_MY_DETAILS => 'sur la page "Mes infos personnelles"',
+ self::SNIPPET_BEFORE_NEW_TRANSACTION => 'avant le formulaire de saisie d\'écriture',
+ ];
+
+ const VALID_NAME_REGEXP = '/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/';
+
+ const TABLE = 'modules';
+
+ protected ?int $id;
+
+ /**
+ * Directory name
+ */
+ protected string $name;
+
+ protected string $label;
+ protected ?string $description;
+ protected ?string $author;
+ protected ?string $author_url;
+ protected ?string $restrict_section;
+ protected ?int $restrict_level;
+ protected bool $home_button;
+ protected bool $menu;
+ protected ?\stdClass $config;
+ protected bool $enabled;
+ protected bool $web;
+
+ /**
+ * System modules are always available, disabling them only hides the links
+ */
+ protected bool $system;
+
+ public function selfCheck(): void
+ {
+ $this->assert(preg_match(self::VALID_NAME_REGEXP, $this->name), 'Nom unique de module invalide: ' . $this->name);
+ $this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide');
+ $this->assert(!isset($this->author_url) || preg_match('!^(?:https?://|mailto:)!', $this->author_url), 'L\'adresse du site de l\'auteur est invalide');
+ $this->assert(!isset($this->restrict_section) || in_array($this->restrict_section, Session::SECTIONS, true), 'Restriction de section invalide');
+ $this->assert(!isset($this->restrict_level) || in_array($this->restrict_level, Session::ACCESS_LEVELS, true), 'Restriction de niveau invalide');
+
+ if (!$this->exists()) {
+ $this->assert(!DB::getInstance()->test(self::TABLE, 'name = ?', $this->name), 'Un module existe déjà avec ce nom unique');
+ }
+ }
+
+ public function importForm(array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ if (isset($source['restrict'])) {
+ $this->set('restrict_section', strtok($source['restrict'], '_') ?: null);
+ $this->set('restrict_level', (int)strtok('') ?: null);
+ }
+
+ parent::importForm($source);
+ }
+ /**
+ * Fills information from module.ini file
+ */
+ public function updateFromINI(bool $use_local = true): bool
+ {
+ if ($use_local && ($file = Files::get($this->path(self::META_FILE)))) {
+ $ini = $file->fetch();
+ $from_dist = false;
+ }
+ elseif (file_exists($this->distPath(self::META_FILE))) {
+ $ini = file_get_contents($this->distPath(self::META_FILE));
+ $from_dist = true;
+ }
+ else {
+ return false;
+ }
+
+ try {
+ $ini = Utils::parse_ini_string($ini, false, \INI_SCANNER_TYPED);
+ }
+ catch (\RuntimeException $e) {
+ throw new ValidationException(sprintf('Le fichier module.ini est invalide pour "%s" : %s', $this->name, $e->getMessage(), 0, $e));
+ }
+
+ if (empty($ini)) {
+ return false;
+ }
+
+ $ini = (object) $ini;
+
+ if (!isset($ini->name)) {
+ return false;
+ }
+
+ $restrict_section = null;
+ $restrict_level = null;
+
+ if (isset($ini->restrict_section, $ini->restrict_level)
+ && array_key_exists($ini->restrict_level, Session::ACCESS_LEVELS)
+ && in_array($ini->restrict_section, Session::SECTIONS)) {
+ $restrict_section = $ini->restrict_section;
+ $restrict_level = Session::ACCESS_LEVELS[$ini->restrict_level];
+ }
+
+ $this->set('label', $ini->name);
+ $this->set('description', $ini->description ?? null);
+ $this->set('author', $ini->author ?? null);
+ $this->set('author_url', $ini->author_url ?? null);
+ $this->set('web', !empty($ini->web));
+ $this->set('home_button', !empty($ini->home_button));
+ $this->set('menu', !empty($ini->menu));
+ $this->set('restrict_section', $restrict_section);
+ $this->set('restrict_level', $restrict_level);
+
+ if ($from_dist && !empty($ini->system)) {
+ $this->set('system', true);
+ }
+
+ return true;
+ }
+
+ public function exportToIni(): void
+ {
+ $ini = '';
+
+ foreach ($this->asArray() as $key => $value) {
+ if ($key == 'name' || $key == 'id') {
+ continue;
+ }
+
+ if ($key == 'label') {
+ $key = 'name';
+ }
+
+ if ($key == 'restrict_level') {
+ $value = array_search($value, Session::ACCESS_LEVELS);
+ }
+
+ if (is_bool($value)) {
+ $value = $value ? 'true' : 'false';
+ }
+ elseif (null === $value || trim($value) === '') {
+ $value = 'null';
+ }
+ elseif (is_string($value)) {
+ $value = strtr($value, ['"' => '\\"', "'" => "\\'", '$' => '\\$']);
+ $value = '"' . $value . '"';
+ }
+
+ $ini .= sprintf("%s = %s\n", $key, $value);
+ }
+
+ Files::createFromString($this->path('module.ini'), $ini);
+ }
+
+ public function updateTemplates(): void
+ {
+ $check = self::SNIPPETS + [self::CONFIG_FILE => 'Config'];
+ $templates = [];
+ $db = DB::getInstance();
+
+ $db->begin();
+ $db->delete('modules_templates', 'id_module = ' . (int)$this->id());
+
+ foreach ($check as $file => $label) {
+ if (Files::exists($this->path($file)) || file_exists($this->distPath($file))) {
+ $templates[] = $file;
+ $db->insert('modules_templates', ['id_module' => $this->id(), 'name' => $file]);
+ }
+ }
+
+ foreach ($this->listFiles('snippets/markdown') as $file) {
+ $db->insert('modules_templates', ['id_module' => $this->id(), 'name' => $file->path]);
+ }
+
+ $db->commit();
+ }
+
+ public function icon_url(): ?string
+ {
+ if (!$this->hasFile(self::ICON_FILE)) {
+ return null;
+ }
+
+ return $this->url(self::ICON_FILE);
+ }
+
+ public function config_url(): ?string
+ {
+ if (!$this->hasFile(self::CONFIG_FILE)) {
+ return null;
+ }
+
+ return $this->url(self::CONFIG_FILE);
+ }
+
+ public function storage_root(): string
+ {
+ return File::CONTEXT_EXTENSIONS . '/m/' . $this->name;
+ }
+
+ public function path(string $file = null): string
+ {
+ return self::ROOT . '/' . $this->name . ($file ? '/' . $file : '');
+ }
+
+ public function distPath(string $file = null): string
+ {
+ return self::DIST_ROOT . '/' . $this->name . ($file ? '/' . $file : '');
+ }
+
+ public function dir(): ?File
+ {
+ return Files::get($this->path());
+ }
+
+ public function storage(): ?File
+ {
+ return Files::get($this->storage_root());
+ }
+
+ public function hasFile(string $file): bool
+ {
+ return $this->hasLocalFile($file) || $this->hasDistFile($file);
+ }
+
+ public function hasDist(): bool
+ {
+ return file_exists($this->distPath());
+ }
+
+ public function hasLocal(): bool
+ {
+ return Files::exists($this->path());
+ }
+
+ public function hasLocalFile(string $path): bool
+ {
+ return Files::exists($this->path($path));
+ }
+
+ public function hasDistFile(string $path): bool
+ {
+ return @file_exists($this->distPath($path));
+ }
+
+ public function fetchFile(string $path): ?string
+ {
+ if ($this->hasLocalFile($path)) {
+ return $this->fetchLocalFile($path);
+ }
+
+ return $this->fetchDistFile($path);
+ }
+
+ public function fetchLocalFile(string $path): ?string
+ {
+ $file = Files::get($this->path($path));
+ return !$file ? null : $file->fetch();
+ }
+
+ public function fetchDistFile(string $path): ?string
+ {
+ return @file_get_contents($this->distPath($path)) ?: null;
+ }
+
+ public function hasConfig(): bool
+ {
+ return DB::getInstance()->test('modules_templates', 'id_module = ? AND name = ?', $this->id(), self::CONFIG_FILE);
+ }
+
+ public function hasData(): bool
+ {
+ return DB::getInstance()->test('sqlite_master', 'type = \'table\' AND name = ?', sprintf('module_data_%s', $this->name));
+ }
+
+ public function getDataSize(): int
+ {
+ return DB::getInstance()->getTableSize(sprintf('module_data_%s', $this->name));
+ }
+
+ public function getCodeSize(): int
+ {
+ $dir = $this->dir();
+
+ if ($dir) {
+ return $dir->getRecursiveSize();
+ }
+
+ return 0;
+ }
+
+ public function getFilesSize(): int
+ {
+ $dir = $this->storage();
+
+ if ($dir) {
+ return $dir->getRecursiveSize();
+ }
+
+ return 0;
+ }
+
+ public function canDelete(): bool
+ {
+ return !$this->enabled && $this->hasLocal() && !$this->hasDist();
+ }
+
+ public function canReset(): bool
+ {
+ return $this->hasLocal() && $this->hasDist();
+ }
+
+ public function canDeleteData(): bool
+ {
+ return !empty($this->config) || $this->hasData();
+ }
+
+ public function listFiles(?string $path = null): array
+ {
+ $out = [];
+ $base = File::CONTEXT_MODULES . '/' . $this->name;
+
+ if ($path && false !== strpos($path, '..')) {
+ return [];
+ }
+
+ $path ??= '';
+ $local_path = trim($base . '/' . $path, '/');
+
+ foreach (Files::list($local_path) as $file) {
+ $_path = substr($file->path, strlen($base . '/'));
+
+ $out[$file->name] = (object) [
+ 'name' => $file->name,
+ 'dir' => $file->isDir(),
+ 'path' => $_path,
+ 'file_path' => $file->path,
+ 'type' => $file->mime,
+ 'local' => true,
+ 'dist' => false,
+ 'file' => $file,
+ ];
+ }
+
+ $dist_path = $this->distPath($path);
+
+ if (is_dir($dist_path)) {
+ foreach (scandir($dist_path) as $file) {
+ if (substr($file, 0, 1) == '.') {
+ continue;
+ }
+
+ if (isset($out[$file])) {
+ $out[$file]->dist = true;
+ continue;
+ }
+
+ $out[$file] = (object) [
+ 'name' => $file,
+ 'type' => mime_content_type($dist_path . '/' . $file),
+ 'dir' => is_dir($dist_path . '/' . $file),
+ 'path' => trim($path . '/' . $file, '/'),
+ 'local' => false,
+ 'dist' => true,
+ 'file_path' => $base . '/' . trim($path . '/' . $file, '/'),
+ 'file' => null,
+ 'dist_path' => $dist_path . '/' . $file,
+ ];
+ }
+ }
+
+ foreach ($out as &$file) {
+ $file->editable = !$file->dir && (UserTemplate::isTemplate($file->path)
+ || substr($file->type, 0, 5) === 'text/'
+ || preg_match('/\.(?:json|md|skriv|html|css|js|ini)$/', $file->name));
+ $file->open_url = '!common/files/preview.php?p=' . rawurlencode($file->file_path);
+ $file->edit_url = '!common/files/edit.php?fallback=code&p=' . rawurlencode($file->file_path);
+ $file->delete_url = '!common/files/delete.php?p=' . rawurlencode($file->file_path);
+ }
+
+ unset($file);
+
+ uasort($out, function ($a, $b) {
+ if ($a->dir == $b->dir) {
+ return strnatcasecmp($a->name, $b->name);
+ }
+ elseif ($a->dir && !$b->dir) {
+ return -1;
+ }
+ else {
+ return 1;
+ }
+ });
+
+
+ return $out;
+ }
+
+ public function delete(): bool
+ {
+ $this->resetChanges();
+ $this->deleteData();
+
+ return parent::delete();
+ }
+
+ public function resetChanges(): void
+ {
+ $dir = $this->dir();
+
+ if ($dir) {
+ $dir->delete();
+ }
+ }
+
+ public function deleteData(): void
+ {
+ DB::getInstance()->exec(sprintf('DROP TABLE IF EXISTS module_data_%s; UPDATE modules SET config = NULL WHERE name = \'%1$s\';', $this->name));
+
+ // Delete all files
+ if ($dir = Files::get($this->storage_root())) {
+ $dir->delete();
+ }
+ }
+
+ public function url(string $file = '', array $params = null)
+ {
+ if (null !== $params) {
+ $params = '?' . http_build_query($params);
+ }
+
+ if ($this->web && $this->enabled && !$file) {
+ return BASE_URL;
+ }
+
+ return sprintf('%sm/%s/%s%s', BASE_URL, $this->name, $file, $params);
+ }
+
+ public function public_url(string $file = '', array $params = null)
+ {
+ return str_replace(BASE_URL, WWW_URL, $this->url($file, $params));
+ }
+
+ public function isValidPath(string $path): bool
+ {
+ return (bool) preg_match('!^(?:[\w\d_-]+/)*[\w\d_-]+(?:\.[\w\d_-]+)*$!i', $path);
+ }
+
+ public function validatePath(string $path): void
+ {
+ if (!$this->isValidPath($path)) {
+ throw new \InvalidArgumentException('Invalid skeleton name');
+ }
+ }
+
+ public function template(string $file)
+ {
+ if ($file == self::CONFIG_FILE) {
+ Session::getInstance()->requireAccess(Session::SECTION_CONFIG, Session::ACCESS_ADMIN);
+ }
+
+ $this->validatePath($file);
+
+ $ut = new UserTemplate($this->name . '/' . $file);
+ $ut->setModule($this);
+
+ return $ut;
+ }
+
+ public function fetch(string $file, array $params): string
+ {
+ $ut = $this->template($file);
+ $ut->assignArray($params);
+ return $ut->fetch();
+ }
+
+ public function serve(string $path, bool $has_local_file, array $params = []): void
+ {
+ if (substr(Utils::basename($path), 0, 1) === '.') {
+ throw new UserException('Unknown path', 404);
+ }
+
+ if (UserTemplate::isTemplate($path)) {
+ // Error if path is not valid
+ // we allow any path for static files, but not for skeletons
+ if (!$this->isValidPath($path)) {
+ if ($this->web) {
+ $path = '404.html';
+ }
+ else {
+ http_response_code(404);
+ throw new UserException('This address is invalid.');
+ }
+ }
+
+ if ($this->web) {
+ $this->serveWeb($path, $params);
+ return;
+ }
+ else {
+ $ut = $this->template($path);
+ $ut->serve($params);
+ }
+
+ return;
+ }
+ // Serve a static file from a user module
+ elseif ($has_local_file) {
+ $file = Files::get(File::CONTEXT_MODULES . '/' . $this->name . '/' . $path);
+
+ if (!$file) {
+ throw new UserException('Invalid path');
+ }
+
+ $file->validateCanRead();
+ $file->serve();
+ }
+ // Serve a static file from dist path
+ else {
+ $type = $this->getFileTypeFromExtension($path);
+ $real_path = $this->distPath($path);
+
+ if (!is_file($real_path)) {
+ throw new UserException('Invalid path', 404);
+ }
+
+ if ($this->web) {
+ // Create symlink to static file
+ Cache::link($path, $real_path);
+ }
+
+ http_response_code(200);
+ header(sprintf('Content-Type: %s;charset=utf-8', $type), true);
+ readfile($real_path);
+ flush();
+ }
+ }
+
+ public function serveWeb(string $path, array $params): void
+ {
+ $uri = $params['uri'] ?? null;
+
+ // Fire signal before display of a web page
+ $plugin_params = ['path' => $path, 'uri' => $uri, 'module' => $this];
+ $module = $this;
+
+ $signal = Plugins::fire('web.request.before', true, compact('path', 'uri', 'module'));
+
+ if ($signal && $signal->isStopped()) {
+ return;
+ }
+
+ unset($signal);
+
+ $type = null;
+
+ $ut = $this->template($path);
+ $ut->assignArray($params);
+ $content = $ut->fetch();
+ $type = $ut->getContentType();
+ $code = $ut->getStatusCode();
+
+ if ($uri !== null && preg_match('!html|xml|text!', $type) && !$ut->get('nocache') && $code == 200) {
+ $cache = true;
+ }
+ else {
+ $cache = false;
+ }
+
+ // Call plugins, allowing them to modify the content
+ $signal = Plugins::fire(
+ 'web.request',
+ true,
+ compact('path', 'uri', 'module', 'content', 'type', 'cache', 'code'),
+ compact('type', 'cache', 'content', 'code')
+ );
+
+ if ($signal && $signal->isStopped()) {
+ return;
+ }
+
+ if ($signal) {
+ $ut->setHeader('type', $signal->getOut('type'));
+ $ut->setHeader('code', $signal->getOut('code'));
+ $cache = $signal->getOut('cache');
+ $content = $signal->getOut('content');
+ }
+
+ unset($signal);
+
+ $ut->dumpHeaders();
+
+ if ($type == 'application/pdf') {
+ Utils::streamPDF($content);
+ }
+ else {
+ echo $content;
+ }
+
+ if ($cache) {
+ Cache::store($uri, $content);
+ }
+
+ Plugins::fire('web.request.after', false, compact('path', 'uri', 'module', 'content', 'type', 'cache'));
+ }
+
+ public function getFileTypeFromExtension(string $path): ?string
+ {
+ $dot = strrpos($path, '.');
+
+ // Templates with no extension are returned as HTML by default
+ // unless {{:http type=...}} is used
+ if ($dot === false) {
+ return 'text/html';
+ }
+
+ // Templates with no extension are returned as HTML by default
+ // unless {{:http type=...}} is used
+ if ($dot === false) {
+ return 'text/html';
+ }
+
+ $ext = substr($path, $dot+1);
+
+ // Common types
+ switch ($ext) {
+ case 'txt':
+ return 'text/plain';
+ case 'html':
+ case 'htm':
+ case 'tpl':
+ case 'btpl':
+ case 'skel':
+ return 'text/html';
+ case 'xml':
+ return 'text/xml';
+ case 'css':
+ return 'text/css';
+ case 'js':
+ return 'text/javascript';
+ case 'png':
+ case 'gif':
+ case 'webp':
+ return 'image/' . $ext;
+ case 'svg':
+ return 'image/svg+xml';
+ case 'jpeg':
+ case 'jpg':
+ return 'image/jpeg';
+ default:
+ return null;
+ }
+ }
+
+ public function export(Session $session): void
+ {
+ $download_name = 'module_' . $this->name;
+
+ header('Content-type: application/zip');
+ header(sprintf('Content-Disposition: attachment; filename="%s"', $download_name. '.zip'));
+
+ $target = 'php://output';
+ $zip = new ZipWriter($target);
+ $zip->setCompression(9);
+
+ $add = function ($path) use ($zip, &$add) {
+ foreach ($this->listFiles($path) as $file) {
+ if ($file->dir) {
+ $add($file->path);
+ }
+ elseif ($file->local) {
+ if ($pointer = $file->file->getReadOnlyPointer()) {
+ $zip->addFromPointer($file->file_path, $pointer);
+ }
+ elseif ($path = $file->file->getLocalFilePath()) {
+ $zip->addFromPath($file->file_path, $path);
+ }
+ }
+ else {
+ $zip->addFromPath($file->file_path, $file->dist_path);
+ }
+ }
+ };
+
+ $add(null);
+
+ $zip->close();
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ $enabled_web = isset($this->_modified['enabled']) && $this->enabled && $this->web;
+ $r = parent::save($selfcheck);
+
+ if ($r && $enabled_web) {
+ DB::getInstance()->preparedQuery('UPDATE modules SET enabled = 0 WHERE web = 1 AND enabled = 1 AND name != ?;', $this->name);
+ }
+
+ return $r;
+ }
+
+ public function listSnippets(): array
+ {
+ $out = [];
+
+ foreach (DB::getInstance()->iterate('SELECT name FROM modules_templates WHERE id_module = ? AND name LIKE \'snippets/%\';', $this->id()) as $row) {
+ $label = self::SNIPPETS[$row->name] ?? null;
+
+ if (!$label && ($match = sscanf($row->name, 'snippets/markdown/%[^.].html'))) {
+ $label = sprintf('extension MarkDown <<%s>>, utilisable dans les pages du site web', current($match));
+ }
+
+ $out[$row->name] = $label;
+ }
+
+ return $out;
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Plugin.php b/src/include/lib/Paheko/Entities/Plugin.php
new file mode 100644
index 0000000..19ed353
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Plugin.php
@@ -0,0 +1,434 @@
+name);
+ }
+
+ public function selfCheck(): void
+ {
+ $this->assert(preg_match('/^' . Plugins::NAME_REGEXP . '$/', $this->name), 'Nom unique d\'extension invalide: ' . $this->name);
+ $this->assert(isset($this->label) && trim($this->label) !== '', sprintf('%s : le nom de l\'extension ("name") ne peut rester vide', $this->name));
+ $this->assert(isset($this->label) && trim($this->version) !== '', sprintf('%s : la version ne peut rester vide', $this->name));
+
+ if ($this->hasCode() || $this->enabled) {
+ $this->assert($this->hasFile(self::META_FILE), 'Le fichier plugin.ini est absent');
+ $this->assert(!$this->menu || $this->hasFile(self::INDEX_FILE), 'Le fichier admin/index.php n\'existe pas alors que la directive "menu" est activée.');
+ $this->assert(!$this->home_button || $this->hasFile(self::INDEX_FILE), 'Le fichier admin/index.php n\'existe pas alors que la directive "home_button" est activée.');
+ $this->assert(!$this->home_button || $this->hasFile(self::ICON_FILE), 'Le fichier admin/icon.svg n\'existe pas alors que la directive "home_button" est activée.');
+ }
+
+ $this->assert(!isset($this->restrict_section) || in_array($this->restrict_section, Session::SECTIONS, true), 'Restriction de section invalide');
+ $this->assert(!isset($this->restrict_level) || in_array($this->restrict_level, Session::ACCESS_LEVELS, true), 'Restriction de niveau invalide');
+ }
+
+ public function setBrokenMessage(string $str)
+ {
+ $this->_broken_message = $str;
+ }
+
+ public function getBrokenMessage(): ?string
+ {
+ return $this->_broken_message;
+ }
+
+ /**
+ * Fills information from plugin.ini file
+ */
+ public function updateFromINI(): bool
+ {
+ if (!$this->hasFile(self::META_FILE)) {
+ return false;
+ }
+
+ try {
+ $ini = Utils::parse_ini_file($this->path(self::META_FILE), false);
+ }
+ catch (\RuntimeException $e) {
+ throw new ValidationException(sprintf('Le fichier plugin.ini est invalide pour "%s" : %s', $this->name, $e->getMessage(), 0, $e));
+ }
+
+ if (empty($ini)) {
+ return false;
+ }
+
+ $ini = (object) $ini;
+
+ if (!isset($ini->name)) {
+ return false;
+ }
+
+ $this->assert(empty($ini->min_version) || version_compare(\Paheko\paheko_version(), $ini->min_version, '>='), sprintf('L\'extension "%s" nécessite Paheko version %s ou supérieure.', $this->name, $ini->min_version));
+
+ $restrict_section = null;
+ $restrict_level = null;
+
+ if (isset($ini->restrict_section, $ini->restrict_level)
+ && array_key_exists($ini->restrict_level, Session::ACCESS_LEVELS)
+ && in_array($ini->restrict_section, Session::SECTIONS)) {
+ $restrict_section = $ini->restrict_section;
+ $restrict_level = Session::ACCESS_LEVELS[$ini->restrict_level];
+ }
+
+ $this->set('label', $ini->name);
+ $this->set('version', $ini->version);
+ $this->set('description', $ini->description ?? null);
+ $this->set('author', $ini->author ?? null);
+ $this->set('author_url', $ini->author_url ?? null);
+ $this->set('home_button', !empty($ini->home_button));
+ $this->set('menu', !empty($ini->menu));
+ $this->set('restrict_section', $restrict_section);
+ $this->set('restrict_level', $restrict_level);
+
+ return true;
+ }
+
+ public function icon_url(): ?string
+ {
+ if (!$this->hasFile(self::ICON_FILE)) {
+ return null;
+ }
+
+ return $this->url(self::ICON_FILE);
+ }
+
+ public function path(string $file = null): ?string
+ {
+ $path = Plugins::getPath($this->name);
+
+ if (!$path) {
+ return null;
+ }
+
+ return $path . ($file ? '/' . $file : '');
+ }
+
+ public function hasFile(string $file): bool
+ {
+ $path = $this->path($file);
+
+ if (!$path) {
+ return false;
+ }
+
+ return file_exists($path);
+ }
+
+ public function hasConfig(): bool
+ {
+ return $this->hasFile(self::CONFIG_FILE);
+ }
+
+ public function fetchFile(string $path): ?string
+ {
+ if (!$this->hasFile($path)) {
+ return null;
+ }
+
+ return file_get_contents($this->path($path));
+ }
+
+ public function url(string $file = '', array $params = null)
+ {
+ if (null !== $params) {
+ $params = '?' . http_build_query($params);
+ }
+
+ if (substr($file, 0, 6) == 'admin/') {
+ $url = ADMIN_URL;
+ $file = substr($file, 6);
+ }
+ else {
+ $url = WWW_URL;
+ }
+
+ return sprintf('%sp/%s/%s%s', $url, $this->name, $file, $params);
+ }
+
+ public function storage_root(): string
+ {
+ return File::CONTEXT_EXTENSIONS . '/p/' . $this->name;
+ }
+
+ public function getConfig(string $key = null)
+ {
+ if (is_null($key)) {
+ return $this->config;
+ }
+
+ if ($this->config && property_exists($this->config, $key)) {
+ return $this->config->$key;
+ }
+
+ return null;
+ }
+
+ public function setConfigProperty(string $key, $value = null)
+ {
+ if (null === $this->config) {
+ $this->config = new \stdClass;
+ }
+
+ if (is_null($value)) {
+ unset($this->config->$key);
+ }
+ else {
+ $this->config->$key = $value;
+ }
+
+ $this->_modified['config'] = true;
+ }
+
+ public function setConfig(\stdClass $config)
+ {
+ $this->config = $config;
+ $this->_modified['config'] = true;
+ }
+
+ /**
+ * Associer un signal à un callback du plugin
+ * @param string $signal Nom du signal (par exemple boucle.agenda pour la boucle de type AGENDA)
+ * @param mixed $callback Callback, sous forme d'un nom de fonction ou de méthode statique
+ * @return boolean TRUE
+ */
+ public function registerSignal(string $signal, callable $callback): void
+ {
+ $callable_name = '';
+
+ if (!is_callable($callback, true, $callable_name) || !is_string($callable_name))
+ {
+ throw new \LogicException('Le callback donné n\'est pas valide.');
+ }
+
+ // pour empêcher d'appeler des méthodes de Paheko après un import de base de données "hackée"
+ if (strpos($callable_name, 'Paheko\\Plugin\\') !== 0)
+ {
+ throw new \LogicException('Le callback donné n\'utilise pas le namespace Paheko\\Plugin : ' . $callable_name);
+ }
+
+ $db = DB::getInstance();
+
+ $callable_name = str_replace('Paheko\\Plugin\\', '', $callable_name);
+
+ $db->preparedQuery('INSERT OR REPLACE INTO plugins_signals VALUES (?, ?, ?);', [$signal, $this->name, $callable_name]);
+ }
+
+ public function unregisterSignal(string $signal): void
+ {
+ DB::getInstance()->preparedQuery('DELETE FROM plugins_signals WHERE plugin = ? AND signal = ?;', [$this->name, $signal]);
+ }
+
+ public function delete(): bool
+ {
+ if ($this->hasFile(self::UNINSTALL_FILE)) {
+ $this->call(self::UNINSTALL_FILE, true);
+ }
+
+ // Delete all files
+ if ($dir = Files::get($this->storage_root())) {
+ $dir->delete();
+ }
+
+ $db = DB::getInstance();
+ $db->delete('plugins_signals', 'plugin = ?', $this->name);
+ return parent::delete();
+ }
+
+ /**
+ * Renvoie TRUE si le plugin a besoin d'être mis à jour
+ * (si la version notée dans la DB est différente de la version notée dans paheko_plugin.ini)
+ * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon
+ */
+ public function needUpgrade(): bool
+ {
+ try {
+ $infos = (object) Utils::parse_ini_file($this->path(self::META_FILE), false);
+ }
+ catch (\RuntimeException $e) {
+ return false;
+ }
+
+ if (version_compare($this->version, $infos->version, '!=')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Mettre à jour le plugin
+ * Appelle le fichier upgrade.php dans l'archive si celui-ci existe.
+ */
+ public function upgrade(): void
+ {
+ $this->updateFromINI();
+
+ if ($this->hasFile(self::UPGRADE_FILE)) {
+ $this->call(self::UPGRADE_FILE, true);
+ }
+
+ $this->save();
+ }
+
+ public function upgradeIfRequired(): void
+ {
+ if ($this->needUpgrade()) {
+ $this->upgrade();
+ }
+ }
+
+ public function oldVersion(): ?string
+ {
+ return $this->getModifiedProperty('version');
+ }
+
+ public function call(string $file, bool $allow_protected = false): void
+ {
+ $file = ltrim($file, './');
+
+ if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file)) {
+ throw new UserException('Chemin de fichier incorrect.');
+ }
+
+ if (!$allow_protected && in_array($file, self::PROTECTED_FILES)) {
+ throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.');
+ }
+
+ $path = $this->path($file);
+
+ if (!file_exists($path)) {
+ throw new UserException(sprintf('Le fichier "%s" n\'existe pas dans le plugin "%s"', $file, $this->name));
+ }
+
+ if (is_dir($path)) {
+ throw new UserException(sprintf('Sécurité : impossible de lister le répertoire "%s" du plugin "%s".', $file, $this->name));
+ }
+
+ $is_private = (0 === strpos($file, 'admin/'));
+
+ // Créer l'environnement d'exécution du plugin
+ if (substr($file, -4) === '.php') {
+ if (substr($file, 0, 6) == 'admin/' || substr($file, 0, 7) == 'public/') {
+ define('Paheko\PLUGIN_ROOT', $this->path());
+ define('Paheko\PLUGIN_URL', WWW_URL . 'p/' . $this->name . '/');
+ define('Paheko\PLUGIN_ADMIN_URL', ADMIN_URL .'p/' . $this->name . '/');
+ define('Paheko\PLUGIN_QSP', '?');
+
+ $tpl = Template::getInstance();
+
+ if ($is_private) {
+ require ROOT . '/www/admin/_inc.php';
+ $tpl->assign('current', 'plugin_' . $this->name);
+ }
+
+ $tpl->assign('plugin', $this);
+ $tpl->assign('plugin_url', \Paheko\PLUGIN_URL);
+ $tpl->assign('plugin_admin_url', \Paheko\PLUGIN_ADMIN_URL);
+ $tpl->assign('plugin_root', \Paheko\PLUGIN_ROOT);
+ }
+
+ $plugin = $this;
+
+ include $path;
+ }
+ else {
+ Plugins::routeStatic($this->name, $file);
+ }
+ }
+
+ public function route(string $uri): void
+ {
+ $uri = ltrim($uri, '/');
+ $session = Session::getInstance();
+
+ if (0 === strpos($uri, 'admin/')) {
+ if (!$session->isLogged()) {
+ Utils::redirect('!login.php');
+ }
+
+ // Restrict access
+ if (isset($this->restrict_section, $this->restrict_level)) {
+ $session->requireAccess($this->restrict_section, $this->restrict_level);
+ }
+ }
+
+ if (!$uri || substr($uri, -1) == '/') {
+ $uri .= 'index.php';
+ }
+
+ try {
+ $this->call($uri);
+ }
+ catch (UserException $e) {
+ http_response_code(404);
+ throw new UserException($e->getMessage());
+ }
+ }
+
+ public function isAvailable(): bool
+ {
+ return $this->hasFile(self::META_FILE);
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Entities/Search.php b/src/include/lib/Paheko/Entities/Search.php
new file mode 100644
index 0000000..8663935
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Search.php
@@ -0,0 +1,336 @@
+ 'Recherche avancée',
+ self::TYPE_SQL => 'Recherche SQL',
+ self::TYPE_SQL_UNPROTECTED => 'Recherche SQL non protégée',
+ ];
+
+ const TARGET_USERS = 'users';
+ const TARGET_ACCOUNTING = 'accounting';
+ const TARGET_ALL = 'all';
+
+ const TARGETS = [
+ self::TARGET_USERS => 'Membres',
+ self::TARGET_ACCOUNTING => 'Comptabilité',
+ ];
+
+ /**
+ * Match the last LIMIT clause from the SQL query
+ * Will match:
+ * SELECT * FROM table LIMIT 5 -> LIMIT 5
+ * SELECT ... (... LIMIT 5) LIMIT 10 -> LIMIT 10
+ * SELECT * FROM (SELECT * FROM bla LIMIT 10) -> no match (as the limit is in a subquery)
+ */
+ const LIMIT_REGEXP = '/LIMIT\s+\d+(?:\s*,\s*-?\d+|\s+OFFSET\s+-?\d+)?(?!.*LIMIT\s+-?\d+|.*\))/is';
+
+ protected ?int $id;
+ protected ?int $id_user = null;
+ protected string $label;
+ protected \DateTime $created;
+ protected string $target;
+ protected string $type;
+ protected string $content;
+
+ protected $_result = null;
+ protected $_as = null;
+
+ public function selfCheck(): void
+ {
+ parent::selfCheck();
+
+ $this->assert(strlen('label') > 0, 'Le champ libellé doit être renseigné');
+ $this->assert(strlen('label') <= 500, 'Le champ libellé est trop long');
+
+ $db = DB::getInstance();
+
+ if ($this->id_user !== null) {
+ $this->assert($db->test('users', 'id = ?', $this->id_user), 'Numéro de membre inconnu');
+ }
+
+ $this->assert(array_key_exists($this->type, self::TYPES));
+ $this->assert(array_key_exists($this->target, self::TARGETS));
+
+ $this->assert(strlen($this->content), 'Le contenu de la recherche ne peut être vide');
+
+ if ($this->type === self::TYPE_JSON) {
+ $this->assert(json_decode($this->content) !== null, 'Recherche invalide pour le type JSON');
+ }
+ }
+
+ public function getDynamicList(): DynamicList
+ {
+ if ($this->type == self::TYPE_JSON) {
+ return $this->getAdvancedSearch()->make($this->content);
+ }
+ else {
+ throw new \LogicException('SQL search cannot be used as dynamic list');
+ }
+ }
+
+ public function getAdvancedSearch(): AdvancedSearch
+ {
+ if ($this->target == self::TARGET_ACCOUNTING) {
+ $class = 'Paheko\Accounting\AdvancedSearch';
+ }
+ else {
+ $class = 'Paheko\Users\AdvancedSearch';
+ }
+
+ if (null === $this->_as || !is_a($this->_as, $class)) {
+ $this->_as = new $class;
+ }
+
+ return $this->_as;
+ }
+
+ public function transformToSQL()
+ {
+ if ($this->type != self::TYPE_JSON) {
+ throw new \LogicException('Cannot transform a non-JSON search to SQL');
+ }
+
+ $sql = $this->getDynamicList()->SQL();
+
+ // Remove indentation
+ $sql = preg_replace('/^\s*/m', '', $sql);
+
+ $this->set('content', $sql);
+ $this->set('type', self::TYPE_SQL);
+ }
+
+ public function SQL(array $options = []): string
+ {
+ if ($this->type == self::TYPE_JSON) {
+ $sql = $this->getDynamicList()->SQL();
+ }
+ else {
+ $sql = $this->content;
+ }
+
+ $has_limit = stripos($sql, 'LIMIT') !== false;
+
+ // force LIMIT
+ if (!empty($options['limit'])) {
+ $regexp = $has_limit ? self::LIMIT_REGEXP : '/;[^;]*$|(=;)$/s';
+ $limit = ' LIMIT ' . (int) $options['limit'];
+ $sql = preg_replace($regexp, $limit, trim($sql));
+ }
+ elseif (!empty($options['no_limit']) && $has_limit) {
+ $sql = preg_replace(self::LIMIT_REGEXP, '', $sql);
+ }
+
+ if (!empty($options['select_also'])) {
+ $sql = preg_replace('/^\s*SELECT\s+(.*?)\s+FROM\s+/Uis', 'SELECT $1, ' . implode(', ', (array)$options['select_also']) . ' FROM ', $sql);
+ }
+ elseif (!empty($options['select'])) {
+ $sql = preg_replace('/^\s*SELECT\s+(.*?)\s+FROM\s+/Uis', 'SELECT ' . implode(', ', (array)$options['select']) . ' FROM ', $sql);
+ }
+
+ $sql = trim($sql, "\n\r\t; ");
+
+ return $sql;
+ }
+
+ /**
+ * Returns a SQLite3Result for the current search
+ */
+ public function query(array $options = []): \SQLite3Result
+ {
+ if (null !== $this->_result && empty($options['no_cache'])) {
+ return $this->_result;
+ }
+
+ $sql = $this->SQL($options);
+
+ $allowed_tables = $this->getProtectedTables();
+ $db = DB::getInstance();
+
+ try {
+ $db->toggleUnicodeLike(true);
+
+ // Lock database against changes
+ $db->setReadOnly(true);
+
+ $st = $db->protectSelect($allowed_tables, $sql);
+ $result = $db->execute($st);
+
+ $db->setReadOnly(false);
+
+ if (empty($options['no_cache'])) {
+ $this->_result = $result;
+ }
+
+ return $result;
+ }
+ catch (DB_Exception $e) {
+ throw new UserException('Erreur dans la requête : ' . $e->getMessage(), 0, $e);
+ }
+ finally {
+ $db->toggleUnicodeLike(false);
+ }
+ }
+
+ public function getHeader(array $options = []): array
+ {
+ $r = $this->query($options);
+ $columns = [];
+
+ for ($i = 0; $i < $r->numColumns(); $i++) {
+ $columns[] = $r->columnName($i);
+ }
+
+ return $columns;
+ }
+
+ public function iterateResults(): iterable
+ {
+ $r = $this->query();
+
+ while ($row = $r->fetchArray(\SQLITE3_NUM)) {
+ yield $row;
+ }
+ }
+
+ public function hasUserId(): bool
+ {
+ try {
+ $sql = $this->SQL();
+
+ if (!preg_match('/(?:FROM|JOIN)\s+users/i', $sql)) {
+ return false;
+ }
+
+ $header = $this->getHeader(['limit' => 1, 'no_cache' => true]);
+ }
+ catch (UserException $e) {
+ return false;
+ }
+
+ if (!in_array('id', $header) && !in_array('_user_id', $header)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function hasLimit(): bool
+ {
+ if ($this->type === self::TYPE_JSON) {
+ return false;
+ }
+
+ return (bool) preg_match(self::LIMIT_REGEXP, $this->SQL());
+ }
+
+ public function countResults(bool $ignore_errors = true): ?int
+ {
+ $sql = $this->SQL(['no_limit' => true, 'no_cache' => true]);
+ $sql = rtrim($sql);
+ $sql = rtrim($sql, ';');
+ $sql = 'SELECT COUNT(*) FROM (' . $sql . ')';
+
+ $allowed_tables = $this->getProtectedTables();
+ $db = DB::getInstance();
+
+ try {
+ $db->toggleUnicodeLike(true);
+
+ // Lock database against changes
+ $db->setReadOnly(true);
+ $st = $db->protectSelect($allowed_tables, $sql);
+ $r = $db->execute($st);
+ $db->setReadOnly(false);
+
+ $count = (int) $r->fetchArray(\SQLITE3_NUM)[0] ?? 0;
+ $r->finalize();
+ $st->close();
+ return $count;
+ }
+ catch (DB_Exception $e) {
+ if ($ignore_errors) {
+ return null;
+ }
+
+ throw new UserException('Erreur dans la requête : ' . $e->getMessage(), 0, $e);
+ }
+ finally {
+ $db->toggleUnicodeLike(false);
+ }
+ }
+
+ public function export(string $format, string $title = 'Recherche')
+ {
+ CSV::export($format, $title, $this->iterateResults(), $this->getHeader());
+ }
+
+ public function schema(): array
+ {
+ $out = [];
+ $db = DB::getInstance();
+
+ foreach ($this->getAdvancedSearch()->schemaTables() as $table => $comment) {
+ $schema = $db->getTableSchema($table);
+ $schema['comment'] = $comment;
+ $out[$table] = $schema;
+ }
+
+ return $out;
+ }
+
+ public function getProtectedTables(): ?array
+ {
+ if ($this->type != self::TYPE_SQL || $this->target == self::TARGET_ALL) {
+ return null;
+ }
+
+ $list = $this->getAdvancedSearch()->tables();
+ $tables = [];
+
+ foreach ($list as $name) {
+ $tables[$name] = null;
+ }
+
+ return $tables;
+ }
+
+ public function getGroups(): array
+ {
+ if ($this->type != self::TYPE_JSON) {
+ throw new \LogicException('Only JSON searches can use this method');
+ }
+
+ return json_decode($this->content, true)['groups'];
+ }
+
+ public function quick(string $query): DynamicList
+ {
+ $this->content = json_encode($this->getAdvancedSearch()->simple($query, false));
+ $this->type = self::TYPE_JSON;
+ return $this->getDynamicList();
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Services/Fee.php b/src/include/lib/Paheko/Entities/Services/Fee.php
new file mode 100644
index 0000000..4472bab
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Services/Fee.php
@@ -0,0 +1,256 @@
+assert(trim($this->label) !== '', 'Le libellé doit être renseigné');
+ $this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
+ $this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
+ $this->assert(null === $this->amount || $this->amount > 0, 'Le montant est invalide : ' . $this->amount);
+ $this->assert($this->id_service, 'Aucun service n\'a été indiqué pour ce tarif.');
+ $this->assert((null === $this->id_account && null === $this->id_year)
+ || (null !== $this->id_account && null !== $this->id_year), 'Le compte doit être indiqué avec l\'exercice');
+ $this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ?', $this->id_account), 'Le compte indiqué n\'existe pas');
+ $this->assert(null === $this->id_year || $db->test(Year::TABLE, 'id = ?', $this->id_year), 'L\'exercice indiqué n\'existe pas');
+ $this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ? AND id_chart = (SELECT id_chart FROM acc_years WHERE id = ?)', $this->id_account, $this->id_year), 'Le compte sélectionné ne correspond pas à l\'exercice');
+ $this->assert(null === $this->id_project || $db->test(Project::TABLE, 'id = ?', $this->id_project), 'Le projet sélectionné n\'existe pas.');
+
+ if (null !== $this->formula && ($error = $this->checkFormula())) {
+ throw new ValidationException('Formule de calcul invalide: ' . $error);
+ }
+
+ $this->assert(null === $this->amount || null === $this->formula, 'Il n\'est pas possible de spécifier à la fois une formule et un montant');
+ }
+
+ public function getAmountForUser(int $user_id): ?int
+ {
+ if ($this->amount) {
+ return $this->amount;
+ }
+ elseif ($this->formula) {
+ $db = DB::getInstance();
+ return (int) $db->firstColumn($this->getFormulaSQL(), $user_id);
+ }
+
+ return null;
+ }
+
+ protected function getFormulaSQL()
+ {
+ return sprintf('SELECT (%s) FROM users WHERE id = ?;', $this->formula);
+ }
+
+ protected function checkFormula(): ?string
+ {
+ try {
+ $db = DB::getInstance();
+ $sql = $this->getFormulaSQL();
+ $db->protectSelect(['users' => null, 'services_users' => null, 'services' => null, 'services_fees' => null], $sql);
+ return null;
+ }
+ catch (DB_Exception $e) {
+ return $e->getMessage();
+ }
+ }
+
+ public function service()
+ {
+ return EntityManager::findOneById(Service::class, $this->id_service);
+ }
+
+ public function allUsersList(bool $include_hidden_categories = false): DynamicList
+ {
+ $identity = DynamicFields::getNameFieldsSQL('u');
+
+ $columns = [
+ 'id_user' => [
+ 'select' => 'su.id_user',
+ ],
+ 'service_label' => [
+ 'select' => 's.label',
+ 'label' => 'Activité',
+ 'export' => true,
+ ],
+ 'fee_label' => [
+ 'select' => 'sf.label',
+ 'label' => 'Tarif',
+ 'export' => true,
+ ],
+ 'user_number' => [
+ 'label' => 'Numéro de membre',
+ 'select' => 'u.' . DynamicFields::getNumberField(),
+ 'export' => true,
+ ],
+ 'identity' => [
+ 'label' => 'Membre',
+ 'select' => $identity,
+ ],
+ 'paid' => [
+ 'label' => 'Payé ?',
+ 'select' => 'su.paid',
+ 'order' => 'su.paid %s, su.date %1$s',
+ ],
+ 'paid_amount' => [
+ 'label' => 'Montant payé',
+ 'select' => 'CASE WHEN tu.id_service_user IS NOT NULL THEN SUM(l.credit) ELSE NULL END',
+ ],
+ 'date' => [
+ 'label' => 'Date',
+ 'select' => 'su.date',
+ ],
+ ];
+
+ $tables = 'services_users su
+ INNER JOIN users u ON u.id = su.id_user
+ INNER JOIN services_fees sf ON sf.id = su.id_fee
+ INNER JOIN services s ON s.id = sf.id_service
+ INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) AS su2 ON su2.id = su.id
+ LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
+ LEFT JOIN acc_transactions_lines l ON l.id_transaction = tu.id_transaction';
+ $conditions = sprintf('su.id_fee = %d', $this->id());
+
+ if (!$include_hidden_categories) {
+ $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->groupBy('su.id_user');
+ $list->orderBy('paid', true);
+ $list->setCount('COUNT(DISTINCT su.id_user)');
+
+ $list->setExportCallback(function (&$row) {
+ $row->paid_amount = $row->paid_amount ? Utils::money_format($row->paid_amount, '.', '', false) : null;
+ });
+
+ return $list;
+ }
+
+ public function activeUsersList(bool $include_hidden_categories = false): DynamicList
+ {
+ $list = $this->allUsersList();
+ $conditions = sprintf('su.id_fee = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
+ AND su.paid = 1', $this->id());
+
+ if (!$include_hidden_categories) {
+ $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+
+ $list->setConditions($conditions);
+ return $list;
+ }
+
+ public function unpaidUsersList(bool $include_hidden_categories = false): DynamicList
+ {
+ $list = $this->allUsersList();
+ $conditions = sprintf('su.id_fee = %d AND su.paid = 0', $this->id());
+
+ if (!$include_hidden_categories) {
+ $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+
+ $list->setConditions($conditions);
+ return $list;
+ }
+
+ public function expiredUsersList(bool $include_hidden_categories = false): DynamicList
+ {
+ $list = $this->allUsersList();
+ $conditions = sprintf('su.id_fee = %d AND su.expiry_date < date()', $this->id());
+
+ if (!$include_hidden_categories) {
+ $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+
+ $list->setConditions($conditions);
+ return $list;
+ }
+
+
+ public function getUsers(bool $paid_only = false): array
+ {
+ $where = $paid_only ? 'AND paid = 1' : '';
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+ $sql = sprintf('SELECT su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.id_fee = ? %s;', $id_field, $where);
+ return DB::getInstance()->getAssoc($sql, $this->id());
+ }
+
+ public function hasSubscriptions(): bool
+ {
+ return DB::getInstance()->test('services_users', 'id_fee = ?', $this->id());
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Services/Reminder.php b/src/include/lib/Paheko/Entities/Services/Reminder.php
new file mode 100644
index 0000000..2d76ed3
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Services/Reminder.php
@@ -0,0 +1,153 @@
+assert($this->id_service, 'Aucun service n\'a été indiqué pour ce tarif.');
+ $this->assert(trim($this->subject) !== '', 'Le sujet doit être renseigné');
+ $this->assert(strlen($this->subject) <= 200, 'Le sujet doit faire moins de 200 caractères');
+ $this->assert(trim($this->body) !== '', 'Le corps du message doit être renseigné');
+ $this->assert(strlen($this->body) <= 64000, 'Le corps du message doit faire moins de 64.000 caractères');
+ $this->assert($this->delay !== null, 'Le délai de rappel doit être renseigné');
+ }
+
+ public function service()
+ {
+ return EntityManager::findOneById(Service::class, $this->id_service);
+ }
+
+ public function importForm(array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ if (isset($source['delay_type'])) {
+ if (1 == $source['delay_type'] && !empty($source['delay_before'])) {
+ $source['delay'] = (int)$source['delay_before'] * -1;
+ }
+ elseif (2 == $source['delay_type'] && !empty($source['delay_after'])) {
+ $source['delay'] = (int)$source['delay_after'];
+ }
+ else {
+ $source['delay'] = 0;
+ }
+ }
+
+ // Warning: inverse logic here
+ if (!empty($source['yes_before'])) {
+ $source['not_before_date'] = null;
+ }
+ elseif (isset($source['yes_before']) && empty($source['yes_before'])) {
+ $source['not_before_date'] = date('Y-m-d');
+ }
+
+ parent::importForm($source);
+ }
+
+ public function sentList(): DynamicList
+ {
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+ $db = DB::getInstance();
+
+ $columns = [
+ 'id_user' => [
+ 'select' => 'srs.id_user',
+ ],
+ 'identity' => [
+ 'label' => 'Membre',
+ 'select' => $id_field,
+ ],
+ 'reminder_date' => [
+ 'label' => 'Date d\'envoi',
+ 'select' => 'srs.sent_date',
+ 'order' => 'srs.sent_date %s, srs.id %1$s',
+ ],
+ ];
+
+ $tables = 'services_reminders_sent srs
+ INNER JOIN users u ON u.id = srs.id_user';
+ $conditions = sprintf('srs.id_reminder = %d', $this->id());
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('reminder_date', true);
+ return $list;
+ }
+
+ public function pendingList(): DynamicList
+ {
+ $db = DB::getInstance();
+
+ $columns = [
+ 'id_user' => [
+ 'select' => 'id',
+ ],
+ 'identity' => [
+ 'label' => 'Membre',
+ ],
+ 'expiry_date' => [
+ 'label' => 'Date d\'expiration',
+ ],
+ 'reminder_date' => [
+ 'label' => 'Date d\'envoi',
+ ],
+ ];
+
+ $conditions = sprintf('su.id_service = %d AND sr.id = %d', $this->id_service, $this->id);
+ $tables = '(' . Reminders::getPendingSQL(false, $conditions) . ') AS pending';
+
+ $list = new DynamicList($columns, $tables);
+ $list->orderBy('expiry_date', false);
+ return $list;
+ }
+
+ public function getPreview(int $id_user): ?string
+ {
+ $conditions = sprintf('su.id_service = %d AND su.id_user = %d AND sr.id = %d', $this->id_service, $id_user, $this->id);
+ $sql = Reminders::getPendingSQL(false, $conditions);
+ $db = DB::getInstance();
+
+ foreach ($db->iterate($sql) as $reminder) {
+ $m = Reminders::createMessage($reminder);
+ return $m->getMessage($reminder);
+ }
+
+ return null;
+ }
+
+ public function deleteHistory(): void
+ {
+ $db = DB::getInstance();
+ $db->exec(sprintf('DELETE FROM services_reminders_sent WHERE id_reminder = %s;', $this->id));
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Services/ReminderMessage.php b/src/include/lib/Paheko/Entities/Services/ReminderMessage.php
new file mode 100644
index 0000000..f0d23d3
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Services/ReminderMessage.php
@@ -0,0 +1,112 @@
+body ?? $this->reminder()->body;
+
+ if (false !== strpos($body, '{{')) {
+ $body = '{{**keep_whitespaces**}}' . $body;
+ return UserTemplate::createFromUserString($body);
+ }
+ else {
+ return $body;
+ }
+ }
+
+ public function getMessage(stdClass $reminder): string
+ {
+ $body = $this->getBody($reminder);
+
+ $r = self::getMessageVariables($reminder);
+
+ if ($body instanceof UserTemplate) {
+ $body->assignArray($r, null, false);
+
+ try {
+ $body = $body->fetch();
+ }
+ catch (\KD2\Brindille_Exception $e) {
+ throw new UserException('Erreur de syntaxe dans le corps du message :' . PHP_EOL . $e->getPrevious()->getMessage(), 0, $e);
+ }
+ }
+
+ return $body;
+ }
+
+ static public function getMessageVariables(stdClass $reminder): array
+ {
+ $reminder = Fees::addUserAmountToObject($reminder, $reminder->id_user);
+ $reminder->user_amount = CommonModifiers::money_currency($reminder->user_amount ?? 0, true, false, false);
+ $reminder->reminder_date = CommonModifiers::date_short($reminder->reminder_date);
+ $reminder->expiry_date = CommonModifiers::date_short($reminder->expiry_date);
+
+ return (array) $reminder;
+ }
+
+ public function reminder(): ?Reminder
+ {
+ if (!$this->id_reminder) {
+ return null;
+ }
+
+ $this->_reminder ??= Reminders::get($this->id_reminder);
+ return $this->_reminder;
+ }
+
+ public function send(stdClass $reminder, $body = null)
+ {
+ $body ??= $this->getBody($reminder);
+ $data = ['data' => (array)self::getMessageVariables($reminder)];
+
+ foreach (DynamicFields::getEmailFields() as $email_field) {
+ $email = $reminder->$email_field ?? null;
+
+ if (empty($email)) {
+ continue;
+ }
+
+ $data['data']['email'] = $email;
+
+ // Envoi du mail
+ Emails::queue(Emails::CONTEXT_PRIVATE, [$email => $data], null, $reminder->subject, $body);
+ }
+
+ $this->save();
+
+ Plugins::fire('reminder.send.after', false, compact('reminder'));
+ }
+
+}
diff --git a/src/include/lib/Paheko/Entities/Services/Service.php b/src/include/lib/Paheko/Entities/Services/Service.php
new file mode 100644
index 0000000..b9cf4d5
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Services/Service.php
@@ -0,0 +1,212 @@
+assert(trim((string) $this->label) !== '', 'Le libellé doit être renseigné');
+ $this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
+ $this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
+ $this->assert(!isset($this->duration, $this->start_date, $this->end_date) || $this->duration || ($this->start_date && $this->end_date), 'Seulement une option doit être choisie : durée ou dates de début et de fin de validité');
+ $this->assert(null === $this->start_date || $this->start_date instanceof \DateTimeInterface);
+ $this->assert(null === $this->end_date || $this->end_date instanceof \DateTimeInterface);
+ $this->assert(null === $this->duration || (is_int($this->duration) && $this->duration > 0), 'La durée n\'est pas valide');
+ $this->assert(null === $this->start_date || $this->end_date >= $this->start_date, 'La date de fin de validité ne peut être avant la date de début');
+ }
+
+ public function importForm(?array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ if (isset($source['period'])) {
+ if (1 == $source['period']) {
+ $source['start_date'] = $source['end_date'] = null;
+ }
+ elseif (2 == $source['period']) {
+ $source['duration'] = null;
+ }
+ else {
+ $source['duration'] = $source['start_date'] = $source['end_date'] = null;
+ }
+ }
+
+ if (isset($source['archived_present']) && empty($source['archived'])) {
+ $source['archived'] = false;
+ }
+
+ parent::importForm($source);
+ }
+
+ public function fees()
+ {
+ return new Fees($this->id());
+ }
+
+ public function allUsersList(bool $include_hidden_categories = false): DynamicList
+ {
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+ $columns = [
+ 'id_user' => [
+ ],
+ 'end_date' => [
+ ],
+ 'service_label' => [
+ 'select' => 's.label',
+ 'label' => 'Activité',
+ 'export' => true,
+ ],
+ 'fee_label' => [
+ 'select' => 'sf.label',
+ 'label' => 'Tarif',
+ 'export' => true,
+ ],
+ 'user_number' => [
+ 'label' => 'Numéro de membre',
+ 'select' => 'u.' . DynamicFields::getNumberField(),
+ 'export' => true,
+ ],
+ 'identity' => [
+ 'label' => 'Membre',
+ 'select' => $id_field,
+ ],
+ 'status' => [
+ 'label' => 'Statut',
+ 'select' => 'CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END',
+ ],
+ 'paid' => [
+ 'label' => 'Payé ?',
+ 'select' => 'su.paid',
+ 'order' => 'su.paid %s, su.date %1$s',
+ ],
+ 'expiry' => [
+ 'label' => 'Date d\'expiration',
+ 'select' => 'MAX(su.expiry_date)',
+ ],
+ 'fee' => [
+ 'label' => 'Tarif',
+ 'select' => 'sf.label',
+ ],
+ 'date' => [
+ 'label' => 'Date d\'inscription',
+ 'select' => 'su.date',
+ ],
+ ];
+
+ $tables = 'services_users su
+ INNER JOIN users u ON u.id = su.id_user
+ INNER JOIN services s ON s.id = su.id_service
+ LEFT JOIN services_fees sf ON sf.id = su.id_fee
+ INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) AS su2 ON su2.id = su.id';
+ $conditions = sprintf('su.id_service = %d', $this->id());
+
+ if (!$include_hidden_categories) {
+ $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->groupBy('su.id_user');
+ $list->orderBy('paid', true);
+ $list->setCount('COUNT(DISTINCT su.id_user)');
+
+ $list->setExportCallback(function (&$row) {
+ $row->status = $row->status == -1 ? 'En retard' : ($row->status == 1 ? 'En cours' : '');
+ $row->paid = $row->paid ? 'Oui' : 'Non';
+ });
+
+ return $list;
+ }
+
+ public function activeUsersList(bool $include_hidden_categories = false): DynamicList
+ {
+ $list = $this->allUsersList();
+ $conditions = sprintf('su.id_service = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
+ AND su.paid = 1', $this->id());
+
+ if (!$include_hidden_categories) {
+ $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+
+ $list->setConditions($conditions);
+ return $list;
+ }
+
+ public function unpaidUsersList(bool $include_hidden_categories = false): DynamicList
+ {
+ $list = $this->allUsersList();
+ $conditions = sprintf('su.id_service = %d AND su.paid = 0', $this->id());
+
+ if (!$include_hidden_categories) {
+ $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+
+ $list->setConditions($conditions);
+ return $list;
+ }
+
+ public function expiredUsersList(bool $include_hidden_categories = false): DynamicList
+ {
+ $list = $this->allUsersList();
+ $conditions = sprintf('su.id_service = %d AND su.expiry_date < date()', $this->id());
+
+ if (!$include_hidden_categories) {
+ $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+
+ $list->setConditions($conditions);
+ return $list;
+ }
+
+ public function hasSubscriptions(): bool
+ {
+ return DB::getInstance()->test('services_users', 'id_service = ?', $this->id());
+ }
+
+ public function getUsers(bool $paid_only = false) {
+ $where = $paid_only ? 'AND paid = 1' : '';
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+ $sql = sprintf('SELECT su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.id_service = ? %s;', $id_field, $where);
+ return DB::getInstance()->getAssoc($sql, $this->id());
+ }
+
+ public function long_label(): string
+ {
+ if ($this->duration) {
+ $duration = sprintf('%d jours', $this->duration);
+ }
+ elseif ($this->start_date)
+ $duration = sprintf('du %s au %s', $this->start_date->format('d/m/Y'), $this->end_date->format('d/m/Y'));
+ else {
+ $duration = 'ponctuelle';
+ }
+
+ return sprintf('%s — %s', $this->label, $duration);
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Services/Service_User.php b/src/include/lib/Paheko/Entities/Services/Service_User.php
new file mode 100644
index 0000000..6170179
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Services/Service_User.php
@@ -0,0 +1,278 @@
+assert($this->id_service, 'Aucune activité spécifiée');
+ $this->assert($this->id_user, 'Aucun membre spécifié');
+ $this->assert(!$this->isDuplicate(), 'Cette activité a déjà été enregistrée pour ce membre, ce tarif et cette date');
+
+ $db = DB::getInstance();
+ // don't allow an id_fee that does not match a service
+ if (null !== $this->id_fee && !$db->test(Fee::TABLE, 'id = ? AND id_service = ?', $this->id_fee, $this->id_service)) {
+ $this->set('id_fee', null);
+ }
+ }
+
+ public function isDuplicate(bool $using_date = true): bool
+ {
+ if (!isset($this->id_user, $this->id_service)) {
+ throw new \LogicException('Entity does not define either user or service');
+ }
+
+ $params = [
+ 'id_user' => $this->id_user,
+ 'id_service' => $this->id_service,
+ 'id_fee' => $this->id_fee,
+ ];
+
+ if ($using_date) {
+ $params['date'] = $this->date->format('Y-m-d');
+ }
+
+ $where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params));
+ $where = implode(' AND ', $where);
+
+ if ($this->exists()) {
+ $where .= sprintf(' AND id != %d', $this->id());
+ }
+
+ return DB::getInstance()->test(self::TABLE, $where, array_values($params));
+ }
+
+ public function importForm(?array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ $service = null;
+
+ if (!empty($source['id_service']) && empty($source['expiry_date'])) {
+ $service = $this->_service = Services::get((int) $source['id_service']);
+
+ if (!$service) {
+ throw new \LogicException('The requested service is not found');
+ }
+
+ if ($service->duration) {
+ $dt = new Date;
+ $dt->modify(sprintf('+%d days', $service->duration));
+ $this->set('expiry_date', $dt);
+ }
+ elseif ($service->end_date) {
+ $this->set('expiry_date', $service->end_date);
+ }
+ else {
+ $this->set('expiry_date', null);
+ }
+ }
+
+ if (!empty($source['id_service'])) {
+ if (!$service) {
+ $service = $this->_service = Services::get((int) $source['id_service']);
+ }
+ }
+
+ return parent::importForm($source);
+ }
+
+ public function service(): Service
+ {
+ if (null === $this->_service) {
+ $this->_service = Services::get($this->id_service);
+ }
+
+ return $this->_service;
+ }
+
+ /**
+ * Returns the Fee entity linked to this subscription
+ * This can be NULL if there was no fee existing at the time of subscription
+ * (that way you can use subscriptions without fees if you want)
+ */
+ public function fee(): ?Fee
+ {
+ if (null === $this->id_fee) {
+ return null;
+ }
+
+ if (null === $this->_fee) {
+ $this->_fee = Fees::get($this->id_fee);
+ }
+
+ return $this->_fee;
+ }
+
+ public function addPayment(int $user_id, ?array $source = null): Transaction
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ if (!$this->id_fee) {
+ throw new \RuntimeException('Cannot add a payment to a subscription that is not linked to a fee');
+ }
+
+ if (!$this->fee()->id_year) {
+ throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié');
+ }
+
+ if (empty($source['amount'])) {
+ throw new ValidationException('Montant non précisé');
+ }
+
+ $account = Form::getSelectorValue($source['account_selector'] ?? null);
+
+ if (!$account) {
+ throw new ValidationException('Aucune compte n\'a été sélectionné.');
+ }
+
+ $label = $this->service()->label;
+
+ if ($this->fee()->label != $label) {
+ $label .= ' - ' . $this->fee()->label;
+ }
+
+ $label .= sprintf(' (%s)', Users::getName($this->id_user));
+
+ $transaction = Transactions::create(array_merge($source, [
+ 'type' => Transaction::TYPE_REVENUE,
+ 'label' => $label,
+ 'id_project' => $source['id_project'] ?? $this->fee()->id_project,
+ 'simple' => [Transaction::TYPE_REVENUE => [
+ 'credit' => [$this->fee()->id_account => null],
+ 'debit' => $source['account_selector'],
+ ]],
+ 'id_year' => $this->fee()->id_year,
+ ]));
+
+ $transaction->id_creator = $user_id;
+ $transaction->id_year = $this->fee()->id_year;
+ $transaction->type = Transaction::TYPE_REVENUE;
+
+ $transaction->save();
+ $transaction->linkToSubscription($this->id());
+
+ return $transaction;
+ }
+
+ public function updateExpectedAmount(): void
+ {
+ $fee = $this->fee();
+
+ if ($fee && $fee->id_account && $this->id_user) {
+ $this->set('expected_amount', $fee->getAmountForUser($this->id_user));
+ }
+ else {
+ $this->set('expected_amount', null);
+ }
+ }
+
+ static public function createFromForm(array &$users, int $creator_id, bool $from_copy = false, ?array $source = null): self
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ if (!count($users)) {
+ throw new ValidationException('Aucun membre n\'a été sélectionné.');
+ }
+
+ $multiple_users = count($users) > 1;
+ $errors = [];
+
+ foreach ($users as $id => $name) {
+ $su = new self;
+ $su->date = new Date;
+ $su->importForm($source);
+ $su->id_user = (int) $id;
+
+ if (empty($su->id_service)) {
+ throw new ValidationException('Aucune activité n\'a été sélectionnée.');
+ }
+
+ $su->updateExpectedAmount();
+
+ if ($su->isDuplicate($from_copy ? false : true)) {
+ if ($from_copy) {
+ continue;
+ }
+ else {
+ $errors[] = $name;
+
+ if (!$multiple_users) {
+ throw new ValidationException(sprintf('%s : Cette activité a déjà été enregistrée pour ce membre et cette date', $name));
+ }
+
+ unset($users[$id]);
+ continue;
+ }
+ }
+
+ $su->save();
+
+ if ($su->id_fee && $su->fee()->id_account
+ && !empty($source['amount'])
+ && !empty($source['create_payment'])) {
+ try {
+ $su->addPayment($creator_id, $source);
+ }
+ catch (ValidationException $e) {
+ if ($e->getMessage() == 'Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé') {
+ throw new ValidationException('Impossible d\'enregistrer l\'inscription : ce tarif d\'activité est lié à un exercice clôturé. Merci de modifier le tarif et choisir un autre exercice.', 0, $e);
+ }
+ else {
+ throw $e;
+ }
+ }
+ }
+ }
+
+ if (count($errors)) {
+ $db->rollback();
+
+ throw new ValidationException(sprintf("Les membres suivants ne pourront pas être inscrits car ils sont déjà inscrits à cette activité et à la date indiquée :\n%s\n\nValidez à nouveau le formulaire pour confirmer les inscriptions des autres membres.", implode(', ', $errors)));
+ }
+
+ $db->commit();
+
+ return $su;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Entities/Signal.php b/src/include/lib/Paheko/Entities/Signal.php
new file mode 100644
index 0000000..396cae8
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Signal.php
@@ -0,0 +1,85 @@
+name = $name;
+ $this->stoppable = $stoppable;
+ $this->in = $in;
+ $this->out = $out;
+ }
+
+ public function isStoppable(): bool
+ {
+ return $this->stoppable;
+ }
+
+ public function isStopped(): bool
+ {
+ return $this->stop;
+ }
+
+ public function stop(): void
+ {
+ if (!$this->stoppable) {
+ throw new \LogicException('Trying to stop a non-stoppable signal');
+ }
+
+ $this->stop = true;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function getIn(?string $name = null)
+ {
+ if (null === $name) {
+ return $this->in;
+ }
+
+ if (!array_key_exists($name, $this->in)) {
+ throw new \LogicException(sprintf('Cannot get incoming variable "%s" in signal "%s": unknown variable name', $name, $this->name));
+ }
+
+ return $this->in[$name];
+ }
+
+ public function setOutArray(array $out): void
+ {
+ $this->out = $out;
+ }
+
+ public function setOut(string $name, $value): void
+ {
+ $this->out[$name] = $value;
+ }
+
+ public function getOut(?string $name = null)
+ {
+ if (null === $name) {
+ return $this->out;
+ }
+
+ return $this->out[$name] ?? null;
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Users/Category.php b/src/include/lib/Paheko/Entities/Users/Category.php
new file mode 100644
index 0000000..74ba671
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Users/Category.php
@@ -0,0 +1,143 @@
+ [
+ 'label' => 'Les membres de cette catégorie peuvent-ils se connecter ?',
+ 'shape' => Utils::ICONS['login'],
+ 'options' => [
+ Session::ACCESS_NONE => 'N\'a pas le droit de se connecter',
+ Session::ACCESS_READ => 'A le droit de se connecter',
+ ],
+ ],
+ 'users' => [
+ 'label' => 'Gestion des membres',
+ 'shape' => Utils::ICONS['users'],
+ 'options' => [
+ Session::ACCESS_NONE => 'Pas d\'accès',
+ Session::ACCESS_READ => 'Lecture uniquement (peut voir les informations personnelles de tous les membres, y compris leurs inscriptions à des activités)',
+ Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter et modifier des membres, mais pas les supprimer ni les changer de catégorie, peut inscrire des membres à des activités, peut envoyer des messages collectifs)',
+ Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
+ ],
+ ],
+ 'accounting' => [
+ 'label' => 'Comptabilité',
+ 'shape' => Utils::ICONS['money'],
+ 'options' => [
+ Session::ACCESS_NONE => 'Pas d\'accès',
+ Session::ACCESS_READ => 'Lecture uniquement (peut lire toutes les informations de tous les exercices)',
+ Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter des écritures, mais pas les modifier ni les supprimer)',
+ Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
+ ],
+ ],
+ 'documents' => [
+ 'label' => 'Documents',
+ 'shape' => Utils::ICONS['folder'],
+ 'options' => [
+ Session::ACCESS_NONE => 'Pas d\'accès',
+ Session::ACCESS_READ => 'Lecture uniquement (peut lire tous les fichiers)',
+ Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter, modifier et déplacer des fichiers, mais pas les supprimer)',
+ Session::ACCESS_ADMIN => 'Administration (peut tout faire, notamment mettre des fichiers dans la corbeille)',
+ ],
+ ],
+ 'web' => [
+ 'label' => 'Gestion du site web',
+ 'shape' => Utils::ICONS['globe'],
+ 'options' => [
+ Session::ACCESS_NONE => 'Pas d\'accès',
+ Session::ACCESS_READ => 'Lecture uniquement (peut lire les pages)',
+ Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter et modifier des pages et catégories, mais pas les supprimer)',
+ Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
+ ],
+ ],
+ 'config' => [
+ 'label' => 'Les membres de cette catégorie peuvent-ils modifier la configuration ?',
+ 'shape' => Utils::ICONS['settings'],
+ 'options' => [
+ Session::ACCESS_NONE => 'Ne peut pas modifier la configuration',
+ Session::ACCESS_ADMIN => 'Peut modifier la configuration',
+ ],
+ ],
+ ];
+
+ public function selfCheck(): void
+ {
+ parent::selfCheck();
+
+ $this->assert(trim($this->name) !== '', 'Le nom de catégorie ne peut rester vide.');
+
+ foreach (self::PERMISSIONS as $key => $perm) {
+ $this->assert(array_key_exists($this->{'perm_' . $key}, $perm['options']), 'Invalid value for perm_' . $key);
+ }
+ }
+
+ public function delete(): bool
+ {
+ $db = DB::getInstance();
+ $config = Config::getInstance();
+
+ if ($this->id() == $config->get('default_category')) {
+ throw new UserException('Il est interdit de supprimer la catégorie définie par défaut dans la configuration.');
+ }
+
+ if ($db->test('users', 'id_category = ?', $this->id())) {
+ throw new UserException('La catégorie contient encore des membres, il n\'est pas possible de la supprimer.');
+ }
+
+ return parent::delete();
+ }
+
+ public function setAllPermissions(int $access): void
+ {
+ foreach (self::PERMISSIONS as $key => $perm) {
+ // Restrict to the maximum access level, as some permissions only allow up to READ
+ $perm_access = min($access, max(array_keys($perm['options'])));
+ $this->set('perm_' . $key, $perm_access);
+ }
+ }
+
+ public function getPermissions(): array
+ {
+ $out = [];
+
+ foreach (self::PERMISSIONS as $key => $perm) {
+ $out[$key] = $this->{'perm_' . $key};
+ }
+
+ return $out;
+ }
+
+ public function count(): int
+ {
+ return DB::getInstance()->count(self::TABLE, 'id = ?', $this->id);
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Users/DynamicField.php b/src/include/lib/Paheko/Entities/Users/DynamicField.php
new file mode 100644
index 0000000..cce4cf5
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Users/DynamicField.php
@@ -0,0 +1,395 @@
+ 'Adresse E-Mail',
+ 'url' => 'Adresse URL',
+ 'checkbox' => 'Case à cocher',
+ 'date' => 'Date',
+ 'datetime' => 'Date et heure',
+ 'month' => 'Mois et année',
+ 'year' => 'Année',
+ 'file' => 'Fichier',
+ 'password' => 'Mot de passe',
+ 'number' => 'Nombre',
+ 'decimal' => 'Nombre à virgule',
+ 'tel' => 'Numéro de téléphone',
+ 'select' => 'Sélecteur à choix unique',
+ 'multiple' => 'Sélecteur à choix multiple',
+ 'country' => 'Sélecteur de pays',
+ 'text' => 'Texte libre, une ligne',
+ 'datalist' => 'Texte libre, une ligne, à choix multiple',
+ 'textarea' => 'Texte libre, plusieurs lignes',
+ 'virtual' => 'Calculé',
+ ];
+
+ const PHP_TYPES = [
+ 'email' => '?string',
+ 'url' => '?string',
+ 'checkbox' => 'bool',
+ 'date' => '?' . Date::class,
+ 'datetime' => '?DateTime',
+ 'month' => '?string',
+ 'year' => '?int',
+ 'file' => '?string',
+ 'password' => '?string',
+ 'number' => '?integer',
+ 'decimal' => '?float',
+ 'tel' => '?string',
+ 'select' => '?string',
+ 'multiple' => '?int',
+ 'country' => '?string',
+ 'text' => '?string',
+ 'textarea' => '?string',
+ 'datalist' => '?string',
+ 'virtual' => 'dynamic',
+ ];
+
+ const SQL_TYPES = [
+ 'email' => 'TEXT',
+ 'url' => 'TEXT',
+ 'checkbox' => 'INTEGER NOT NULL DEFAULT 0',
+ 'date' => 'TEXT',
+ 'datetime' => 'TEXT',
+ 'month' => 'TEXT',
+ 'year' => 'INTEGER',
+ 'file' => 'TEXT',
+ 'password' => 'TEXT',
+ 'number' => 'INTEGER',
+ 'decimal' => 'FLOAT',
+ 'tel' => 'TEXT',
+ 'select' => 'TEXT',
+ 'multiple' => 'INTEGER',
+ 'country' => 'TEXT',
+ 'text' => 'TEXT',
+ 'textarea' => 'TEXT',
+ 'datalist' => 'TEXT',
+ 'virtual' => null,
+ ];
+
+ const SEARCH_TYPES = [
+ 'email',
+ 'text',
+ 'textarea',
+ 'url',
+ ];
+
+ const LOGIN_FIELD_TYPES = [
+ 'email',
+ 'url',
+ 'text',
+ 'number',
+ 'tel',
+ ];
+
+ const NAME_FIELD_TYPES = [
+ 'text',
+ 'select',
+ 'number',
+ 'url',
+ 'email',
+ ];
+
+ const SQL_CONSTRAINTS = [
+ 'checkbox' => '%1s = 1 OR %1s = 0',
+ 'date' => '%1s IS NULL OR (date(%1$s) IS NOT NULL AND date(%1s) = %1$s)',
+ 'datetime' => '%1s IS NULL OR (date(%1$s) IS NOT NULL AND date(%1s) = %1$s)',
+ 'month' => '%1s IS NULL OR (date(%1s || \'-03\') = %1$s || \'-03\')', // Use 3rd day to avoid any potential issue with timezones
+ ];
+
+ const SYSTEM_FIELDS = [
+ 'id' => '?int',
+ 'id_category' => 'int',
+ 'pgp_key' => '?string',
+ 'otp_secret' => '?string',
+ 'date_login' => '?DateTime',
+ 'date_updated' => '?DateTime',
+ 'id_parent' => '?int',
+ 'is_parent' => 'bool',
+ 'preferences' => '?stdClass',
+ ];
+
+ const SYSTEM_FIELDS_SQL = [
+ 'id INTEGER PRIMARY KEY,',
+ 'id_category INTEGER NOT NULL REFERENCES users_categories(id),',
+ 'date_login TEXT NULL CHECK (date_login IS NULL OR datetime(date_login) = date_login),',
+ 'date_updated TEXT NULL CHECK (date_updated IS NULL OR datetime(date_updated) = date_updated),',
+ 'otp_secret TEXT NULL,',
+ 'pgp_key TEXT NULL,',
+ 'id_parent INTEGER NULL REFERENCES users(id) ON DELETE SET NULL CHECK (id_parent IS NULL OR is_parent = 0),',
+ 'is_parent INTEGER NOT NULL DEFAULT 0,',
+ 'preferences TEXT NULL,'
+ ];
+
+ public function sql_type(): string
+ {
+ if ($this->type == 'checkbox') {
+ return 'INTEGER';
+ }
+
+ return self::SQL_TYPES[$this->type];
+ }
+
+ public function delete(): bool
+ {
+ if (!$this->canDelete()) {
+ throw new ValidationException('Ce champ est utilisé en interne, il n\'est pas possible de le supprimer');
+ }
+
+ foreach (DynamicFields::getVirtualFields() as $field) {
+ if ($field->isReferencing($this->name)) {
+ throw new ValidationException(sprintf('Ce champ ne peut être supprimé, car le champ calculé "%s" en a besoin pour fonctionner.', $field->label));
+ }
+ }
+
+ if ($this->type == 'file') {
+ // Delete all linked files
+ $glob = sprintf('%s/*/%s', File::CONTEXT_USER, $this->name);
+
+ foreach (Files::glob($glob) as $file) {
+ $file->delete();
+ }
+
+ DB::getInstance()->preparedQuery('DELETE FROM users_files WHERE field = ?;', $this->name);
+ }
+
+ return parent::delete();
+ }
+
+ public function canSetDefaultValue(): bool
+ {
+ return in_array($this->type ?? null, ['text', 'textarea', 'number', 'select', 'multiple']);
+ }
+
+ public function isPreset(): bool
+ {
+ return (bool) ($this->system & self::PRESET);
+ }
+
+ public function isName(): bool
+ {
+ return (bool) ($this->system & self::NAMES);
+ }
+
+ public function isNumber(): bool
+ {
+ return (bool) ($this->system & self::NUMBER);
+ }
+
+ public function isVirtual(): bool
+ {
+ return $this->type == 'virtual';
+ }
+
+ public function isReferencing(string $name): bool
+ {
+ return $this->isVirtual() && preg_match('/\b' . $name . '\b/', $this->sql);
+ }
+
+ public function canDelete(): bool
+ {
+ if ($this->system & self::PASSWORD || $this->system & self::NUMBER || $this->system & self::NAMES || $this->system & self::LOGIN) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function hasSearchCache(): bool
+ {
+ return in_array($this->type, DynamicField::SEARCH_TYPES);
+ }
+
+ public function selfCheck(): void
+ {
+ // Disallow name change if the field exists
+ if ($this->exists()) {
+ $this->assert(!$this->isModified('name'));
+ $this->assert(!$this->isModified('type'));
+ }
+
+ $this->name = strtolower($this->name);
+
+ $this->assert(in_array($this->management_access_level, Session::ACCESS_LEVELS, true));
+ $this->assert(in_array($this->user_access_level, Session::ACCESS_LEVELS, true));
+
+ $this->assert(!array_key_exists($this->name, self::SYSTEM_FIELDS), 'Ce nom de champ est déjà utilisé par un champ système, merci d\'en choisir un autre.');
+ $this->assert(preg_match('!^[a-z][a-z0-9]*(_[a-z0-9]+)*$!', $this->name), 'Le nom du champ est invalide : ne sont acceptés que les lettres minuscules et les chiffres (éventuellement séparés par un underscore).');
+
+ $this->assert(trim($this->label) != '', 'Le libellé est obligatoire.');
+ $this->assert($this->type && array_key_exists($this->type, self::TYPES), 'Type de champ invalide.');
+
+ if ($this->system & self::PASSWORD) {
+ $this->assert($this->type == 'password', 'Le champ mot de passe ne peut être d\'un type différent de mot de passe.');
+ }
+
+ if ($this->type == 'multiple' || $this->type == 'select') {
+ $this->options = array_filter($this->options);
+ $this->assert(is_array($this->options) && count($this->options) >= 1 && trim((string)current($this->options)) !== '', 'Ce champ nécessite de comporter au moins une option possible: ' . $this->name);
+ }
+
+ $db = DB::getInstance();
+
+ if (!$this->exists()) {
+ $this->assert(!$db->test(self::TABLE, 'name = ?', $this->name), 'Ce nom de champ est déjà utilisé par un autre champ: ' . $this->name);
+ }
+ else {
+ $this->assert(!$db->test(self::TABLE, 'name = ? AND id != ?', $this->name, $this->id()), 'Ce nom de champ est déjà utilisé par un autre champ.');
+ }
+
+ if ($this->exists()) {
+ $this->assert($this->system & self::PRESET || !array_key_exists($this->name, DynamicFields::getInstance()->getPresets()), 'Ce nom de champ est déjà utilisé par un champ pré-défini.');
+ }
+
+ if ($this->type === 'virtual') {
+ $this->assert(null !== $this->sql && strlen(trim($this->sql)), 'Le code SQL est manquant');
+
+ try {
+ $db->protectSelect(['users' => []], sprintf('SELECT (%s) FROM users;', $this->sql));
+ }
+ catch (\KD2\DB\DB_Exception $e) {
+ throw new ValidationException('Le code SQL du champ calculé est invalide: ' . $e->getMessage(), 0, $e);
+ }
+ }
+ }
+
+ public function importForm(array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ $source['required'] = !empty($source['required']) ? true : false;
+ $source['list_table'] = !empty($source['list_table']) ? true : false;
+
+ return parent::importForm($source);
+ }
+
+ public function getRealType(): ?string
+ {
+ $db = DB::getInstance();
+ $type = $db->firstColumn(sprintf('SELECT TYPEOF(%s) FROM users_view WHERE %1$s IS NOT NULL LIMIT 1;', $db->quoteIdentifier($this->name)));
+ return strtolower($type) ?: null;
+ }
+
+ public function hasNullValues(): bool
+ {
+ $db = DB::getInstance();
+ return (bool) $db->firstColumn(sprintf('SELECT 1 FROM users_view WHERE %1$s IS NULL LIMIT 1;', $db->quoteIdentifier($this->name)));
+ }
+
+ public function getStringValue($value): ?string
+ {
+ if (null === $value) {
+ return null;
+ }
+
+ switch ($this->type) {
+ case 'multiple':
+ // Useful for search results, if a value is not a number
+ if (!is_numeric($value)) {
+ return '';
+ }
+
+ $out = [];
+
+ foreach ($this->options as $b => $name)
+ {
+ if ($value & (0x01 << $b))
+ $out[] = $name;
+ }
+
+ return implode(', ', $out);
+ case 'checkbox':
+ return $value ? 'Oui' : '';
+ case 'date':
+ return CommonModifiers::date_short($value, false);
+ case 'datetime':
+ return CommonModifiers::date_short($value, true);
+ case 'country':
+ return Utils::getCountryName($value);
+ default:
+ return (string) $value;
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Users/User.php b/src/include/lib/Paheko/Entities/Users/User.php
new file mode 100644
index 0000000..70c4fbb
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Users/User.php
@@ -0,0 +1,707 @@
+property = 'value' to set a property value on this class
+ * as they will not be saved using save(). Please use $user->set('property', 'value').
+ *
+ * This is because dynamic properties are set as public, and __set is not called.
+ * TODO: change to storing properties in an array
+ */
+#[\AllowDynamicProperties]
+class User extends Entity
+{
+ const NAME = 'Membre';
+ const PRIVATE_URL = '!users/details.php?id=%d';
+
+ const MINIMUM_PASSWORD_LENGTH = 8;
+
+ const TABLE = 'users';
+
+ const PREFERENCES = [
+ 'folders_gallery' => false,
+ 'page_size' => 100,
+ 'accounting_expert' => false,
+ 'dark_theme' => false,
+ 'force_handheld' => false,
+ ];
+
+ protected bool $_loading = false;
+
+ public function __construct()
+ {
+ $this->reloadProperties();
+
+ parent::__construct();
+ }
+
+ protected function reloadProperties(): void
+ {
+ if (empty(self::$_types_cache[static::class])) {
+ $this->_types = DynamicField::SYSTEM_FIELDS;
+
+ $fields = DynamicFields::getInstance()->all();
+
+ foreach ($fields as $key => $config) {
+ // Fallback to dynamic, if a field type has been deleted
+ $this->_types[$key] = DynamicField::PHP_TYPES[$config->type] ?? 'dynamic';
+ }
+ }
+ elseif (empty($this->_types)) {
+ $this->_types = self::$_types_cache[static::class];
+ }
+
+ self::_loadEntityTypesCache($this->_types);
+
+ $this->_loading = true;
+
+ foreach ($this->_types as $key => $v) {
+ if (!property_exists($this, $key)) {
+ $this->$key = null;
+ }
+ }
+
+ $this->_loading = false;
+ }
+
+ public function __wakeup(): void
+ {
+ $this->reloadProperties();
+ }
+
+ public function set(string $key, $value) {
+ if ($this->_loading && $value === null) {
+ $this->$key = $value;
+ return;
+ }
+
+ // Don't bother for type with virtual columns
+ // also don't set it as modified as we don't save the value
+ if ($this->_types[$key] === 'dynamic') {
+ $this->$key = $value;
+ return;
+ }
+
+ // Filter double/triple spaces instead of double spaces,
+ // to help users who try to log in, see https://fossil.kd2.org/paheko/info/c3295fe0af72e4b3
+ // Only when setting a new value
+ if (is_string($value) && false !== strpos($value, ' ') && DynamicFields::get($key)->type == 'text') {
+ $value = preg_replace('![ ]{2,}!', ' ', $value);
+ }
+
+ return parent::set($key, $value);
+ }
+
+ public function selfCheck(): void
+ {
+ $this->assert(!empty($this->id_category), 'Aucune catégorie n\'a été sélectionnée.');
+
+ $df = DynamicFields::getInstance();
+
+ foreach ($df->all() as $field) {
+ $value = $this->{$field->name};
+
+ if (null !== $value) {
+ if ($field->type === 'email') {
+ $this->assert($value === null || SMTP::checkEmailIsValid($value, false), sprintf('"%s" : l\'adresse e-mail "%s" n\'est pas valide.', $field->label, $value));
+ }
+ elseif ($field->type === 'checkbox') {
+ $this->assert($value === false || $value === true, sprintf('"%s" : la valeur de ce champ n\'est pas valide.', $field->label));
+ }
+ }
+
+ if (!$field->required || $field->system & $field::PASSWORD) {
+ continue;
+ }
+
+ if (empty($value) && ($field->system & $field::NUMBER)) {
+ $this->setNumberIfEmpty();
+ continue;
+ }
+
+ $this->assert(null !== $value, sprintf('"%s" : ce champ est requis', $field->label));
+
+ if (is_bool($value) && $field->required) {
+ $this->assert($value === true, sprintf('"%s" : ce champ doit être coché', $field->label));
+ }
+ elseif (!is_array($value) && !is_object($value) && !is_bool($value)) {
+ $this->assert('' !== trim((string)$value), sprintf('"%s" : ce champ ne peut être vide', $field->label));
+ }
+
+ if ($field->type === 'select') {
+ $this->assert(in_array($value, $field->options), sprintf('"%s" : la valeur "%s" ne fait pas partie des options possibles', $field->label, $value));
+ }
+ elseif ($field->type === 'country') {
+ $this->assert(strlen($value) === 2, sprintf('"%s" : un champ pays ne peut contenir que deux lettres', $field->label));
+ $this->assert(Utils::getCountryName($value) !== null, sprintf('"%s" : pays inconnu : "%s"', $field->label, $value));
+ }
+ }
+
+ // check user number
+ $field = DynamicFields::getNumberField();
+ $this->assert($this->$field !== null && ctype_digit((string)$this->$field), 'Numéro de membre invalide : ne peut contenir que des chiffres');
+
+ $db = DB::getInstance();
+
+ if (!$this->exists()) {
+ $number_exists = $db->test(self::TABLE, sprintf('%s = ?', $db->quoteIdentifier($field)), $this->$field);
+ }
+ else {
+ $number_exists = $db->test(self::TABLE, sprintf('%s = ? AND id != ?', $db->quoteIdentifier($field)), $this->$field, $this->id());
+ }
+
+ $this->assert(!$number_exists, 'Ce numéro de membre est déjà attribué à un autre membre.');
+
+ $field = DynamicFields::getLoginField();
+ if ($this->$field !== null) {
+ if (!$this->exists()) {
+ $login_exists = $db->test(self::TABLE, sprintf('%s = ? COLLATE NOCASE', $db->quoteIdentifier($field)), $this->$field);
+ }
+ else {
+ $login_exists = $db->test(self::TABLE, sprintf('%s = ? COLLATE NOCASE AND id != ?', $db->quoteIdentifier($field)), $this->$field, $this->id());
+ }
+
+ $this->assert(!$login_exists, sprintf('Le champ "%s" (utilisé comme identifiant de connexion) est déjà utilisé par un autre membre. Il doit être unique pour chaque membre.', $df->fieldByKey($field)->label));
+ }
+
+ if ($this->id_parent !== null) {
+ $this->assert(!$this->is_parent, 'Un membre ne peut être responsable et rattaché en même temps.');
+ $this->assert($this->id_parent > 0, 'Invalid parent ID');
+ $this->assert(!$this->exists() || $this->id_parent != $this->id(), 'Invalid parent ID');
+ $this->assert(!$db->test(self::TABLE, 'id = ? AND id_parent IS NOT NULL', $this->id_parent), 'Le membre sélectionné comme responsable est déjà rattaché à un autre membre.');
+ }
+ }
+
+ public function delete(): bool
+ {
+ $session = Session::getInstance();
+
+ if ($session->isLogged()) {
+ $user = $session->getUser();
+
+ if ($user->id == $this->id) {
+ throw new UserException('Il n\'est pas possible de supprimer son propre compte. Merci de demander à un autre administrateur de le faire.');
+ }
+ }
+
+ Files::delete($this->attachmentsDirectory());
+
+ return parent::delete();
+ }
+
+ public function asArray(bool $for_database = false): array
+ {
+ $out = parent::asArray($for_database);
+
+ // Remove virtual columns
+ if ($for_database) {
+ foreach (DynamicFields::getInstance()->all() as $field) {
+ if ($field->type == 'virtual') {
+ unset($out[$field->name]);
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ public function asModuleArray(): array
+ {
+ $out = $this->asArray();
+ $out['_name'] = $this->name();
+ $out['_email'] = $this->email();
+ $out['_number'] = $this->number();
+ $out['_login'] = $this->login();
+ return $out;
+ }
+
+ public function asDetailsArray(bool $modified_values = false): array
+ {
+ $list = DynamicFields::getInstance()->all();
+ $out = [];
+
+ foreach ($list as $field) {
+ $key = $field->name;
+
+ if ($modified_values && $this->isModified($key)) {
+ $out[$key] = $this->getModifiedProperty($key);
+ }
+ else {
+ $out[$key] = $this->$key;
+ }
+
+ $out[$key] = $field->getStringValue($out[$key]);
+ }
+
+ return $out;
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ if (!count($this->_modified) && $this->exists()) {
+ return true;
+ }
+
+ $columns = array_intersect(DynamicFields::getInstance()->getSearchColumns(), array_keys($this->_modified));
+ $login_field = DynamicFields::getLoginField();
+ $login_modified = $this->_modified[$login_field] ?? null;
+ $password_modified = $this->_modified['password'] ?? null;
+
+ $this->set('date_updated', new \DateTime);
+
+ parent::save($selfcheck);
+
+ // We are not using a trigger as it would make modifying the users table from outside impossible
+ // (because the transliterate_to_ascii function does not exist)
+ if (count($columns)) {
+ DynamicFields::getInstance()->rebuildUserSearchCache($this->id());
+ }
+
+ if ($login_modified && $this->password) {
+ EmailTemplates::loginChanged($this);
+ Log::add(Log::LOGIN_CHANGE, null, $this->id());
+ }
+
+ if ($password_modified && $this->password && $this->id == Session::getUserId()) {
+ EmailTemplates::passwordChanged($this);
+ }
+
+ if ($password_modified) {
+ Log::add(Log::LOGIN_PASSWORD_CHANGE, null, $this->id());
+ Plugins::fire('user.change.password.after', false, ['user' => $this]);
+ }
+
+ if ($login_modified) {
+ Plugins::fire('user.change.login.after', false, ['user' => $this, 'old_login' => $login_modified]);
+ }
+
+ return true;
+ }
+
+ public function category(): Category
+ {
+ return Categories::get($this->id_category);
+ }
+
+ public function attachmentsDirectory(): string
+ {
+ return File::CONTEXT_USER . '/' . $this->id();
+ }
+
+ public function listFiles(string $field_name = null): array
+ {
+ return Files::listForUser($this->id, $field_name);
+ }
+
+ public function login(): ?string
+ {
+ $field = DynamicFields::getLoginField();
+ return (string)$this->$field ?: null;
+ }
+
+ public function number(): ?string
+ {
+ $field = DynamicFields::getNumberField();
+ return (string)$this->$field ?: null;
+ }
+
+ public function setNumberIfEmpty(): void
+ {
+ $field = DynamicFields::getNumberField();
+
+ if (!empty($this->$field)) {
+ return;
+ }
+
+ $db = DB::getInstance();
+ $new = $db->firstColumn(sprintf('SELECT MAX(%s) + 1 FROM %s WHERE %1$s IS NOT NULL;', $db->quoteIdentifier($field), User::TABLE));
+ $new = $new ?: $db->count(User::TABLE);
+ $this->set($field, (int)$new);
+ }
+
+ public function name(): string
+ {
+ $out = [];
+
+ foreach (DynamicFields::getNameFields() as $key) {
+ $out[] = $this->$key;
+ }
+
+ return implode(' ', $out);
+ }
+
+ public function email(): ?string
+ {
+ $field = DynamicFields::getFirstEmailField();
+ return (string)$this->$field ?: null;
+ }
+
+ public function importForm(array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ // Don't allow changing security credentials from form
+ unset($source['id_category'], $source['password'], $source['otp_secret'], $source['pgp_key']);
+
+ if (isset($source['id_parent']) && is_array($source['id_parent'])) {
+ $source['id_parent'] = Form::getSelectorValue($source['id_parent']);
+ }
+
+ foreach (DynamicFields::getInstance()->fieldsByType('multiple') as $f) {
+ if (!(isset($source[$f->name . '_present']) || isset($source[$f->name]))) {
+ continue;
+ }
+
+ if (isset($source[$f->name]) && is_string($source[$f->name])) {
+ $source[$f->name] = array_map('trim', explode(',', $source[$f->name]));
+ }
+
+ $options = isset($source[$f->name]) && is_array($source[$f->name]) ? $source[$f->name] : [];
+
+ $v = 0;
+
+ foreach ($f->options as $k => $label) {
+ if (in_array($label, $options, true)) {
+ $k = 0x01 << $k;
+ $v |= $k;
+ }
+ }
+
+ $source[$f->name] = $v ?: null;
+ }
+
+ // Handle unchecked checkbox in HTML form: no value returned
+ foreach (DynamicFields::getInstance()->fieldsByType('checkbox') as $f) {
+ if (!(isset($source[$f->name . '_present']) || isset($source[$f->name]))) {
+ continue;
+ }
+
+ $source[$f->name] = !empty($source[$f->name]);
+ }
+
+ foreach (DynamicFields::getInstance()->fieldsByType('country') as $f) {
+ if (!isset($source[$f->name])) {
+ continue;
+ }
+
+ if (strlen($source[$f->name]) !== 2) {
+ $source[$f->name] = Utils::getCountryCode($source[$f->name]);
+ }
+
+ $source[$f->name] = $source[$f->name] ?: null;
+ }
+
+ return parent::importForm($source);
+ }
+
+ public function importSecurityForm(bool $user_mode = true, array $source = null, Session $session = null)
+ {
+ $source ??= $_POST;
+
+ $allowed = ['password', 'password_check', 'password_confirmed', 'password_delete', 'otp_secret', 'otp_disable', 'pgp_key', 'otp_code'];
+ $source = array_intersect_key($source, array_flip($allowed));
+
+ $session = Session::getInstance();
+
+ if ($user_mode && !Session::getInstance()->checkPassword($source['password_check'] ?? null, $this->password)) {
+ $this->assert(
+ $session->checkPassword($source['password_check'] ?? null, $this->password),
+ 'Le mot de passe fourni ne correspond pas au mot de passe actuel. Merci de bien vouloir renseigner votre mot de passe courant pour confirmer les changements.'
+ );
+ }
+
+ if (!empty($source['password_delete'])) {
+ $source['password'] = null;
+ }
+ elseif (isset($source['password'])) {
+ $source['password'] = trim($source['password']);
+
+ // Maximum bcrypt password length
+ $this->assert(strlen($source['password']) <= 72, sprintf('Le mot de passe doit faire moins de %d caractères.', 72));
+ $this->assert(strlen($source['password']) >= self::MINIMUM_PASSWORD_LENGTH, sprintf('Le mot de passe doit faire au moins %d caractères.', self::MINIMUM_PASSWORD_LENGTH));
+ $this->assert(hash_equals($source['password'], trim($source['password_confirmed'] ?? '')), 'La vérification du mot de passe doit être identique au mot de passe.');
+ $this->assert(!$session->isPasswordCompromised($source['password']), 'Le mot de passe choisi figure dans une liste de mots de passe compromis (piratés), il ne peut donc être utilisé ici. Si vous l\'avez utilisé sur d\'autres sites il est recommandé de le changer sur ces autres sites également.');
+
+ $source['password'] = $session::hashPassword($source['password']);
+ }
+
+ if (!empty($source['otp_disable'])) {
+ $source['otp_secret'] = null;
+ }
+ elseif (isset($source['otp_secret'])) {
+ $this->assert(trim($source['otp_code'] ?? '') !== '', 'Le code TOTP doit être renseigné pour confirmer l\'opération');
+ $this->assert($session->checkOTP($source['otp_secret'], $source['otp_code']), 'Le code TOTP entré n\'est pas valide.');
+ }
+
+ if (!empty($source['pgp_key'])) {
+ $this->assert($session->getPGPFingerprint($source['pgp_key']), 'Clé PGP invalide : impossible de récupérer l\'empreinte de la clé.');
+ }
+
+ // Don't allow user to change password if the password field cannot be changed by user
+ if ($user_mode && !$this->canChangePassword($session)) {
+ unset($source['password']);
+ }
+
+ unset($source['password_confirmed'], $source['password_check']);
+
+ parent::importForm($source);
+ }
+
+ public function getEmails(): array
+ {
+ $out = [];
+
+ foreach (DynamicFields::getEmailFields() as $f) {
+ if (isset($this->$f) && trim($this->$f)) {
+ $out[] = strtolower($this->$f);
+ }
+ }
+
+ return $out;
+ }
+
+ public function canEmail(): bool
+ {
+ return count($this->getEmails()) > 0;
+ }
+
+ public function getNameAndEmail(): string
+ {
+ $email_field = DynamicFields::getFirstEmailField();
+
+ return sprintf('"%s" <%s>', $this->name(), $this->{$email_field});
+ }
+
+ public function isChild(): bool
+ {
+ return (bool) $this->id_parent;
+ }
+
+ public function getParentName(): ?string
+ {
+ if (!$this->isChild()) {
+ return null;
+ }
+
+ return Users::getName($this->id_parent);
+ }
+
+ public function getParentSelector(): ?array
+ {
+ if (!$this->isChild()) {
+ return null;
+ }
+
+ return [$this->id_parent => $this->getParentName()];
+ }
+
+ public function hasChildren(): bool
+ {
+ return DB::getInstance()->test(self::TABLE, 'id_parent = ?', $this->id());
+ }
+
+ public function listChildren(): array
+ {
+ $name = DynamicFields::getNameFieldsSQL();
+ return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ?;', $name, self::TABLE), $this->id());
+ }
+
+ public function listSiblings(): array
+ {
+ if (!$this->id_parent) {
+ return [];
+ }
+
+ $name = DynamicFields::getNameFieldsSQL();
+ return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ? AND id != ?;', $name, self::TABLE), $this->id_parent, $this->id());
+ }
+
+ public function sendMessage(string $subject, string $message, bool $send_copy, ?User $from = null)
+ {
+ $config = Config::getInstance();
+ $email_field = DynamicFields::getFirstEmailField();
+
+ $from = $from ? $from->getNameAndEmail() : null;
+
+ Emails::queue(Emails::CONTEXT_PRIVATE, [$this->{$email_field} => ['pgp_key' => $this->pgp_key]], $from, $subject, $message);
+
+ if ($send_copy) {
+ Emails::queue(Emails::CONTEXT_PRIVATE, [$config->org_email], null, $subject, $message);
+ }
+ }
+
+ public function checkLoginFieldForUserEdit()
+ {
+ $session = Session::getInstance();
+
+ if (!$session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) {
+ return;
+ }
+
+ $field = DynamicFields::getLoginField();
+
+ if (!$this->isModified($field)) {
+ return;
+ }
+
+ if (!isset($this->$field) || trim($this->$field) !== '') {
+ return;
+ }
+
+ throw new UserException("Le champ identifiant ne peut être laissé vide pour un administrateur, sinon vous ne pourriez plus vous connecter.");
+ }
+
+ public function canChangePassword(Session $session): bool
+ {
+ if ($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)) {
+ return true;
+ }
+
+ $password_field = current(DynamicFields::getInstance()->fieldsBySystemUse('password'));
+ return $password_field->user_access_level === Session::ACCESS_WRITE;
+ }
+
+ public function checkDuplicate(): ?int
+ {
+ $id_field = DynamicFields::getNameFieldsSQL();
+ $db = DB::getInstance();
+ return $db->firstColumn(sprintf('SELECT id FROM %s WHERE %s = ?;', self::TABLE, $id_field), $this->name()) ?: null;
+ }
+
+ public function getPreference(string $key)
+ {
+ return $this->preferences->{$key} ?? null;
+ }
+
+ public function setPreference(string $key, $value): void
+ {
+ if (isset($this->$key)) {
+ settype($value, gettype(self::PREFERENCES[$key]));
+ }
+
+ if (null === $this->preferences) {
+ $this->preferences = new \stdClass;
+ }
+
+ $this->preferences->{$key} = $value;
+ $this->_modified['preferences'] = null;
+ }
+
+ public function deletePreference(string $key): void
+ {
+ if (null === $this->preferences || !isset($this->preferences->{$key})) {
+ return;
+ }
+
+ unset($this->preferences->{$key});
+ $this->_modified['preferences'] = null;
+ }
+
+ /**
+ * Save preferences if they have been modified
+ */
+ public function __destruct()
+ {
+ // We can't save preferences if user does not exist (eg. LDAP/Forced Login via LOCAL_LOGIN)
+ if (!$this->exists()) {
+ return;
+ }
+
+ // Nothing to save
+ if (!$this->isModified('preferences')) {
+ return;
+ }
+
+
+ DB::getInstance()->update(self::TABLE, ['preferences' => json_encode($this->preferences)], 'id = ' . $this->id());
+ $this->clearModifiedProperties(['preferences']);
+ }
+
+ public function url(): string
+ {
+ return Utils::getLocalURL(sprintf(self::PRIVATE_URL, $this->id));
+ }
+
+ public function diff(): array
+ {
+ $out = [];
+
+ foreach ($this->_modified as $key => $old) {
+ $out[$key] = [$old, $this->$key];
+ }
+
+ return $out;
+ }
+
+ public function downloadExport(): void
+ {
+ $services_list = Services_User::perUserList($this->id);
+ $services_list->setPageSize(null);
+
+ $export_data = [
+ 'user' => $this,
+ 'services' => $services_list->asArray(true),
+ ];
+
+ $tpl = Template::getInstance();
+ $tpl->assign('user', $this);
+ $tpl->assign(compact('services_list'));
+ $html = $tpl->fetch('me/export.tpl');
+
+ $name = sprintf('%s - Donnees - %s.zip', Config::getInstance()->get('org_name'), $this->name());
+ header('Content-type: application/zip');
+ header(sprintf('Content-Disposition: attachment; filename="%s"', $name));
+
+ $zip = new ZipWriter('php://output');
+ $zip->setCompression(9);
+
+ $zip->add('info.html', $html);
+ $zip->add('info.json', json_encode($export_data));
+
+ foreach ($this->listFiles() as $file) {
+ $pointer = $file->getReadOnlyPointer();
+ $path = !$pointer ? $file->getLocalFilePath() : null;
+ $zip->add($file->path, null, $path, $pointer);
+
+ if ($pointer) {
+ fclose($pointer);
+ }
+ }
+
+ $zip->close();
+ }
+}
diff --git a/src/include/lib/Paheko/Entities/Web/Page.php b/src/include/lib/Paheko/Entities/Web/Page.php
new file mode 100644
index 0000000..e0d0524
--- /dev/null
+++ b/src/include/lib/Paheko/Entities/Web/Page.php
@@ -0,0 +1,528 @@
+ 'MarkDown',
+ Render::FORMAT_ENCRYPTED => 'Chiffré',
+ Render::FORMAT_SKRIV => 'SkrivML',
+ ];
+
+ const STATUS_ONLINE = 'online';
+ const STATUS_DRAFT = 'draft';
+
+ const STATUS_LIST = [
+ self::STATUS_ONLINE => 'En ligne',
+ self::STATUS_DRAFT => 'Brouillon',
+ ];
+
+ const TYPE_CATEGORY = 1;
+ const TYPE_PAGE = 2;
+
+ const TYPES = [
+ self::TYPE_CATEGORY => 'Category',
+ self::TYPE_PAGE => 'Page',
+ ];
+
+ const TEMPLATES = [
+ self::TYPE_PAGE => 'article.html',
+ self::TYPE_CATEGORY => 'category.html',
+ ];
+
+ const DUPLICATE_URI_ERROR = 42;
+
+ protected ?File $_dir = null;
+ protected ?array $_attachments = null;
+ protected ?array $_tagged_attachments = null;
+ protected ?string $_html = null;
+
+ static public function create(int $type, ?int $id_parent, string $title, string $status = self::STATUS_ONLINE): self
+ {
+ $page = new self;
+ $data = compact('type', 'id_parent', 'title', 'status');
+ $data['content'] = '';
+
+ $page->importForm($data);
+ $page->published = new \DateTime;
+ $page->modified = new \DateTime;
+ $page->type = $type;
+
+ $db = DB::getInstance();
+ if ($db->test(self::TABLE, 'uri = ?', $page->uri)) {
+ $page->importForm(['uri' => $page->uri . date('-Y-m-d-His')]);
+ }
+
+ return $page;
+ }
+
+ public function dir(bool $force_reload = false): File
+ {
+ if (null === $this->_dir || $force_reload) {
+ $this->_dir = Files::get(File::CONTEXT_WEB . '/' . $this->uri);
+
+ if (null === $this->_dir) {
+ $this->_dir = Files::mkdir(File::CONTEXT_WEB . '/' . $this->uri);
+ }
+ }
+
+ return $this->_dir;
+ }
+
+ public function url(): string
+ {
+ return WWW_URL . $this->uri;
+ }
+
+ public function template(): string
+ {
+ return self::TEMPLATES[$this->type];
+ }
+
+ public function asTemplateArray(): array
+ {
+ $out = $this->asArray();
+ $out['path'] = $this->path();
+ $out['parent'] = Utils::dirname($out['path']);
+ $out['url'] = $this->url();
+ $out['html'] = trim($this->content) !== '' ? $this->render() : '';
+ $row['has_attachments'] = $this->hasAttachments();
+ return $out;
+ }
+
+ public function render(bool $admin = false): string
+ {
+ $user_prefix = $admin ? ADMIN_URL . 'web/?uri=' : null;
+
+ $this->_html ??= Render::render($this->format, $this->dir_path(), $this->content, $user_prefix);
+
+ return $this->_html;
+ }
+
+ public function excerpt(int $length = 500): string
+ {
+ return $this->preview(Modifiers::truncate($this->content, $length));
+ }
+
+ public function requiresExcerpt(int $length = 500): bool
+ {
+ return mb_strlen($this->content) > $length;
+ }
+
+ public function preview(string $content): string
+ {
+ $user_prefix = ADMIN_URL . 'web/?uri=';
+ return Render::render($this->format, $this->dir_path(), $content, $user_prefix);
+ }
+
+ public function dir_path(): string
+ {
+ return File::CONTEXT_WEB . '/' . $this->uri;
+ }
+
+ /**
+ * Return page path according to parents, eg. category_uri/sub_category_uri/page_uri
+ */
+ public function path(): string
+ {
+ if (!isset($this->_path)) {
+ $sql = sprintf('SELECT GROUP_CONCAT(uri, \'/\') FROM (%s);',
+ rtrim(sprintf(Web::BREADCRUMBS_SQL, $this->id()), '; ')
+ );
+
+ $this->_path = DB::getInstance()->firstColumn($sql);
+ }
+
+ return $this->_path;
+ }
+
+ public function listVersions(): DynamicList
+ {
+ $name_field = DynamicFields::getNameFieldsSQL('u');
+
+ $columns = [
+ 'id' => ['select' => 'v.id'],
+ 'id_user' => ['select' => 'v.id_user'],
+ 'date' => [
+ 'select' => 'v.date',
+ 'label' => 'Date',
+ ],
+ 'author' => [
+ 'label' => 'Auteur',
+ 'select' => $name_field,
+ ],
+ 'size' => [
+ 'label' => 'Longueur du texte',
+ 'select' => 'v.size',
+ ],
+ 'changes' => [
+ 'label' => 'Évolution',
+ 'select' => 'v.changes',
+ ],
+ ];
+
+ $tables = 'web_pages_versions v
+ LEFT JOIN users u ON u.id = v.id_user';
+ $conditions = sprintf('v.id_page = %d', $this->id());
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('id', true);
+
+ return $list;
+ }
+
+ public function getVersion(int $id): ?\stdClass
+ {
+ return DB::getInstance()->first('SELECT a.*,
+ IFNULL((SELECT content FROM web_pages_versions WHERE id_page = a.id_page AND id < a.id ORDER BY id DESC LIMIT 1), \'\') AS previous_content
+ FROM web_pages_versions a
+ WHERE a.id_page = ? AND a.id = ?;', $this->id(), $id) ?: null;
+ }
+
+ public function syncSearch(): void
+ {
+ if ($this->format == Render::FORMAT_ENCRYPTED) {
+ $content = null;
+ }
+ else {
+ $content = $this->render();
+ }
+
+ $this->dir()->indexForSearch(compact('content'), $this->title, 'text/html');
+ }
+
+ public function saveNewVersion(?int $user_id): bool
+ {
+ $content_modified = $this->isModified('content');
+ $prev_content = $this->getModifiedProperty('content');
+
+ $r = $this->save();
+
+ if ($content_modified) {
+ $db = DB::getInstance();
+ $l = mb_strlen($this->content);
+
+ $version = [
+ 'id_user' => $user_id,
+ 'id_page' => $this->id(),
+ 'date' => new \DateTime,
+ 'content' => $this->content,
+ 'size' => $l,
+ 'changes' => $l - mb_strlen($prev_content),
+ ];
+
+ $db->insert('web_pages_versions', $version);
+ $version['id'] = $db->lastInsertId();
+
+ Plugins::fire('web.page.version.new', false, [
+ 'entity' => $this,
+ 'content' => $this->content,
+ 'old_content' => $prev_content,
+ 'version' => (object) $version,
+ ]);
+ }
+
+ return $r;
+ }
+
+ public function save(bool $selfcheck = true): bool
+ {
+ $dir = null;
+
+ if ($this->isModified('uri')) {
+ $dir = Files::get(File::CONTEXT_WEB . '/' . $this->getModifiedProperty('uri'));
+ }
+
+ // Update modified date if required
+ if (count($this->_modified) && !isset($this->_modified['modified'])) {
+ $this->set('modified', new \DateTime);
+ }
+
+ $update_search = $this->isModified('content') || $this->isModified('format');
+
+ parent::save($selfcheck);
+
+ if ($dir) {
+ $dir->rename($this->dir_path());
+ }
+
+ if ($update_search) {
+ $this->syncSearch();
+ }
+
+ Cache::clear();
+
+ return true;
+ }
+
+ public function delete(): bool
+ {
+ $dir = $this->dir();
+
+ $r = parent::delete();
+
+ if ($r && $dir) {
+ $dir->delete();
+ }
+
+ Cache::clear();
+ return $r;
+ }
+
+ public function selfCheck(): void
+ {
+ $db = DB::getInstance();
+ $this->assert($this->type === self::TYPE_CATEGORY || $this->type === self::TYPE_PAGE, 'Unknown page type');
+ $this->assert(array_key_exists($this->status, self::STATUS_LIST), 'Unknown page status');
+ $this->assert(array_key_exists($this->format, self::FORMATS_LIST), 'Unknown page format');
+ $this->assert(trim($this->title) !== '', 'Le titre ne peut rester vide');
+ $this->assert(mb_strlen($this->title) <= 200, 'Le titre ne peut faire plus de 200 caractères');
+ $this->assert(trim($this->uri) !== '', 'L\'URI ne peut rester vide');
+ $this->assert(strlen($this->uri) <= 150, 'L\'URI ne peut faire plus de 150 caractères');
+
+ if ($this->exists()) {
+ $this->assert($this->id_parent !== $this->id(), 'Invalid parent page');
+ }
+
+ $this->assert(!$this->exists() || !$db->test(self::TABLE, 'uri = ? AND id != ?', $this->uri, $this->id()), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->uri, self::DUPLICATE_URI_ERROR);
+ $this->assert($this->exists() || !$db->test(self::TABLE, 'uri = ?', $this->uri), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->uri, self::DUPLICATE_URI_ERROR);
+ }
+
+ public function importForm(array $source = null)
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ if (isset($source['date']) && isset($source['date_time'])) {
+ $source['published'] = $source['date'] . ' ' . $source['date_time'];
+ }
+
+ if (isset($source['title']) && !$this->exists()) {
+ $source['uri'] = $source['title'];
+ }
+
+ if (isset($source['uri'])) {
+ $source['uri'] = Utils::transformTitleToURI($source['uri']);
+
+ if (!$this->exists()) {
+ $source['uri'] = strtolower($source['uri']);
+ }
+ }
+
+ $uri = $source['uri'] ?? ($this->uri ?? null);
+
+ if (array_key_exists('id_parent', $source) && is_array($source['id_parent'])) {
+ $source['id_parent'] = Form::getSelectorValue($source['id_parent']) ?: null;
+ }
+
+ if (!empty($source['encryption']) ) {
+ $this->set('format', Render::FORMAT_ENCRYPTED);
+ }
+ elseif (empty($source['format'])) {
+ $this->set('format', Render::FORMAT_MARKDOWN);
+ }
+
+ $this->set('status', empty($source['status']) ? self::STATUS_ONLINE : $source['status']);
+
+ return parent::importForm($source);
+ }
+
+ public function getBreadcrumbs(): array
+ {
+ return Web::getBreadcrumbs($this->id());
+ }
+
+ public function listAttachments(): array
+ {
+ if (null === $this->_attachments) {
+ $list = Files::list($this->dir_path());
+
+ // Remove sub-directories
+ $list = array_filter($list, fn ($a) => $a->type != $a::TYPE_DIRECTORY);
+
+ $this->_attachments = $list;
+ }
+
+ return $this->_attachments;
+ }
+
+ /**
+ * List attachments that are cited in the text content
+ */
+ public function listTaggedAttachments(): array
+ {
+ if (null === $this->_tagged_attachments) {
+ $this->render();
+ $this->_tagged_attachments = Render::listAttachments($this->dir_path());
+ }
+
+ return $this->_tagged_attachments;
+ }
+
+ /**
+ * List attachments that are *NOT* cited in the text content
+ */
+ public function listOrphanAttachments(): array
+ {
+ $used = $this->listTaggedAttachments();
+ $orphans = [];
+
+ foreach ($this->listAttachments() as $file) {
+ if (!in_array($file->uri(), $used)) {
+ $orphans[] = $file->uri();
+ }
+ }
+
+ return $orphans;
+ }
+
+ public function hasAttachments(): bool
+ {
+ foreach ($this->listAttachments() as $attachment) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return list of images
+ * If $all is FALSE then this will only return images that are not present in the content
+ */
+ public function getImageGallery(bool $all = true): array
+ {
+ return $this->getAttachmentsGallery($all, true);
+ }
+
+ /**
+ * Return list of files
+ * If $all is FALSE then this will only return files that are not present in the content
+ */
+ public function getAttachmentsGallery(bool $all = true, bool $images = false): array
+ {
+ $out = [];
+ $tagged = [];
+
+ if (!$all) {
+ $tagged = $this->listTaggedAttachments();
+ }
+
+ foreach ($this->listAttachments() as $a) {
+ if ($images && !$a->isImage()) {
+ continue;
+ }
+ elseif (!$images && $a->isImage()) {
+ continue;
+ }
+
+ // Skip
+ if (!$all && in_array($a->name, $tagged)) {
+ continue;
+ }
+
+ $out[] = $a;
+ }
+
+ return $out;
+ }
+
+ /**
+ * Return list of internal links in page that link to non-existing pages
+ */
+ public function checkInternalPagesLinks(?array &$pages = null): array
+ {
+ if ($this->format == Render::FORMAT_ENCRYPTED) {
+ return [];
+ }
+
+ $renderer = Render::getRenderer($this->format, $this->dir_path());
+ $renderer->render($this->content);
+ $errors = [];
+
+ foreach ($renderer->listLinks() as $link) {
+ if ($link['type'] !== 'page') {
+ continue;
+ }
+
+ $uri = strtok($link['uri'], '#');
+ strtok('');
+
+ if (null !== $pages && !array_key_exists($uri, $pages)) {
+ $errors[$uri] = $link['label'];
+ }
+ elseif (null === $pages && !Web::getByURI($uri)) {
+ $errors[$uri] = $link['label'];
+ }
+ }
+
+ return $errors;
+ }
+
+ public function hasSubPages(): bool
+ {
+ return DB::getInstance()->test('web_pages', 'id_parent = ?', $this->id());
+ }
+
+ public function toggleType(): void
+ {
+ $has_sub_pages = $this->hasSubPages();
+
+ if ($has_sub_pages) {
+ $this->set('type', self::TYPE_CATEGORY);
+ }
+ elseif ($this->type == self::TYPE_CATEGORY) {
+ $this->set('type', self::TYPE_PAGE);
+ }
+ else {
+ $this->set('type', self::TYPE_CATEGORY);
+ }
+ }
+
+ public function isCategory(): bool
+ {
+ return $this->type == self::TYPE_CATEGORY;
+ }
+
+ public function isOnline(): bool
+ {
+ return $this->status == self::STATUS_ONLINE;
+ }
+}
diff --git a/src/include/lib/Paheko/Entity.php b/src/include/lib/Paheko/Entity.php
new file mode 100644
index 0000000..023582a
--- /dev/null
+++ b/src/include/lib/Paheko/Entity.php
@@ -0,0 +1,232 @@
+import($source);
+ }
+ catch (\UnexpectedValueException $e) {
+ throw new ValidationException($e->getMessage(), 0, $e);
+ }
+ }
+
+ static public function filterUserDateValue(?string $value): ?\DateTime
+ {
+ $value = trim((string) $value);
+
+ if (!$value) {
+ return null;
+ }
+
+ if (preg_match('!^20\d{2}[01]\d[0123]\d$!', $value)) {
+ return Date::createFromFormat('!Ymd', $value);
+ }
+ elseif (ctype_digit($value)) {
+ return new DateTime('@' . $value);
+ }
+ elseif ($v = \DateTime::createFromFormat('!Y-m-d H:i:s', $value)) {
+ return $v;
+ }
+ elseif ($v = \DateTime::createFromFormat('!Y-m-d H:i', $value)) {
+ return $v;
+ }
+ elseif ($v = Date::createFromFormat('!Y-m-d', $value)) {
+ return $v;
+ }
+ elseif (preg_match('!^\d{2}/\d{2}/\d{4}\s\d{2}:\d{2}!', $value)) {
+ return \DateTime::createFromFormat('!d/m/Y H:i', substr($value, 0, 16));
+ }
+ elseif (preg_match('!^\d{2}/\d{2}/\d{2}$!', $value)) {
+ $year = substr($value, -2);
+
+ // Make sure recent years are in the 21st century
+ if ($year < date('y') + 10) {
+ $year = sprintf('20%02d', $year);
+ }
+ // while old dates remain in the old one
+ else {
+ $year = sprintf('19%02d', $year);
+ }
+
+ return Date::createFromFormat('d/m/Y', substr($value, 0, -2) . $year);
+ }
+ elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $value)) {
+ return Date::createFromFormat('!d/m/Y', $value);
+ }
+ elseif (preg_match('!^\d{4}/\d{2}/\d{2}$!', $value)) {
+ return Date::createFromFormat('!Y/m/d', $value);
+ }
+ elseif (null !== $value) {
+ throw new ValidationException('Format de date invalide (merci d\'utiliser le format JJ/MM/AAAA) : ' . $value);
+ }
+
+ return null;
+ }
+
+ protected function filterUserValue(string $type, $value, string $key)
+ {
+ if ($type == 'date' || $type == Date::class) {
+ if ($value instanceof Date) {
+ return $value;
+ }
+ elseif ($value instanceof \DateTimeInterface) {
+ return Date::createFromInterface($value);
+ }
+
+ $d = self::filterUserDateValue($value);
+
+ if (!$d) {
+ return $d;
+ }
+
+ $y = $d->format('Y');
+ if ($y < 1900 || $y > 2100) {
+ throw new ValidationException(sprintf('Date invalide (%s) : doit être entre 1900 et 2100', $key));
+ }
+
+ return $d;
+
+ }
+ elseif (($type == 'DateTime' || $type === 'DateTimeInterface') && is_string($value)) {
+ $d = self::filterUserDateValue($value);
+ return $d;
+ }
+
+ return parent::filterUserValue($type, $value, $key);
+ }
+
+ protected function assert($test, string $message = null, int $code = 0): void
+ {
+ if ($test) {
+ return;
+ }
+
+ if (null === $message) {
+ $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
+ $caller_class = array_pop($backtrace);
+ $caller = array_pop($backtrace);
+ $message = sprintf('Entity assertion fail from class %s on line %d', $caller_class['class'], $caller['line']);
+ throw new \UnexpectedValueException($message);
+ }
+ else {
+ throw new ValidationException($message, $code);
+ }
+ }
+
+ // Add plugin signals to save/delete
+ public function save(bool $selfcheck = true): bool
+ {
+ $name = get_class($this);
+ $name = str_replace('Paheko\Entities\\', '', $name);
+
+ // We are doing selfcheck here before sending the before event
+ if ($selfcheck) {
+ $this->selfCheck();
+ }
+
+ $new = $this->exists() ? false : true;
+ $modified = $this->isModified();
+ $entity = $this;
+ $params = compact('entity', 'new', 'modified');
+
+ $signals = [
+ // Specific entity signal
+ 'entity.' . $name . '.save',
+ // Generic entity signal
+ 'entity.save',
+ ];
+
+ if ($new) {
+ $signals[] = 'entity.' . $name . '.create';
+ $signals[] = 'entity.create';
+ }
+ elseif ($modified) {
+ $signals[] = 'entity.' . $name . '.modify';
+ $signals[] = 'entity.modify';
+ $params['modified_properties'] = $this->getModifiedProperties();
+ }
+
+ foreach ($signals as $signal_name) {
+ $signal = Plugins::fire($signal_name . '.before', true, $params);
+
+ if ($signal && $signal->isStopped()) {
+ return true;
+ }
+ }
+
+ $params['success'] = parent::save(false);
+
+ // Log creation/edit, but don't record stuff that doesn't change anything
+ if ($this::NAME && ($new || $modified)) {
+ Log::add($new ? Log::CREATE : Log::EDIT, ['entity' => get_class($this), 'id' => $this->id()]);
+ }
+
+ foreach ($signals as $signal_name) {
+ Plugins::fire($signal_name . '.after', false, $params);
+ }
+
+ return $params['success'];
+ }
+
+ public function delete(): bool
+ {
+ $type = get_class($this);
+ $type = str_replace('Paheko\Entities\\', '', $type);
+ $name = 'entity.' . $type . '.delete';
+
+ $id = $this->id();
+ $entity = $this;
+
+ // Specific entity signal
+ $signal = Plugins::fire($name . '.before', true, compact('entity', 'id'));
+
+ if ($signal && $signal->isStopped()) {
+ return true;
+ }
+
+ // Generic entity signal
+ $signal = Plugins::fire('entity.delete.before', true, compact('entity', 'id'));
+
+ if ($signal && $signal->isStopped()) {
+ return true;
+ }
+
+ $success = parent::delete();
+
+ if ($this::NAME) {
+ Log::add(Log::DELETE, ['entity' => get_class($this), 'id' => $id]);
+ }
+
+ Plugins::fire($name . '.after', false, compact('entity', 'success', 'id'));
+ Plugins::fire('entity.delete.after', false, compact('entity', 'success', 'id'));
+
+ return $success;
+ }
+}
diff --git a/src/include/lib/Paheko/Extensions.php b/src/include/lib/Paheko/Extensions.php
new file mode 100644
index 0000000..e200c86
--- /dev/null
+++ b/src/include/lib/Paheko/Extensions.php
@@ -0,0 +1,256 @@
+firstColumn('
+ SELECT 1 FROM modules WHERE enabled = 1 AND web = 0
+ UNION ALL
+ SELECT 1 FROM plugins WHERE enabled = 1 AND name != \'welcome\';');
+ }
+
+ static public function listAvailableButtons(): array
+ {
+ $list = self::listDisabled();
+
+ // Sort items by label
+ uasort($list, fn ($a, $b) => strnatcasecmp($a->label, $b->label));
+
+ foreach ($list as &$item) {
+ $url = sprintf('%s/%s/', $item->type == 'plugin' ? ADMIN_URL . 'p' : BASE_URL . 'm', $item->name);
+ $item = CommonFunctions::linkButton([
+ 'label' => $item->label,
+ 'icon' => $url . 'icon.svg',
+ 'href' => '!config/ext/?install=1&focus=' . $item->name,
+ ]);
+ }
+
+ return $list;
+ }
+
+ static public function get(string $type, string $name)
+ {
+ if ($type === 'module') {
+ return Modules::get($name);
+ }
+ elseif ($type === 'plugin') {
+ $ext = Plugins::get($name);
+ $ext ??= Plugins::getInstallable($name);
+ return $ext;
+ }
+ else {
+ throw new \InvalidArgumentException('Invalid type: ' . $type);
+ }
+ }
+
+ static public function toggle(string $type, string $name, bool $enabled)
+ {
+ if ($type === 'plugin') {
+ $plugin = Plugins::get($name);
+
+ if (!$plugin && $enabled) {
+ $plugin = Plugins::install($name);
+ }
+ elseif ($plugin) {
+ $plugin->set('enabled', $enabled);
+ $plugin->save();
+ }
+ else {
+ return null;
+ }
+
+ return $plugin;
+ }
+ elseif ($type === 'module') {
+ $m = Modules::get($name);
+
+ if (!$m) {
+ return null;
+ }
+
+ $m->set('enabled', $enabled);
+ $m->save();
+
+ return $m;
+ }
+ else {
+ throw new \InvalidArgumentException('Invalid type: ' . $type);
+ }
+ }
+
+ static public function normalize($item): \stdClass
+ {
+ $type = $item instanceof Plugin ? 'plugin' : 'module';
+ $c = $item;
+ $item = (object) $c->asArray();
+ $item->$type = $c;
+ $item->type = $type;
+ $item->label = $c->label ?? $c->name;
+ $item->icon_url = $c->icon_url();
+ $item->details_url = sprintf('details.php?type=%s&name=%s', $type, $c->name);
+ $item->config_url = $c->hasConfig() ? $c->url($c::CONFIG_FILE) : null;
+ $item->installed = $type == 'plugin' ? $c->exists() : true;
+ $item->missing = $type == 'plugin' ? !$c->hasCode() : false;
+ $item->broken_message = $type == 'plugin' ? $c->getBrokenMessage() : false;
+ $item->readme = $c->hasFile($c::README_FILE);
+
+ $item->url = null;
+
+ if ($c->hasFile($c::INDEX_FILE)) {
+ $item->url = $c->url($type == 'plugin' ? 'admin/' : '');
+ }
+
+ return $item;
+ }
+
+ static protected function filterList(array &$list): void
+ {
+ foreach ($list as &$item) {
+ $item = self::normalize($item);
+ }
+
+ unset($item);
+
+ usort($list, fn ($a, $b) => strnatcasecmp($a->label ?? $a->name, $b->label ?? $b->name));
+
+ array_walk($list, fn(&$a) => $a = (object) $a);
+ }
+
+ static public function listDisabled(): array
+ {
+ $list = [];
+
+ foreach (EM::getInstance(Module::class)->iterate('SELECT * FROM @TABLE WHERE enabled = 0;') as $m) {
+ $list[$m->name] = $m;
+ }
+
+ foreach (Plugins::listInstallable() as $name => $p) {
+ $list[$name] = $p;
+ }
+
+ foreach (Plugins::listInstalled() as $p) {
+ if ($p->enabled) {
+ continue;
+ }
+
+ $list[$p->name] = $p;
+ }
+
+ self::filterList($list);
+ return $list;
+ }
+
+ static public function listEnabled(): array
+ {
+ $list = [];
+
+ foreach (EM::getInstance(Module::class)->iterate('SELECT * FROM @TABLE WHERE enabled = 1;') as $m) {
+ $list[$m->name] = $m;
+ }
+
+ foreach (Plugins::listInstalled() as $p) {
+ if (!$p->enabled) {
+ continue;
+ }
+
+ if (!$p->hasCode()) {
+ $p->set('enabled', false);
+ $p->save();
+ continue;
+ }
+
+ $list[$p->name] = $p;
+ }
+
+ self::filterList($list);
+ return $list;
+ }
+
+ static public function listMenu(Session $session): array
+ {
+ $list = [];
+
+ $sql = 'SELECT \'module\' AS type, name, label, restrict_section, restrict_level FROM modules WHERE menu = 1 AND enabled = 1
+ UNION ALL
+ SELECT \'plugin\' AS type, name, label, restrict_section, restrict_level FROM plugins WHERE menu = 1 AND enabled = 1;';
+
+ foreach (DB::getInstance()->get($sql) as $item) {
+ if ($item->restrict_section && !$session->canAccess($item->restrict_section, $item->restrict_level)) {
+ continue;
+ }
+
+ $list[$item->type . '_' . $item->name] = $item;
+ }
+
+ // Sort items by label
+ uasort($list, fn ($a, $b) => strnatcasecmp($a->label, $b->label));
+
+ foreach ($list as &$item) {
+ $item = sprintf('%s ',
+ $item->type == 'plugin' ? ADMIN_URL . 'p' : BASE_URL . 'm',
+ $item->name,
+ $item->label
+ );
+ }
+
+ unset($item);
+
+ // Append plugins from signals
+ $signal = Plugins::fire('menu.item', false, compact('session'), $list);
+
+ return $signal ? $signal->getOut() : $list;
+ }
+
+ static public function listHomeButtons(Session $session): array
+ {
+ $list = [];
+
+ $sql = 'SELECT \'module\' AS type, name, label, restrict_section, restrict_level FROM modules WHERE home_button = 1 AND enabled = 1
+ UNION ALL
+ SELECT \'plugin\' AS type, name, label, restrict_section, restrict_level FROM plugins WHERE home_button = 1 AND enabled = 1;';
+
+ foreach (DB::getInstance()->get($sql) as $item) {
+ if ($item->restrict_section && !$session->canAccess($item->restrict_section, $item->restrict_level)) {
+ continue;
+ }
+
+ $list[$item->type . '_' . $item->name] = $item;
+ }
+
+ // Sort items by label
+ uasort($list, fn ($a, $b) => strnatcasecmp($a->label, $b->label));
+
+ foreach ($list as &$item) {
+ $url = sprintf('%s/%s/', $item->type == 'plugin' ? ADMIN_URL . 'p' : BASE_URL . 'm', $item->name);
+ $item = CommonFunctions::linkButton([
+ 'label' => $item->label,
+ 'icon' => $url . 'icon.svg',
+ 'href' => $url,
+ ]);
+ }
+
+ unset($item);
+
+ foreach (Modules::snippets(Modules::SNIPPET_HOME_BUTTON) as $name => $v) {
+ $list['module_' . $name] = $v;
+ }
+
+ $signal = Plugins::fire('home.button', false, ['user' => $session->getUser(), 'session' => $session], $list);
+
+ return $signal ? $signal->getOut() : $list;
+ }
+
+}
diff --git a/src/include/lib/Paheko/Files/Files.php b/src/include/lib/Paheko/Files/Files.php
new file mode 100644
index 0000000..b94eb35
--- /dev/null
+++ b/src/include/lib/Paheko/Files/Files.php
@@ -0,0 +1,1111 @@
+ File::CONTEXT_USER . '/' . $s::getUserId() . '/',
+ 'Documents de l\'association' => File::CONTEXT_DOCUMENTS,
+ 'Fichiers des membres' => File::CONTEXT_USER . '//',
+ 'Fichiers des écritures comptables' => File::CONTEXT_TRANSACTION . '//',
+ 'Fichiers du site web (contenu des pages, images, etc.)' => File::CONTEXT_WEB . '//',
+ 'Fichiers de la configuration (logo, etc.)' => File::CONTEXT_CONFIG,
+ 'Code des modules' => File::CONTEXT_MODULES,
+ ];
+
+ $out = [];
+
+ foreach ($contexts as $name => $path) {
+ $out[$name] = $perm[$path] ?? null;
+ }
+
+ return $out;
+ }
+
+ /**
+ * Returns an array of all file permissions for a given user
+ */
+ static public function buildUserPermissions(Session $s): array
+ {
+ $is_admin = $s->canAccess($s::SECTION_CONFIG, $s::ACCESS_ADMIN);
+
+ $p = [];
+
+ // Note USER context files are managed in Session::checkFilePermission
+
+ $p[File::CONTEXT_CONFIG] = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'create' => false,
+ 'read' => $s->isLogged(), // All config files can be accessed by all logged-in users
+ 'write' => $is_admin,
+ 'delete' => false,
+ 'share' => false,
+ ];
+
+ // Modules source code
+ $p[File::CONTEXT_MODULES . '/'] = [
+ 'mkdir' => $is_admin,
+ 'move' => $is_admin,
+ 'create' => $is_admin,
+ 'read' => $s->isLogged(),
+ 'write' => $is_admin,
+ 'delete' => $is_admin,
+ 'share' => false,
+ ];
+
+ // Modules source code
+ $p[File::CONTEXT_MODULES] = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'create' => false,
+ 'read' => $s->isLogged(),
+ 'write' => false,
+ 'delete' => false,
+ 'share' => false,
+ ];
+
+ $p[File::CONTEXT_WEB . '//'] = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'create' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE),
+ 'read' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_READ),
+ 'write' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE),
+ 'delete' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE),
+ 'share' => false,
+ ];
+
+ // At root level of web you can only create new articles
+ $p[File::CONTEXT_WEB] = [
+ 'mkdir' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE),
+ 'move' => false,
+ 'create' => false,
+ 'read' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_READ),
+ 'write' => false,
+ 'delete' => false,
+ 'share' => false,
+ ];
+
+ // Documents: you can do everything as long as you have access
+ $p[File::CONTEXT_DOCUMENTS . '/'] = [
+ 'mkdir' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ 'move' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ 'create' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ 'read' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_READ),
+ 'write' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ 'delete' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_ADMIN),
+ 'share' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ ];
+
+ // The root directory cannot be deleted or renamed/moved
+ $p[File::CONTEXT_DOCUMENTS] = [
+ 'mkdir' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ 'move' => false,
+ 'create' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ 'read' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_READ),
+ 'write' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ 'delete' => false,
+ 'share' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
+ ];
+
+ // You can write in transaction subdirectories
+ $p[File::CONTEXT_TRANSACTION . '//'] = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'create' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE),
+ 'read' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_READ),
+ 'write' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE),
+ 'delete' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_ADMIN),
+ 'share' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE),
+ ];
+
+ // But not in root
+ $p[File::CONTEXT_TRANSACTION] = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'write' => false,
+ 'create' => false,
+ 'delete' => false,
+ 'read' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_READ),
+ 'share' => false,
+ ];
+
+ // Not in trash
+ $p[File::CONTEXT_TRASH] = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'write' => false,
+ 'create' => false,
+ 'delete' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_ADMIN),
+ 'read' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_READ),
+ 'share' => false,
+ ];
+
+ // Not in versions
+ $p[File::CONTEXT_VERSIONS] = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'write' => false,
+ 'create' => false,
+ 'delete' => false,
+ 'read' => false,
+ 'share' => false,
+ ];
+
+ $p[''] = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'write' => false,
+ 'create' => false,
+ 'delete' => false,
+ 'read' => true,
+ 'share' => false,
+ ];
+
+
+ return $p;
+ }
+
+ static public function search(string $search, string $path = null): array
+ {
+ if (strlen($search) > 100) {
+ throw new ValidationException('Recherche trop longue : maximum 100 caractères');
+ }
+
+ $where = '';
+ $params = [trim($search)];
+
+ if (null !== $path) {
+ $where = ' AND path LIKE ?';
+ $params[] = $path;
+ }
+
+ $query = sprintf('SELECT
+ *,
+ dirname(path) AS parent,
+ snippet(files_search, \'\', \' \', \'…\', 2, -30) AS snippet,
+ rank(matchinfo(files_search), 0, 1.0, 1.0) AS points
+ FROM files_search
+ WHERE files_search MATCH ? %s
+ ORDER BY points DESC
+ LIMIT 0,50;', $where);
+
+ $out = [];
+
+ try {
+ $db = DB::getInstance();
+ $db->begin();
+
+ foreach ($db->iterate($query, ...$params) as $row) {
+ $out[] = $row;
+ }
+
+ $db->commit();
+ }
+ catch (DB_Exception $e) {
+ if (strpos($e->getMessage(), 'malformed MATCH') !== false) {
+ throw new UserException('Motif de recherche invalide', 0, $e);
+ }
+
+ throw $e;
+ }
+
+ return $out;
+ }
+
+ static protected function _getParentClause(?string $parent = null): string
+ {
+ $db = DB::getInstance();
+
+ if (!$parent) {
+ return $db->where('path', 'IN', array_keys(File::CONTEXTS_NAMES));
+ }
+ else {
+ File::validatePath($parent);
+ return 'parent = ' . $db->quote($parent);
+ }
+ }
+
+ /**
+ * Returns a list of files and directories inside a parent path
+ * This is not recursive and will only return files and directories
+ * directly in the specified $parent path.
+ */
+ static public function list(?string $parent = null): array
+ {
+ $where = self::_getParentClause($parent);
+ $sql = sprintf('SELECT * FROM @TABLE WHERE %s ORDER BY type DESC, name COLLATE NOCASE ASC;', $where);
+
+ return EM::getInstance(File::class)->allAssoc($sql, 'path');
+ }
+
+ static public function getDynamicList(?string $parent = null): DynamicList
+ {
+ $columns = [
+ 'icon' => ['label' => '', 'select' => NULL],
+ 'name' => [
+ 'label' => 'Nom',
+ 'order' => 'type DESC, name COLLATE U_NOCASE %s',
+ ],
+ 'size' => [
+ 'label' => 'Taille',
+ 'order' => 'type DESC, size %s, name COLLATE U_NOCASE ASC',
+ ],
+ 'modified' => [
+ 'label' => 'Modifié',
+ 'order' => 'type DESC, modified %s, name COLLATE U_NOCASE ASC',
+ ],
+ ];
+
+ $tables = File::TABLE;
+ $conditions = self::_getParentClause($parent);
+
+ $list = new DynamicList($columns, $tables, $conditions);
+
+ $list->orderBy('name', false);
+ $list->setEntity(File::class);
+
+ // Don't take conditions for saving preferences hash
+ $list->togglePreferenceHashElement('conditions', false);
+
+ return $list;
+ }
+
+ static public function listForUser(int $id, string $field_name = null): array
+ {
+ $files = [];
+ $path = (string) $id;
+
+ if ($field_name) {
+ $path .= '/' . $field_name;
+ return self::listForContext(File::CONTEXT_USER, $path);
+ }
+
+ $list = self::listForContext(File::CONTEXT_USER, $path);
+
+ if (!$list) {
+ return [];
+ }
+
+ foreach ($list as $dir) {
+ foreach (Files::list($dir->path) as $file) {
+ $files[] = $file;
+ }
+ }
+
+ return $files;
+ }
+
+ /**
+ * Returns a list of files or directories matching a glob pattern
+ * only * and ? characters are supported in pattern
+ * Sub-directories are not returned
+ */
+ static public function glob(string $pattern): array
+ {
+ return EM::getInstance(File::class)->all(
+ 'SELECT * FROM @TABLE WHERE path GLOB ? AND path NOT GLOB ? ORDER BY type DESC, name COLLATE U_NOCASE ASC;',
+ $pattern,
+ $pattern . '/*'
+ );
+ }
+
+ static public function zipAll(?string $target = null): void
+ {
+ // Extend execution time, up to one hour, just in case
+ if (false === strpos(@ini_get('disable_functions'), 'set_time_limit')) {
+ @set_time_limit(3600);
+ }
+
+ @ini_set('max_execution_time', 3600);
+
+ Files::zip($target, array_keys(File::CONTEXTS_NAMES), null);
+ }
+
+ /**
+ * Creates a ZIP file archive from multiple paths
+ * @param null|string $target Target file name, if left NULL, then will be sent to browser
+ * @param array $paths List of paths to append to ZIP file
+ * @param Session $session Logged-in user session, if set access rights to the path will be checked,
+ * if left NULL, then no check will be made (!).
+ */
+ static public function zip(?string $target, array $paths, ?Session $session, ?string $download_name = null): void
+ {
+ if (!$target) {
+ $download_name ??= Config::getInstance()->org_name . ' - Documents';
+ header('Content-type: application/zip');
+ header(sprintf('Content-Disposition: attachment; filename="%s"', $download_name. '.zip'));
+ $target = 'php://output';
+ }
+
+ $zip = new ZipWriter($target);
+ $zip->setCompression(0);
+
+ foreach ($paths as $path) {
+ foreach (Files::listRecursive($path, $session, false) as $file) {
+ if ($file->isDir()) {
+ continue;
+ }
+
+ $pointer = $file->getReadOnlyPointer();
+ $path = !$pointer ? $file->getLocalFilePath() : null;
+
+ if (!$path && !$pointer) {
+ continue;
+ }
+
+ $zip->add($file->path, null, $path, $pointer);
+
+ if ($pointer) {
+ fclose($pointer);
+ }
+ }
+ }
+
+ $zip->close();
+ }
+
+ static public function listRecursive(?string $path = null, ?Session $session = null, bool $include_directories = true): \Generator
+ {
+ $params = [];
+ $db = DB::getInstance();
+
+ if (!$path) {
+ $sql = 'SELECT * FROM @TABLE WHERE parent IS NULL;';
+ }
+ else {
+ $sql = 'SELECT * FROM @TABLE WHERE path = ? OR (parent = ? OR parent LIKE ? ESCAPE \'!\')';
+
+ if (!$include_directories) {
+ $sql .= ' AND type != ' . File::TYPE_DIRECTORY;
+ }
+
+ $sql .= ';';
+ $params = [$path, $path, $db->escapeLike($path, '!') . '/%'];
+ }
+
+ $list = EM::getInstance(File::class)->iterate($sql, ...$params);
+
+ foreach ($list as $file) {
+ if ($session && !$file->canRead($session)) {
+ continue;
+ }
+
+ yield $file->path => $file;
+ }
+ }
+
+ static public function all()
+ {
+ $sql = 'SELECT * FROM @TABLE;';
+ return EM::getInstance(File::class)->all($sql);
+ }
+
+ /**
+ * List files and directories inside a context (first-level directory)
+ */
+ static public function listForContext(string $context, ?string $ref = null): array
+ {
+ $path = $context;
+
+ if ($ref) {
+ $path .= '/' . $ref;
+ }
+
+ return self::list($path) ?? [];
+ }
+
+ /**
+ * Delete a specified file/directory path
+ */
+ static public function delete(string $path): void
+ {
+ $file = self::get($path);
+
+ if (!$file) {
+ return;
+ }
+
+ $file->delete();
+ }
+
+ static public function callStorage(string $function, ...$args)
+ {
+ $class_name = __NAMESPACE__ . '\\Storage\\' . FILE_STORAGE_BACKEND;
+
+ call_user_func([$class_name, 'configure'], FILE_STORAGE_CONFIG);
+
+ return call_user_func_array([$class_name, $function], $args);
+ }
+
+ static public function ensureContextsExists(): void
+ {
+ foreach (File::CONTEXTS_NAMES as $key => $label) {
+ self::ensureDirectoryExists($key);
+ }
+ }
+
+ /**
+ * Returns a file, if it doesn't exist, NULL is returned
+ * If the file exists in DB but not in storage, it is deleted from DB
+ */
+ static public function get(?string $path): ?File
+ {
+ if (null === $path) {
+ // Root always exists, but is virtual
+ $file = new File;
+ $file->path = '';
+ $file->name = '';
+ $file->parent = null;
+ $file->type = $file::TYPE_DIRECTORY;
+ return $file;
+ }
+
+ try {
+ File::validatePath($path);
+ }
+ catch (ValidationException $e) {
+ return null;
+ }
+
+ $file = EM::findOne(File::class, 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;', $path);
+
+ if (!$file && array_key_exists($path, File::CONTEXTS_NAMES)) {
+ self::ensureContextsExists();
+ $file = EM::findOne(File::class, 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;', $path);
+ }
+
+ if (!$file) {
+ return null;
+ }
+
+ return $file;
+ }
+
+ static public function exists(string $path): bool
+ {
+ return DB::getInstance()->test('files', 'path = ?', $path);
+ }
+
+ static public function getFromURI(string $uri): ?File
+ {
+ $uri = rawurldecode($uri);
+ $uri = trim($uri, '/');
+
+ $context = strtok($uri, '/');
+ $other = strtok('');
+
+ // Not in a context? it's probably a web page attachment
+ if (false === strpos($other, '/') && !array_key_exists($context, File::CONTEXTS_NAMES)) {
+ $uri = File::CONTEXT_WEB . '/' . $uri;
+ }
+
+ return self::get($uri);
+ }
+
+ static public function getContext(string $path): ?string
+ {
+ $pos = strpos($path, '/');
+
+ if (false === $pos) {
+ return $path;
+ }
+
+ $context = substr($path, 0, $pos);
+
+ if (!$context) {
+ return null;
+ }
+
+ if (!array_key_exists($context, File::CONTEXTS_NAMES)) {
+ return null;
+ }
+
+ return $context;
+ }
+
+ static public function getContextRef(string $path): ?string
+ {
+ $context = strtok($path, '/');
+ $ref = strtok('/');
+ strtok('');
+ return $ref ?: null;
+ }
+
+ static public function getBreadcrumbs(string $path): array
+ {
+ $parts = explode('/', $path);
+ $breadcrumbs = [];
+ $path = '';
+
+ foreach ($parts as $part) {
+ $path = trim($path . '/' . $part, '/');
+ $breadcrumbs[$path] = $part;
+ }
+
+ return $breadcrumbs;
+ }
+
+ static public function getContextsDiskUsage(): array
+ {
+ $sql = 'SELECT SUBSTR(path, 1, INSTR(path, \'/\') - 1) AS context, SUM(size) AS total
+ FROM files
+ WHERE type = ?
+ GROUP BY SUBSTR(path, 1, INSTR(path, \'/\'));';
+
+ $quotas = DB::getInstance()->getAssoc($sql, File::TYPE_FILE);
+ $list = [];
+
+ foreach (File::CONTEXTS_NAMES as $context => $name) {
+ $list[$context] = ['label' => $name, 'size' => $quotas[$context] ?? null];
+ }
+
+ uasort($list, fn($a, $b) => $a['size'] == $b['size'] ? 0 : ($a['size'] > $b['size'] ? -1 : 1));
+
+ return $list;
+ }
+
+ static public function getContextDiskUsage(string $context): int
+ {
+ $sql = 'SELECT SUM(size) FROM files WHERE type = ? AND path LIKE ?;';
+ return (int) DB::getInstance()->firstColumn($sql, File::TYPE_FILE, $context . '/%');
+ }
+
+ static public function getQuota(): float
+ {
+ return FILE_STORAGE_QUOTA ?? self::callStorage('getQuota');
+ }
+
+ static public function getUsedQuota(): float
+ {
+ return (float) DB::getInstance()->firstColumn('SELECT SUM(size) FROM files;');
+ }
+
+ static public function getRemainingQuota(float $used_quota = null): float
+ {
+ if (FILE_STORAGE_QUOTA) {
+ return max(0, FILE_STORAGE_QUOTA - ($used_quota ?? self::getUsedQuota()));
+ }
+ else {
+ return self::callStorage('getRemainingQuota');
+ }
+ }
+
+ static public function checkQuota(int $size = 0): void
+ {
+ if (!self::$quota) {
+ return;
+ }
+
+ $remaining = self::getRemainingQuota();
+
+ if (($remaining - (float) $size) <= 0) {
+ throw new ValidationException('L\'espace disque est insuffisant pour réaliser cette opération');
+ }
+ }
+
+ static protected function create(string $parent, string $name, array $source = []): File
+ {
+ Files::checkQuota();
+
+ File::validateFileName($name);
+ File::validatePath($parent);
+
+ File::validateCanHTML($name, $parent);
+
+ $target = $parent . '/' . $name;
+
+ self::ensureDirectoryExists($parent);
+ $finfo = \finfo_open(\FILEINFO_MIME_TYPE);
+
+ $file = self::get($target);
+
+ if (!$file) {
+ $file = new File;
+ $file->set('path', $target);
+ $file->set('parent', $parent);
+ $file->set('name', $name);
+ }
+
+ if (isset($source['pointer'])) {
+ if (0 !== fseek($source['pointer'], 0, SEEK_END)) {
+ throw new \RuntimeException('Stream is not seekable');
+ }
+
+ $file->set('size', ftell($source['pointer']));
+ fseek($source['pointer'], 0, SEEK_SET);
+ $file->set('mime', mime_content_type($source['pointer']));
+ }
+ elseif (isset($source['path'])) {
+ $file->set('mime', finfo_file($finfo, $source['path']));
+ $file->set('size', filesize($source['path']));
+ $file->set('modified', new \DateTime('@' . filemtime($source['path'])));
+ }
+ elseif (isset($source['content'])) {
+ $file->set('size', strlen($source['content']));
+ $file->set('mime', finfo_buffer($finfo, $source['content']));
+ }
+ else {
+ $file->set('size', 0);
+ $file->set('mime', 'text/plain');
+ }
+
+ $file->set('image', in_array($file->mime, $file::IMAGE_TYPES));
+
+ // Force empty files as text/plain
+ if ($file->mime == 'application/x-empty' && !$file->size) {
+ $file->set('mime', 'text/plain');
+ }
+
+ return $file;
+ }
+
+ static public function createDocument(string $parent, string $name, string $extension): File
+ {
+ // From https://github.com/nextcloud/richdocuments/tree/2338e2ff7078040d54fc0c70a96c8a1b860f43a0/emptyTemplates
+ // We need to copy an empty template, or Collabora will create flat-XML file
+ if ($extension == 'ods') {
+ $tpl = 'UEsDBBQAAAAAAOw6wVCFbDmKLgAAAC4AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnNwcmVhZHNoZWV0UEsDBBQAAAAIABxZFFFL43PrmgAAAEABAAAVAAAATUVUQS1JTkYvbWFuaWZlc3QueG1slVDRDoMgDHz3KwjvwvZK1H9poEYSKETqon8vLpluWfawPrXXy921XQTyIxY2r0asMVA5x14uM5kExRdDELEYtiZlJJfsEpHYfPLNXd2kGBpRqzvB0QdsK3nexIUtIbQZeOqllhcc0XloecvYS8g5eAvsE+kHOfWMod7dVckzgisTIkv9p61NxIdGveBHAMaV9bGu0p3++tXQ7FBLAwQUAAAACAAAWRRRA4GGVIkAAAD/AAAACwAAAGNvbnRlbnQueG1sXY/RCsIwDEWf9SvG3uv0Ncz9S01TLLTNWFJwf29xbljzEu49N1wysvcBCRxjSZTVIGetu3ulmAU2eu/LkoGtBIFsEwkoAs+U9yv4TcPtcu2nc1dn/DqCS5hVuqG1fe0y3iIZRxg/+LQzW5ST1YBGdI3Uwge7tcpDy7yQdfIk0i03NMFD/n85vQFQSwECFAMUAAAAAADsOsFQhWw5ii4AAAAuAAAACAAAAAAAAAAAAAAAtIEAAAAAbWltZXR5cGVQSwECFAMUAAAACAAcWRRRS+Nz65oAAABAAQAAFQAAAAAAAAAAAAAAtIFUAAAATUVUQS1JTkYvbWFuaWZlc3QueG1sUEsBAhQDFAAAAAgAAFkUUQOBhlSJAAAA/wAAAAsAAAAAAAAAAAAAALSBIQEAAGNvbnRlbnQueG1sUEsFBgAAAAADAAMAsgAAANMBAAAAAA==';
+ }
+ elseif ($extension == 'odp') {
+ $tpl = 'UEsDBBQAAAAAAC6dVEszJqyoLwAAAC8AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnByZXNlbnRhdGlvblBLAwQUAAAACAAsYRRRP7fJFJoAAABBAQAAFQAAAE1FVEEtSU5GL21hbmlmZXN0LnhtbJVQwQqDMAy97ytK77bbNaj/EmpkhTYtNg79+1VhujF2WC5JXh7vJWkjsh+pCLwKtcTA5Wg7PU8MCYsvwBipgDhImXhIbo7EAp98uJmrVv1F1WgPcPSBmkqeVnVicwhNRrl32uoTjjR4bGTN1GnMOXiH4hPbBw9mX8O8u5s8Ual552j7p69LLJtIPeHHBkKL2G1cpVv79az+8gRQSwMEFAAAAAgAMl4UUXz4vRWJAAAA/gAAAAsAAABjb250ZW50LnhtbF2P0QqDMAxFn+dXiO+d22tw/ksXUyjYpJgI8+8tOGVdXsK994Qkg4QQkWASXBOxORS20ttPmlnhSF/dujCI16jAPpGCIUgmPqfgl4bn/dGNTVtq+DqKS8ymbT82t9MLZZELHslNhHOd+dUkeYvo1LaZ6vAt01bkpfNCWm4ouPAB9hV5yf8fx2YHUEsBAhQDFAAAAAAALp1USzMmrKgvAAAALwAAAAgAAAAAAAAAAAAAALSBAAAAAG1pbWV0eXBlUEsBAhQDFAAAAAgALGEUUT+3yRSaAAAAQQEAABUAAAAAAAAAAAAAALSBVQAAAE1FVEEtSU5GL21hbmlmZXN0LnhtbFBLAQIUAxQAAAAIADJeFFF8+L0ViQAAAP4AAAALAAAAAAAAAAAAAAC0gSIBAABjb250ZW50LnhtbFBLBQYAAAAAAwADALIAAADUAQAAAAA=';
+ }
+ else {
+ $extension = 'odt';
+ $tpl = 'UEsDBBQAAAAAAPMbH0texjIMJwAAACcAAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnRleHRQSwMEFAAAAAgA3U0SUeqX5meSAAAAMQEAABUAAABNRVRBLUlORi9tYW5pZmVzdC54bWyVUEEOgzAMu+8VqHfa7Rq1/CUqQavUphUNE/wemDTYNO2wW2I7thWbkMNAVeA1NHOKXI/VqWlkyFhDBcZEFcRDLsR99lMiFvjUw01fVXdp7AEMIVK7CcelObEpxrag3J0y6oQT9QFbWQo5haXE4FFCZvPgXj8r6PdkLTSLMv+E+cyyX26df8TunmanN19rvr7TrVBLAwQUAAAACACQThJRWmJBaH8AAADjAAAACwAAAGNvbnRlbnQueG1sXY/RCsMgDEXf+xWj767ba+j8FxcjCGpKE6H9+wlbRfYUbs69uWTlECISeMaaqahBLtrm7cipCHzpa657AXYSBYrLJKAIvFG5UjC64Xl/zHZaf0pwj5vKYq9FaA0mOCTjCdMAXFXOTiMa0TNRI/3Im/3ZfUqHttQysqnL/0/sB1BLAQIUAxQAAAAAAPMbH0texjIMJwAAACcAAAAIAAAAAAAAAAAAAACkgQAAAABtaW1ldHlwZVBLAQIUAxQAAAAIAN1NElHql+ZnkgAAADEBAAAVAAAAAAAAAAAAAACkgU0AAABNRVRBLUlORi9tYW5pZmVzdC54bWxQSwECFAMUAAAACACQThJRWmJBaH8AAADjAAAACwAAAAAAAAAAAAAApIESAQAAY29udGVudC54bWxQSwUGAAAAAAMAAwCyAAAAugEAAAAA';
+ }
+
+ $target = $parent . '/' . $name . '.' . $extension;
+
+ if (self::exists($target)) {
+ throw new ValidationException('Un document existe déjà avec ce nom : ' . $name . '.' .$extension);
+ }
+
+ return Files::createFromString($target, base64_decode($tpl));
+ }
+
+ static public function createObject(string $target)
+ {
+ $parent = Utils::dirname($target);
+ $name = Utils::basename($target);
+ return self::create($parent, $name);
+ }
+
+ static public function createFrom(string $target, array $source): File
+ {
+ $target = preg_replace('!\.\.|//!', '', $target);
+ $parent = Utils::dirname($target);
+ $name = Utils::basename($target);
+ $file = self::create($parent, $name, $source);
+ $file->store($source);
+ return $file;
+ }
+
+ /**
+ * Create and store a file from a local path
+ * @param string $target Target parent path + name
+ * @param string $path Source file path
+ * @return File
+ */
+ static public function createFromPath(string $target, string $path): File
+ {
+ return self::createFrom($target, compact('path'));
+ }
+
+ /**
+ * Create and store a file from a string
+ * @param string $target Target parent path + name
+ * @param string $content Source file contents
+ * @return File
+ */
+ static public function createFromString(string $target, string $content): File
+ {
+ return self::createFrom($target, compact('content'));
+ }
+
+ static public function createFromPointer(string $target, $pointer): File
+ {
+ return self::createFrom($target, compact('pointer'));
+ }
+
+ /**
+ * Upload multiple files
+ * @param string $parent Target parent directory (eg. 'documents/Logos')
+ * @param string $key The name of the file input in the HTML form (this MUST have a '[]' at the end of the name)
+ * @return array list of File objects created
+ */
+ static public function uploadMultiple(string $parent, string $key): array
+ {
+ // Detect if it's actually a single file
+ if (isset($_FILES[$key]['name']) && !is_array($_FILES[$key]['name'])) {
+ return [self::upload($parent, $key)];
+ }
+
+ if (!isset($_FILES[$key]['name'][0])) {
+ throw new UserException('Aucun fichier reçu');
+ }
+
+ // Transpose array
+ // see https://www.php.net/manual/en/features.file-upload.multiple.php#53240
+ $files = Utils::array_transpose($_FILES[$key]);
+ $out = [];
+
+ // First check all files
+ foreach ($files as $file) {
+ if (!empty($file['error'])) {
+ throw new UserException(self::getUploadErrorMessage($file['error']));
+ }
+
+ if (empty($file['size']) || empty($file['name'])) {
+ throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.');
+ }
+
+ if (!is_uploaded_file($file['tmp_name'])) {
+ throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.');
+ }
+ }
+
+ // Then create files
+ foreach ($files as $file) {
+ $name = File::filterName($file['name']);
+ $out[] = self::createFromPath($parent . '/' . $name, $file['tmp_name']);
+ }
+
+ return $out;
+ }
+
+ /**
+ * Upload a file using POST from a HTML form
+ * @param string $parent Target parent directory (eg. 'documents/Logos')
+ * @param string $key The name of the file input in the HTML form
+ * @return self Created file object
+ */
+ static public function upload(string $parent, string $key, ?string $name = null): File
+ {
+ if (!isset($_FILES[$key]) || !is_array($_FILES[$key])) {
+ throw new UserException('Aucun fichier reçu');
+ }
+
+ $file = $_FILES[$key];
+
+ if (!empty($file['error'])) {
+ throw new UserException(self::getUploadErrorMessage($file['error']));
+ }
+
+ if (empty($file['size']) || empty($file['name'])) {
+ throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.');
+ }
+
+ if (!is_uploaded_file($file['tmp_name'])) {
+ throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.');
+ }
+
+ $name = File::filterName($name ?? $file['name']);
+
+ return self::createFromPath($parent . '/' . $name, $file['tmp_name']);
+ }
+
+
+ /**
+ * Récupération du message d'erreur
+ * @param integer $error Code erreur du $_FILE
+ * @return string Message d'erreur
+ */
+ static public function getUploadErrorMessage($error)
+ {
+ switch ($error)
+ {
+ case UPLOAD_ERR_INI_SIZE:
+ return 'Le fichier excède la taille permise par la configuration.';
+ case UPLOAD_ERR_FORM_SIZE:
+ return 'Le fichier excède la taille permise par le formulaire.';
+ case UPLOAD_ERR_PARTIAL:
+ return 'L\'envoi du fichier a été interrompu.';
+ case UPLOAD_ERR_NO_FILE:
+ return 'Aucun fichier n\'a été reçu.';
+ case UPLOAD_ERR_NO_TMP_DIR:
+ return 'Pas de répertoire temporaire pour stocker le fichier.';
+ case UPLOAD_ERR_CANT_WRITE:
+ return 'Impossible d\'écrire le fichier sur le disque du serveur.';
+ case UPLOAD_ERR_EXTENSION:
+ return 'Une extension du serveur a interrompu l\'envoi du fichier.';
+ default:
+ return 'Erreur inconnue: ' . $error;
+ }
+ }
+
+ /**
+ * Create a new directory
+ * @param string $parent Target parent path
+ * @param string $name Target name
+ * @param bool $create_parent Create parent directories if they don't exist
+ * @return self
+ */
+ static public function mkdir(string $path, bool $create_parent = true, bool $throw_on_conflict = true): File
+ {
+ $path = trim($path, '/');
+ $parent = Utils::dirname($path) ?: null;
+ $name = Utils::basename($path);
+
+ $name = File::filterName($name);
+ $path = trim($parent . '/' . $name, '/');
+
+ File::validateFileName($name);
+ File::validatePath($path);
+
+ Files::checkQuota();
+
+ if (self::exists($path)) {
+ if ($throw_on_conflict) {
+ throw new ValidationException('Le nom de répertoire choisi existe déjà: ' . $path);
+ }
+
+ return self::get($path);
+ }
+
+ if ($parent && $create_parent) {
+ self::mkdir($parent, true, false);
+ }
+
+ $file = new File;
+ $type = $file::TYPE_DIRECTORY;
+ $file->import(compact('path', 'name', 'parent') + [
+ 'type' => file::TYPE_DIRECTORY,
+ 'image' => false,
+ ]);
+
+ $file->modified = new \DateTime;
+ $file->save();
+
+ Plugins::fire('file.mkdir', false, compact('file'));
+
+ return $file;
+ }
+
+ static public function ensureDirectoryExists(string $path): void
+ {
+ $parts = explode('/', trim($path, '/'));
+ $parts = array_filter($parts);
+ $tree = '';
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ foreach ($parts as $part) {
+ $parent = $tree ?: null;
+ $tree = trim($tree . '/' . $part, '/');
+
+ // Make sure directory exists AND is not in trash
+ $db->preparedQuery('INSERT OR IGNORE INTO files (path, parent, name, type) VALUES (?, ?, ?, ?);',
+ $tree, $parent, $part, File::TYPE_DIRECTORY);
+ }
+
+ $db->commit();
+ }
+
+ /**
+ * Return list of context that can be read by currently logged user
+ */
+ static public function listReadAccessContexts(?Session $session): array
+ {
+ if (!$session->isLogged()) {
+ return [];
+ }
+
+ $list = [];
+
+ if ($session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) {
+ $access[] = File::CONTEXT_CONFIG;
+ $access[] = File::CONTEXT_MODULES;
+ }
+
+ if ($session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)) {
+ $access[] = File::CONTEXT_TRANSACTION;
+ }
+
+ if ($session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)) {
+ $access[] = File::CONTEXT_USER;
+ }
+
+ if ($session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)) {
+ $access[] = File::CONTEXT_DOCUMENTS;
+ }
+
+ if ($session->canAccess($session::SECTION_WEB, $session::ACCESS_READ)) {
+ $access[] = File::CONTEXT_WEB;
+ }
+
+ return array_intersect_key(File::CONTEXTS_NAMES, array_flip($access));
+ }
+
+ /**
+ * Remove empty directories
+ */
+ static public function pruneEmptyDirectories(string $parent): void
+ {
+ // Select all directories that don't contain any sub-files, even in sub-sub-sub directories
+ $sql = 'SELECT d.* FROM files d
+ LEFT JOIN files f ON f.type = ? AND f.path LIKE d.path || \'/%\'
+ WHERE d.type = ? AND d.parent LIKE ? ESCAPE \'!\'
+ GROUP BY d.path
+ HAVING COUNT(f.id) = 0
+ ORDER BY d.path DESC;';
+
+ $like = DB::getInstance()->escapeLike($parent, '!') . '/%';
+
+ // Do not use iterate() here, as the next row might be deleted before we fetch it
+ $i = EM::getInstance(File::class)->all($sql, File::TYPE_FILE, File::TYPE_DIRECTORY, $like);
+
+ foreach ($i as $dir) {
+ $dir->delete();
+ }
+ }
+
+ /**
+ * For each versioned file, prune old version
+ */
+ static public function pruneOldVersions(): void
+ {
+ $sql = 'SELECT a.* FROM files a
+ INNER JOIN files b ON b.path = \'%s/\' || a.path AND b.type = %d
+ WHERE a.type = %d AND a.path NOT LIKE \'%s/%%\';';
+
+ $sql = sprintf($sql, File::CONTEXT_VERSIONS, File::TYPE_DIRECTORY, File::TYPE_FILE, File::CONTEXT_VERSIONS);
+
+ $i = EM::getInstance(File::class)->iterate($sql);
+
+ foreach ($i as $file) {
+ $file->pruneVersions();
+ }
+ }
+
+ /**
+ * For each versioned file, prune old version
+ */
+ static public function deleteAllVersions(): void
+ {
+ $sql = 'SELECT * FROM files WHERE type = %d AND path LIKE \'%s/%%\';';
+
+ $sql = sprintf($sql, File::TYPE_DIRECTORY, File::CONTEXT_VERSIONS);
+
+ $i = EM::getInstance(File::class)->iterate($sql);
+
+ foreach ($i as $file) {
+ $file->delete();
+ }
+ }
+
+ static public function getVersioningPolicy(): string
+ {
+ if (!self::$versioning) {
+ return 'none';
+ }
+
+ return FILE_VERSIONING_POLICY ?? Config::getInstance()->file_versioning_policy;
+ }
+
+ static public function getIconShape(string $name)
+ {
+ $ext = substr($name, strrpos($name, '.') + 1);
+ $ext = strtolower($ext);
+
+ switch ($ext) {
+ case 'ods':
+ case 'xls':
+ case 'xlsx':
+ case 'csv':
+ return 'table';
+ case 'odt':
+ case 'doc':
+ case 'docx':
+ case 'rtf':
+ return 'document';
+ case 'pdf':
+ return 'pdf';
+ case 'odp':
+ case 'ppt':
+ case 'pptx':
+ return 'gallery';
+ case 'txt':
+ case 'skriv':
+ return 'text';
+ case 'md':
+ return 'markdown';
+ case 'html':
+ case 'css':
+ case 'js':
+ case 'tpl':
+ return 'code';
+ case 'mkv':
+ case 'mp4':
+ case 'avi':
+ case 'ogm':
+ case 'ogv':
+ return 'video';
+ case 'png':
+ case 'jpg':
+ case 'jpeg':
+ case 'webp':
+ case 'gif':
+ case 'svg':
+ return 'image';
+ }
+
+ return 'document';
+ }
+}
diff --git a/src/include/lib/Paheko/Files/Storage.php b/src/include/lib/Paheko/Files/Storage.php
new file mode 100644
index 0000000..67af787
--- /dev/null
+++ b/src/include/lib/Paheko/Files/Storage.php
@@ -0,0 +1,188 @@
+begin();
+
+ $cache_files = Files::list($path);
+ $local_files = Files::callStorage('listFiles', $path);
+
+ foreach ($local_files as $file) {
+ if ($file->type == $file::TYPE_DIRECTORY) {
+ self::sync($file->path, $callback);
+ unset($cache_files[$file->path]);
+ continue;
+ }
+
+ $cache_differs = false;
+ $cache = $cache_files[$file->path] ?? null;
+
+ if ($cache) {
+ if ($cache->modified->getTimestamp() !== $file->modified->getTimestamp()) {
+ $cache_differs = true;
+ }
+ elseif ($cache->size !== $file->size) {
+ $cache_differs = true;
+ }
+
+ unset($cache_files[$file->path]);
+ }
+
+ if ($cache && !$cache_differs) {
+ continue;
+ }
+
+ if ($cache) {
+ $cache->deleteSafe();
+ }
+ else {
+ $file->deleteCache();
+ }
+
+ // Don't index versioned files as trashed
+ if ($file->context() === $file::CONTEXT_TRASH && strpos($file->path, $file::CONTEXT_TRASH . $file::CONTEXT_VERSIONS) === 0) {
+ $file->set('trash', $file->modified);
+ }
+
+ // Re-create MD5 hash
+ $file->rehash();
+
+ // save() will *also* add the file to the users_files or transactions_files table
+ $file->save();
+
+ if ($callback) {
+ $callback($cache ? 'update' : 'create', $file);
+ }
+ }
+
+ unset($file, $cache);
+
+ // Delete cached files that are not in backend storage from cache
+ foreach ($cache_files as $file) {
+ // Don't use ->delete() here as it would trigger delete from storage even if there was a bug
+ // but we don't want to risk losing any data
+ $file->deleteSafe();
+
+ if ($callback) {
+ $callback('delete_cache', $file);
+ }
+ }
+
+ $db->commit();
+ }
+
+ /**
+ * Copy all files from a storage backend to another one
+ * This can be used to move from SQLite to FileSystem for example
+ * Note that this only copies files, and is not removing them from the source storage backend.
+ */
+ static public function migrate(string $from, string $to, $from_config = null, $to_config = null, ?callable $callback = null): void
+ {
+ self::call($from, 'configure', $from_config);
+ self::call($to, 'configure', $to_config);
+
+ $db = DB::getInstance();
+
+ try {
+ if (self::call($from, 'isLocked')) {
+ throw new \RuntimeException('Storage is locked: ' . $from);
+ }
+
+ if (self::call($to, 'isLocked')) {
+ throw new \RuntimeException('Storage is locked: ' . $to);
+ }
+
+ self::call($from, 'lock');
+ self::call($to, 'lock');
+
+ $db->begin();
+ $i = 0;
+
+ foreach (Files::all() as $file) {
+ if ($file->isDir()) {
+ continue;
+ }
+
+ if (++$i >= 100) {
+ $db->commit();
+ $db->begin();
+ $i = 0;
+ }
+
+ if (null !== $callback) {
+ $callback('copy', $file);
+ }
+
+ if ($pointer = self::call($from, 'getReadOnlyPointer', $file)) {
+ self::call($to, 'storePointer', $file, $pointer);
+ fclose($pointer);
+ }
+ elseif (($path = self::call($from, 'getLocalFilePath', $file)) && file_exists($path)) {
+ self::call($to, 'storePath', $file, $path);
+ }
+ else {
+ $errors[] = sprintf('%s: no pointer or local file path found in "%s"', $file->path, $from);
+ }
+ }
+
+ $db->commit();
+ }
+ catch (RuntimeException $e) {
+ throw new \RuntimeException('Migration failed', 0, $e);
+ }
+ finally {
+ if ($db->inTransaction()) {
+ $db->rollback();
+ }
+
+ self::call($from, 'unlock');
+ self::call($to, 'unlock');
+ }
+ }
+
+ /**
+ * Delete all files from a storage backend
+ */
+ static public function truncate(string $backend, $config = null): void
+ {
+ self::call($backend, 'configure', $config);
+ self::call($backend, 'truncate');
+ }
+
+ /**
+ * Do cleanup
+ */
+ static public function cleanup(): void
+ {
+ Files::callStorage('cleanup');
+ }
+}
diff --git a/src/include/lib/Paheko/Files/Storage/FileSystem.php b/src/include/lib/Paheko/Files/Storage/FileSystem.php
new file mode 100644
index 0000000..6f9a568
--- /dev/null
+++ b/src/include/lib/Paheko/Files/Storage/FileSystem.php
@@ -0,0 +1,312 @@
+modified->getTimestamp());
+ }
+
+ return $return;
+ }
+
+ static public function storeContent(File $file, string $source_content): bool
+ {
+ $target = self::getLocalFilePath($file);
+ self::ensureParentDirectoryExists($target);
+
+ Utils::safe_mkdir(CACHE_ROOT, null, true);
+ $tmpfile = tempnam(CACHE_ROOT, 'file-');
+ $return = file_put_contents($tmpfile, $source_content) === false ? false : true;
+
+ if ($return) {
+ rename($tmpfile, $target);
+ touch($target, $file->modified->getTimestamp());
+ }
+
+ return $return;
+ }
+
+ static public function storePointer(File $file, $pointer): bool
+ {
+ $target = self::getLocalFilePath($file);
+ self::ensureParentDirectoryExists($target);
+
+ Utils::safe_mkdir(CACHE_ROOT, null, true);
+ $tmpfile = tempnam(CACHE_ROOT, 'file-');
+ $fp = fopen($tmpfile, 'w');
+
+ while (!feof($pointer)) {
+ fwrite($fp, fread($pointer, 8192));
+ }
+
+ fclose($fp);
+
+ rename($tmpfile, $target);
+ touch($target, $file->modified->getTimestamp());
+
+ return true;
+ }
+
+ static public function getLocalFilePath(File $file): ?string
+ {
+ return self::_getStoragePath($file->path);
+ }
+
+ static protected function _getStoragePath(string $path): string
+ {
+ $path = self::_getRoot() . '/' . $path;
+ return str_replace('/', DIRECTORY_SEPARATOR, $path);
+ }
+
+ static public function getReadOnlyPointer(File $file)
+ {
+ try {
+ return fopen(self::getLocalFilePath($file), 'rb');
+ }
+ catch (\Throwable $e) {
+ if (false !== strpos($e->getMessage(), 'No such file')) {
+ return null;
+ }
+
+ throw $e;
+ }
+ }
+
+ static public function touch(File $file, \DateTime $date): void
+ {
+ $path = self::getLocalFilePath($file);
+ touch($path, $date->getTimestamp());
+ }
+
+ static public function rename(File $file, string $new_path): bool
+ {
+ $path = self::getLocalFilePath($file);
+ $new_path = self::_getStoragePath($new_path);
+
+ if (!file_exists($path)) {
+ return true;
+ }
+
+ // Overwrite
+ if (file_exists($new_path)) {
+ Utils::deleteRecursive($new_path, true);
+ }
+
+ self::ensureParentDirectoryExists($new_path);
+
+ rename($path, $new_path);
+ return true;
+ }
+
+ static public function delete(File $file): bool
+ {
+ $path = self::getLocalFilePath($file);
+
+ if (!file_exists($path)) {
+ return true;
+ }
+
+ Utils::deleteRecursive($path, true);
+ return true;
+ }
+
+ /**
+ * @see https://www.crazyws.fr/dev/fonctions-php/fonction-disk-free-space-et-disk-total-space-pour-ovh-2JMH9.html
+ * @see https://github.com/jdel/sspks/commit/a890e347f32e9e3e50a0dd82398947633872bf38
+ */
+ static public function getQuota(): float
+ {
+ $quota = disk_total_space(self::_getRoot());
+ return $quota === false ? (float) \PHP_INT_MAX : (float) $quota;
+ }
+
+ static public function getRemainingQuota(): float
+ {
+ $quota = @disk_free_space(self::_getRoot());
+ return $quota === false ? (float) \PHP_INT_MAX : (float) $quota;
+ }
+
+ static public function truncate(): void
+ {
+ Utils::deleteRecursive(self::_getRoot(), false);
+ }
+
+ static public function lock(): void
+ {
+ touch(self::_getRoot() . DIRECTORY_SEPARATOR . '.lock');
+ }
+
+ static public function unlock(): void
+ {
+ Utils::safe_unlink(self::_getRoot() . DIRECTORY_SEPARATOR . '.lock');
+ }
+
+ static public function isLocked(): bool
+ {
+ return file_exists(self::_getRoot() . DIRECTORY_SEPARATOR . '.lock');
+ }
+
+ static protected function _SplToFile(\SplFileInfo $spl): ?File
+ {
+ // may return slash
+ // see comments https://www.php.net/manual/fr/splfileinfo.getfilename.php
+ // don't use getBasename as it is locale-dependent!
+ $name = trim($spl->getFilename(), '/');
+ $root = self::_getRoot();
+
+ $path = substr($spl->getPathname(), strlen($root . DIRECTORY_SEPARATOR));
+ $path = str_replace(DIRECTORY_SEPARATOR, '/', $path);
+
+ try {
+ File::validateFileName($name);
+ File::validatePath($path);
+ }
+ catch (ValidationException $e) {
+ // Invalid files paths or names cannot be added to file cache
+ return null;
+ }
+
+ $parent = Utils::dirname($path);
+
+ if ($parent == '.' || !$parent) {
+ $parent = null;
+ }
+
+ $data = [
+ 'id' => null,
+ 'name' => $name,
+ 'path' => $path,
+ 'parent' => $parent,
+ 'modified' => new \DateTime('@' . $spl->getMTime()),
+ 'size' => $spl->isDir() ? null : $spl->getSize(),
+ 'type' => $spl->isDir() ? File::TYPE_DIRECTORY : File::TYPE_FILE,
+ 'mime' => $spl->isDir() ? null : mime_content_type($spl->getRealPath()),
+ 'md5' => null,
+ 'trash' => null,
+ ];
+
+ $data['modified']->setTimeZone(new \DateTimeZone(date_default_timezone_get()));
+ $data['image'] = (int) in_array($data['mime'], File::IMAGE_TYPES);
+
+ $file = new File;
+ $file->load($data);
+
+ return $file;
+ }
+
+ static public function listFiles(?string $path = null): array
+ {
+ $root = self::_getRoot();
+ $fullpath = $root . DIRECTORY_SEPARATOR . $path;
+
+ if (!file_exists($fullpath)) {
+ return [];
+ }
+
+ $files = [];
+
+ foreach (new \FilesystemIterator($fullpath, \FilesystemIterator::SKIP_DOTS) as $file) {
+ // Seems that SKIP_DOTS does not work all the time?
+ if ($file->getFilename()[0] == '.') {
+ continue;
+ }
+
+ $obj = self::_SplToFile($file);
+
+ // Skip invalid files
+ if (null === $obj) {
+ continue;
+ }
+
+ // Used to make sorting easier
+ // directory_blabla
+ // file_image.jpeg
+ $files[$file->getFilename()] = $obj;
+ }
+
+ return $files;
+ }
+
+ static public function cleanup(): void
+ {
+ self::_cleanupDirectory(null);
+ }
+
+ /**
+ * Delete empty directories
+ */
+ static protected function _cleanupDirectory(?string $path): void
+ {
+ $path ??= self::_getRoot();;
+
+ foreach (glob($path . '/*', GLOB_ONLYDIR | GLOB_NOSORT) as $dir) {
+ self::_cleanupDirectory($dir);
+ @rmdir($dir);
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/Files/Storage/SQLite.php b/src/include/lib/Paheko/Files/Storage/SQLite.php
new file mode 100644
index 0000000..2b4126a
--- /dev/null
+++ b/src/include/lib/Paheko/Files/Storage/SQLite.php
@@ -0,0 +1,172 @@
+openBlob('files_contents', 'content', $file->id());
+ }
+ catch (\Exception $e) {
+ if (!strstr($e->getMessage(), 'no such rowid')) {
+ throw $e;
+ }
+
+ return null;
+ }
+
+ return $blob;
+ }
+
+ static public function storePath(File $file, string $path): bool
+ {
+ return self::store($file, compact('path'));
+ }
+
+ static public function storeContent(File $file, string $content): bool
+ {
+ return self::store($file, compact('content'));
+ }
+
+ static public function storePointer(File $file, $pointer): bool
+ {
+ return self::store($file, compact('pointer'));
+ }
+
+ static protected function store(File $file, array $source): bool
+ {
+ if (!isset($source['path']) && !isset($source['content']) && !isset($source['pointer'])) {
+ throw new \InvalidArgumentException('Unknown source type');
+ }
+ elseif (count($source) != 1) {
+ throw new \InvalidArgumentException('Invalid source type');
+ }
+
+ $content = $path = $pointer = null;
+ extract($source);
+
+ $db = DB::getInstance();
+
+ $file->save();
+
+ $id = $file->id();
+
+ $db->preparedQuery('INSERT OR REPLACE INTO files_contents (id, content) VALUES (?, zeroblob(?));',
+ $id, $file->size);
+
+ $blob = $db->openBlob('files_contents', 'content', $id, 'main', \SQLITE3_OPEN_READWRITE);
+
+ if (null !== $content) {
+ fwrite($blob, $content);
+ }
+ elseif ($path) {
+ $pointer = fopen($path, 'rb');
+ }
+
+ if ($pointer) {
+ while (!feof($pointer)) {
+ fwrite($blob, fread($pointer, 8192));
+ }
+
+ if ($path) {
+ fclose($pointer);
+ }
+ }
+
+ fclose($blob);
+
+ return true;
+ }
+
+ static public function getLocalFilePath(File $file): ?string
+ {
+ return null;
+ }
+
+ static public function touch(File $file, \DateTime $date): void
+ {
+ }
+
+ static public function delete(File $file): bool
+ {
+ // Nothing to do, files_contents is deleted when files row is deleted (cascade)
+ return true;
+ }
+
+ static public function rename(File $file, string $new_path): bool
+ {
+ return true;
+ }
+
+ /**
+ * @see https://www.crazyws.fr/dev/fonctions-php/fonction-disk-free-space-et-disk-total-space-pour-ovh-2JMH9.html
+ * @see https://github.com/jdel/sspks/commit/a890e347f32e9e3e50a0dd82398947633872bf38
+ */
+ static public function getQuota(): float
+ {
+ $quota = @disk_total_space(DATA_ROOT);
+ return $quota === false ? (float) \PHP_INT_MAX : (float) $quota;
+ }
+
+ static public function getRemainingQuota(): float
+ {
+ $quota = @disk_free_space(DATA_ROOT);
+ return $quota === false ? (float) \PHP_INT_MAX : (float) $quota;
+ }
+
+ static public function truncate(): void
+ {
+ $db = DB::getInstance();
+ $db->exec('DELETE FROM files_contents; VACUUM;');
+ }
+
+ static public function lock(): void
+ {
+ DB::getInstance()->exec('CREATE TABLE IF NOT EXISTS files_lock (lock);');
+ }
+
+ static public function unlock(): void
+ {
+ DB::getInstance()->exec('DROP TABLE IF EXISTS files_lock;');
+ }
+
+ static public function isLocked(): bool
+ {
+ return DB::getInstance()->test('sqlite_master', 'name = ? AND type = ?', 'files_lock', 'table');
+ }
+
+ static public function listFiles(?string $path = null): array
+ {
+ // Doesn't make sense
+ throw new \LogicException('SQLite storage cannot list local files');
+ }
+
+ static public function cleanup(): void
+ {
+ }
+}
diff --git a/src/include/lib/Paheko/Files/Storage/StorageInterface.php b/src/include/lib/Paheko/Files/Storage/StorageInterface.php
new file mode 100644
index 0000000..7e7a0fd
--- /dev/null
+++ b/src/include/lib/Paheko/Files/Storage/StorageInterface.php
@@ -0,0 +1,146 @@
+ [
+ 'select' => 't.id',
+ 'label' => 'N°',
+ ],
+ 'label' => [
+ 'select' => 't.label',
+ 'label' => 'Libellé',
+ ],
+ 'date' => [
+ 'label' => 'Date',
+ 'select' => 't.date',
+ 'order' => 't.date %s, t.id %1$s',
+ ],
+ 'reference' => [
+ 'label' => 'Pièce comptable',
+ 'select' => 't.reference',
+ ],
+ 'year' => [
+ 'select' => 'y.label',
+ 'label' => 'Exercice',
+ 'order' => 'y.end_date %s, t.date %1$s, t.id %1$s',
+ ],
+ 'path' => [
+ 'select' => '\'transaction/\' || t.id',
+ ],
+ ];
+
+ static public function list()
+ {
+ Files::pruneEmptyDirectories(File::CONTEXT_TRANSACTION);
+
+ $columns = self::LIST_COLUMNS;
+
+ $tables = 'acc_transactions_files tf
+ INNER JOIN acc_transactions t ON t.id = tf.id_transaction
+ INNER JOIN acc_years y ON t.id_year = y.id
+ INNER JOIN files f ON f.id = tf.id_file AND f.trash IS NULL';
+
+ $list = new DynamicList($columns, $tables);
+ $list->orderBy('id', true);
+ $list->groupBy('t.id');
+ $list->setCount('COUNT(DISTINCT tf.id_transaction)');
+ $list->setModifier(function (&$row) {
+ $row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
+ });
+
+ return $list;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Files/Trash.php b/src/include/lib/Paheko/Files/Trash.php
new file mode 100644
index 0000000..ddbc8a5
--- /dev/null
+++ b/src/include/lib/Paheko/Files/Trash.php
@@ -0,0 +1,68 @@
+ [
+ 'label' => 'Type',
+ 'header_icon' => 'folder',
+ 'order' => 'type = 2 %s, name COLLATE U_NOCASE %1$s',
+ ],
+ 'name' => [
+ 'label' => 'Nom',
+ ],
+ 'parent' => [
+ 'label' => 'Chemin d\'origine',
+ 'select' => 'SUBSTR(parent, 1 + LENGTH(\'trash/\') + 40 + 1)',
+ ],
+ 'path' => [
+ ],
+ 'trash' => [
+ 'label' => 'Supprimé le',
+ 'order' => 'path %s',
+ ],
+ 'size' => [
+ 'label' => 'Taille',
+ 'select' => 'CASE WHEN type = 1 THEN size ELSE (SELECT SUM(size) FROM files f2 WHERE f2.path LIKE files.path || \'/%\') END',
+ ],
+ ];
+
+ static public function list(): DynamicList
+ {
+ $columns = self::LIST_COLUMNS;
+
+ $tables = File::TABLE;
+
+ $conditions = 'trash IS NOT NULL OR (path LIKE \'trash/%\' AND type = 1)';
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('trash', true);
+
+ return $list;
+ }
+
+ static public function clean(string $expiry = '-30 days'): void
+ {
+ $past = new \DateTime($expiry);
+ $list = EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE trash IS NOT NULL AND trash < ?;', $past);
+
+ foreach ($list as $file) {
+ $file->delete();
+ }
+ }
+
+ static public function getSize(): int
+ {
+ $db = DB::getInstance();
+ return $db->firstColumn('SELECT SUM(size) FROM files WHERE trash IS NOT NULL OR path LIKE \'trash/%\';') ?: 0;
+ }
+
+}
diff --git a/src/include/lib/Paheko/Files/Users.php b/src/include/lib/Paheko/Files/Users.php
new file mode 100644
index 0000000..122aa96
--- /dev/null
+++ b/src/include/lib/Paheko/Files/Users.php
@@ -0,0 +1,46 @@
+ [
+ 'label' => 'Numéro',
+ ],
+ 'identity' => [
+ 'select' => '',
+ 'label' => '',
+ ],
+ 'path' => [
+ 'select' => '\'user/\' || u.id',
+ ],
+ 'id' => [
+ 'label' => null,
+ 'select' => 'u.id',
+ ],
+ ];
+
+ static public function list(): DynamicList
+ {
+ Files::pruneEmptyDirectories(File::CONTEXT_USER);
+
+ $columns = self::LIST_COLUMNS;
+ $columns['identity']['select'] = DF::getNameFieldsSQL('u');
+ $columns['identity']['label'] = DF::getNameLabel();
+ $columns['number']['select'] = DF::getNumberField();
+
+ $tables = 'users_files uf INNER JOIN users u ON uf.id_user = u.id INNER JOIN files f ON uf.id_file = f.id AND f.trash IS NULL';
+
+ $list = new DynamicList($columns, $tables);
+ $list->orderBy('number', false);
+ $list->groupBy('uf.id_user');
+ $list->setCount('COUNT(DISTINCT uf.id_user)');
+
+ return $list;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Files/WebDAV/NextCloud.php b/src/include/lib/Paheko/Files/WebDAV/NextCloud.php
new file mode 100644
index 0000000..b966f87
--- /dev/null
+++ b/src/include/lib/Paheko/Files/WebDAV/NextCloud.php
@@ -0,0 +1,275 @@
+temporary_chunks_path = CACHE_ROOT . '/webdav.chunks';
+ $this->setRootURL(WWW_URL);
+ }
+
+ public function route(?string $uri = null): bool
+ {
+ $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
+
+ // Currently, iOS apps are broken
+ if (stristr($ua, 'nextcloud-ios') || stristr($ua, 'owncloudapp')) {
+ throw new WebDAV_Exception('Your client is not compatible with this server. Consider using a different WebDAV client.', 403);
+ }
+
+ return parent::route($uri);
+ }
+
+ public function auth(?string $login, ?string $password): bool
+ {
+ $session = Session::getInstance();
+
+ if ($session->isLogged()) {
+ return true;
+ }
+
+ if (!$login || !$password) {
+ return false;
+ }
+
+ if ($session->checkAppCredentials($login, $password)) {
+ return true;
+ }
+
+ if ($session->login($login, $password)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function getUserName(): ?string
+ {
+ $s = Session::getInstance();
+ return $s->isLogged() ? $s->user()->name() : null;
+ }
+
+ public function setUserName(string $login): bool
+ {
+ return true;
+ }
+
+ public function getUserQuota(): array
+ {
+ return [
+ 'free' => Files::getRemainingQuota(),
+ 'used' => Files::getUsedQuota(),
+ 'total' => Files::getQuota(),
+ ];
+ }
+
+ public function generateToken(): string
+ {
+ return Session::getInstance()->generateAppToken();
+ }
+
+ public function validateToken(string $token): ?array
+ {
+ return Session::getInstance()->verifyAppToken($_POST['token']);
+ }
+
+ public function getLoginURL(?string $token): string
+ {
+ if ($token) {
+ return sprintf('%slogin.php?app=%s', ADMIN_URL, $token);
+ }
+ else {
+ return sprintf('%slogin.php?app=redirect', ADMIN_URL);
+ }
+ }
+
+ public function getDirectDownloadSecret(string $uri, string $login): string
+ {
+ return hash_hmac('sha1', $uri, SECRET_KEY);
+ }
+
+ protected function cleanChunks(): void
+ {
+ // 36 hours
+ $expire = time() - 36*3600;
+
+ foreach (glob($this->temporary_chunks_path . '/*') as $dir) {
+ $first_file = current(glob($dir . '/*'));
+
+ if (filemtime($first_file) < $expire) {
+ Utils::deleteRecursive($dir, true);
+ }
+ }
+ }
+
+ public function storeChunk(string $login, string $name, string $part, $pointer): void
+ {
+ $this->cleanChunks();
+
+ $path = $this->temporary_chunks_path . '/' . $name;
+ @mkdir($path, 0777, true);
+
+ $file_path = $path . '/' . $part;
+ $out = fopen($file_path, 'wb');
+ $quota = $this->getUserQuota();
+
+ $used = array_sum(array_map(fn($a) => filesize($a), glob($path . '/*')));
+ $used += $quota['used'];
+
+ while (!feof($pointer)) {
+ $data = fread($pointer, 8192);
+ $used += strlen($used);
+
+ if ($used > $quota['free']) {
+ $this->deleteChunks($login, $name);
+ throw new WebDAV_Exception('Your quota does not allow for the upload of this file', 403);
+ }
+
+ fwrite($out, $data);
+ }
+
+ fclose($out);
+ fclose($pointer);
+ }
+
+ public function deleteChunks(string $login, string $name): void
+ {
+ $path = $this->temporary_chunks_path . '/' . $name;
+ Utils::deleteRecursive($path, true);
+ }
+
+ public function listChunks(string $login, string $name): array
+ {
+ $path = $this->temporary_chunks_path . '/' . $name;
+ $list = glob($path . '/*');
+ $list = array_map(fn($a) => str_replace($path . '/', '', $a), $list);
+ return $list;
+ }
+
+ public function assembleChunks(string $login, string $name, string $target, ?int $mtime): array
+ {
+ $parent = Utils::dirname($target);
+ $parent = Files::get($parent);
+
+ if (!$parent || $parent->type != $parent::TYPE_DIRECTORY) {
+ throw new WebDAV_Exception('Target parent directory does not exist', 409);
+ }
+
+ $path = $this->temporary_chunks_path . '/' . $name;
+ $tmp_file = $path . '/__complete';
+
+ $target = $this->prefix . $target;
+
+ $exists = Files::exists($target);
+
+ try {
+ $out = fopen($tmp_file, 'wb');
+ $processed = 0;
+
+ foreach (glob($path . '/*') as $file) {
+ if ($file == $tmp_file) {
+ continue;
+ }
+
+ $in = fopen($file, 'rb');
+
+ while (!feof($in)) {
+ $data = fread($in, 8192);
+ fwrite($out, $data);
+ $processed += strlen($data);
+ }
+
+ fclose($in);
+ }
+
+ fclose($out);
+ $file = Files::createFromPath($target, $tmp_file);
+
+ if ($mtime) {
+ $file->touch($mtime);
+ }
+ }
+ finally {
+ $this->deleteChunks($login, $name);
+ Utils::safe_unlink($tmp_file);
+ }
+
+ return ['created' => !$exists, 'etag' => $file->etag()];
+ }
+
+ public function serveThumbnail(string $uri, int $width, int $height, bool $crop = false, bool $preview = false): void
+ {
+ if (!preg_match('/\.(?:jpe?g|gif|png|webp)$/', $uri)) {
+ http_response_code(404);
+ return;
+ }
+
+ $this->requireAuth();
+ $uri = preg_replace(self::WEBDAV_BASE_REGEXP, '', $uri);
+ $file = Files::get(File::CONTEXT_DOCUMENTS . '/' . $uri);
+
+ if (!$file) {
+ throw new WebDAV_Exception('Not found', 404);
+ }
+
+ if (!$file->image) {
+ throw new WebDAV_Exception('Not an image', 404);
+ }
+
+ if ($crop) {
+ $size = 'crop-256px';
+ }
+ elseif ($width >= 500 || $height >= 500) {
+ $size = '500px';
+ }
+ else {
+ $size = '150px';
+ }
+
+ $file->validateCanRead();
+
+ $this->server->log('Serving thumbnail for: %s - size: %s', $uri, $size);
+
+ try {
+ $file->serveThumbnail($size);
+ }
+ catch (UserException $e) {
+ throw new WebDAV_Exception($e->getMessage(), $e->getCode(), $e);
+ }
+ }
+
+ protected function nc_avatar(): void
+ {
+ header('X-NC-IsCustomAvatar: 1');
+
+ $file = Config::getInstance()->file('icon');
+
+ if (!$file) {
+ $path = ROOT . '/www/admin/static/icon.png';
+
+ header('Content-Type: image/png');
+ header('Last-Modified: ' . gmdate(DATE_ISO8601));
+ header('Content-Length: ' . filesize($path));
+ readfile($path);
+ }
+ else {
+ $file->serveThumbnail('crop-256px');
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/Files/WebDAV/Server.php b/src/include/lib/Paheko/Files/WebDAV/Server.php
new file mode 100644
index 0000000..413edc9
--- /dev/null
+++ b/src/include/lib/Paheko/Files/WebDAV/Server.php
@@ -0,0 +1,110 @@
+setStorage($storage);
+ $wopi->setServer($dav);
+
+ return $wopi->route($uri);
+ }
+
+ static public function route(?string $uri = null): bool
+ {
+ $uri = '/' . ltrim($uri, '/');
+
+ if (self::wopiRoute($uri)) {
+ return true;
+ }
+
+ $dav = new WebDAV;
+ $nc = new NextCloud($dav);
+ $storage = new Storage(Session::getInstance(), $nc);
+ $dav->setStorage($storage);
+
+ $method = $_SERVER['REQUEST_METHOD'] ?? null;
+
+ // Always say YES to OPTIONS
+ if ($method == 'OPTIONS') {
+ $dav->http_options();
+ return true;
+ }
+
+
+ $nc->setServer($dav);
+
+ if ($r = $nc->route($uri)) {
+ // NextCloud route already replied something, stop here
+ return true;
+ }
+
+ // If NextCloud layer didn't return anything
+ // it means we fall back to the default WebDAV server
+ // available on the root path. We need to handle a
+ // classic login/password auth here.
+
+ if (0 !== strpos($uri, '/dav/')) {
+ return false;
+ }
+
+ if (!self::auth()) {
+ http_response_code(401);
+ header('WWW-Authenticate: Basic realm="Please login"');
+ return true;
+ }
+
+ $dav->setBaseURI('/dav/');
+
+ return $dav->route($uri);
+ }
+
+ static public function auth(): bool
+ {
+ $session = Session::getInstance();
+
+ if ($session->isLogged()) {
+ return true;
+ }
+
+ $login = $_SERVER['PHP_AUTH_USER'] ?? null;
+ $password = $_SERVER['PHP_AUTH_PW'] ?? null;
+
+ if (!isset($login, $password)) {
+ return false;
+ }
+
+ if ($session->loginAPI($login, $password)) {
+ return true;
+ }
+
+ if ($session->login($login, $password)) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/include/lib/Paheko/Files/WebDAV/Session.php b/src/include/lib/Paheko/Files/WebDAV/Session.php
new file mode 100644
index 0000000..fd52341
--- /dev/null
+++ b/src/include/lib/Paheko/Files/WebDAV/Session.php
@@ -0,0 +1,164 @@
+_user = (new User)->import([$name => $login . ' (API)']);
+
+ $this->user = -1;
+ $this->_permissions = [];
+
+ foreach (Category::PERMISSIONS as $perm => $data) {
+ $this->_permissions[$perm] = $access;
+ }
+
+ return true;
+ }
+
+ /**
+ * Create a temporary app token for an external service session (eg. NextCloud)
+ */
+ public function generateAppToken(): string
+ {
+ $token = hash('sha256', random_bytes(10));
+
+ $expiry = time() + 30*60; // 30 minutes
+ $this->storeRememberMeSelector('tok_' . $token, 'waiting', $expiry, null);
+
+ return $token;
+ }
+
+ /**
+ * Validate the temporary token once the user has logged-in
+ */
+ public function validateAppToken(string $token): bool
+ {
+ if (!ctype_alnum($token) || strlen($token) > 64) {
+ return false;
+ }
+
+ $token = $this->getRememberMeSelector('tok_' . $token);
+
+ if (!$token || $token->hash != 'waiting') {
+ return false;
+ }
+
+ $user = $this->getUser();
+
+ if (!$user) {
+ throw new \LogicException('Cannot create a token if the user is not logged-in');
+ }
+
+ DB::getInstance()->preparedQuery('UPDATE users_sessions
+ SET hash = \'ok\', id_user = ?, expiry = expiry + 30*60
+ WHERE selector = ?;',
+ $user->id, $this->cookie_name . '_' . $token->selector);
+
+ return true;
+ }
+
+ /**
+ * Verify temporary app token and create a session,
+ * this is similar to "remember me" sessions but without cookies
+ */
+ public function verifyAppToken(string $token): ?array
+ {
+ if (!ctype_alnum($token) || strlen($token) > 64) {
+ return null;
+ }
+
+ $token = $this->getRememberMeSelector('tok_' . $token);
+
+ if (!$token || $token->hash != 'ok') {
+ return null;
+ }
+
+ // Delete temporary token
+ $this->deleteRememberMeSelector($token->selector);
+
+ if ($token->expiry < time()) {
+ return null;
+ }
+
+ $new_token = base_convert(sha1(random_bytes(10)), 16, 36);
+ $selector = 'app_' . substr($new_token, 0, 16);
+ $selector = $this->createSelectorValues($token->user_id, $token->user_password, null, $selector);
+ $this->storeRememberMeSelector($selector->selector, $selector->hash, $selector->expiry, $token->user_id);
+
+ $login = $selector->selector;
+ $password = $selector->verifier;
+
+ return compact('login', 'password');
+ }
+
+
+ public function createAppCredentials(): \stdClass
+ {
+ if (!$this->isLogged()) {
+ throw new \LogicException('User is not logged');
+ }
+
+ $user = $this->getUser();
+ $token = base_convert(sha1(random_bytes(10)), 16, 36);
+ $selector = 'app_' . substr($token, 0, 16);
+ $selector = $this->createSelectorValues($user->id, $user->password, null, $selector);
+ $this->storeRememberMeSelector($selector->selector, $selector->hash, $selector->expiry, $user->id);
+
+ $login = $selector->selector;
+ $password = $selector->verifier;
+ $redirect = sprintf(NextCloud::AUTH_REDIRECT_URL, WWW_URL, $login, $password);
+
+ return (object) compact('login', 'password', 'redirect');
+ }
+
+ public function checkAppCredentials(string $login, string $password): ?User
+ {
+ $selector = $this->getRememberMeSelector($login);
+
+ if (!$selector) {
+ return null;
+ }
+
+ if (!$this->checkRememberMeSelector($selector, $password)) {
+ $this->deleteRememberMeSelector($selector->selector);
+ return null;
+ }
+
+ $this->_user = Users::get($selector->user_id);
+
+ if (!$this->_user) {
+ return null;
+ }
+
+ $this->user = $selector->user_id;
+
+ return $this->_user;
+ }
+}
diff --git a/src/include/lib/Paheko/Files/WebDAV/Storage.php b/src/include/lib/Paheko/Files/WebDAV/Storage.php
new file mode 100644
index 0000000..72fd15a
--- /dev/null
+++ b/src/include/lib/Paheko/Files/WebDAV/Storage.php
@@ -0,0 +1,500 @@
+session = $session;
+ $this->nextcloud = $nextcloud;
+ }
+
+ protected function populateRootCache(): void
+ {
+ if (isset($this->cache)) {
+ return;
+ }
+
+ $access = Files::listReadAccessContexts($this->session);
+
+ $this->cache = ['' => Files::get('')];
+
+ foreach ($access as $context => $name) {
+ $this->cache[$context] = Files::get($context);
+ $this->root[] = $context;
+ }
+ }
+
+ protected function load(string $uri)
+ {
+ $this->populateRootCache();
+
+ $uri = $uri ?: null;
+
+ if (!isset($this->cache[$uri])) {
+ $this->cache[$uri] = Files::get($uri);
+
+ if (!$this->cache[$uri]) {
+ return null;
+ }
+ }
+
+ return $this->cache[$uri];
+ }
+
+ /**
+ * @extends
+ */
+ public function list(string $uri, ?array $properties): iterable
+ {
+ $this->populateRootCache();
+
+ if (!$uri) {
+ foreach ($this->root as $name) {
+ yield $name => null;
+ }
+ return;
+ }
+
+ $file = $this->load($uri);
+
+ if (!$file) {
+ return null;
+ }
+
+ if ($file->type != $file::TYPE_DIRECTORY) {
+ return;
+ }
+
+ foreach (Files::list($uri) as $file) {
+ $path = $uri . '/' . $file->name;
+ $this->cache[$path] = $file;
+ yield $file->name => null;
+ }
+ }
+
+ /**
+ * @extends
+ */
+ public function get(string $uri): ?array
+ {
+ $file = $this->load($uri);
+
+ if (!$file) {
+ throw new WebDAV_Exception('File Not Found', 404);
+ }
+
+ if (!$file->canRead($this->session)) {
+ throw new WebDAV_Exception('Vous n\'avez pas accès à ce chemin', 403);
+ }
+
+ $type = $file->type;
+
+ // Serve files
+ if ($type == File::TYPE_DIRECTORY) {
+ return null;
+ }
+
+ $path = $file->getLocalFilePath();
+
+ if ($path && Router::isXSendFileEnabled()) {
+ Router::xSendFile($path);
+ return ['stop' => true];
+ }
+
+ $pointer = $file->getReadOnlyPointer();
+
+ // We trust the WebDAV server to be more efficient that File::serve
+ // with serving a file for WebDAV clients
+ if (!$pointer && $path) {
+ return ['path' => $path];
+ }
+ elseif (!$pointer) {
+ throw new WebDAV_Exception('File Content not found', 404);
+ }
+
+ return ['resource' => $pointer];
+ }
+
+ /**
+ * @extends
+ */
+ public function exists(string $uri): bool
+ {
+ $this->populateRootCache();
+
+ if (isset($this->cache[$uri])) {
+ return true;
+ }
+
+ return Files::exists($uri);
+ }
+
+ protected function get_file_property(string $uri, string $name, int $depth)
+ {
+ $file = $this->load($uri);
+ $is_dir = $file->type == File::TYPE_DIRECTORY;
+
+ if (!$file) {
+ throw new \LogicException('File does not exist');
+ }
+
+ switch ($name) {
+ case 'DAV::getcontentlength':
+ return $is_dir ? null : $file->size;
+ case 'DAV::getcontenttype':
+ return $is_dir ? null : $file->mime;
+ case 'DAV::resourcetype':
+ return $is_dir ? 'collection' : '';
+ case 'DAV::getlastmodified':
+ return $file->modified ?? null;
+ case 'DAV::displayname':
+ return $file->name;
+ case 'DAV::ishidden':
+ return false;
+ case 'DAV::getetag':
+ return $file->etag();
+ case 'DAV::lastaccessed':
+ return null;
+ case 'DAV::creationdate':
+ return $file->modified ?? null;
+ case WebDAV::PROP_DIGEST_MD5:
+ if ($file->type != File::TYPE_FILE) {
+ return null;
+ }
+
+ return $file->md5 ?? null;
+ // NextCloud stuff
+ case NextCloud::PROP_NC_HAS_PREVIEW:
+ return $file->image ? 'true' : 'false';
+ case NextCloud::PROP_NC_IS_ENCRYPTED:
+ return 'false';
+ case NextCloud::PROP_OC_SHARETYPES:
+ return WebDAV::EMPTY_PROP_VALUE;
+ case NextCloud::PROP_OC_DOWNLOADURL:
+ return $this->nextcloud->getDirectDownloadURL($uri, $this->session::getUserId());
+ case Nextcloud::PROP_NC_RICH_WORKSPACE:
+ return '';
+ case NextCloud::PROP_OC_ID:
+ // fileId is required by NextCloud desktop client
+ if (!isset($file->id)) {
+ // Root directory doesn't have a ID, give something random instead
+ return 10000000;
+ }
+
+ return $file->id();
+ case NextCloud::PROP_OC_PERMISSIONS:
+ $permissions = [
+ NextCloud::PERM_READ => $file->canRead($this->session),
+ NextCloud::PERM_WRITE => $file->canWrite($this->session),
+ NextCloud::PERM_DELETE => $file->canDelete($this->session),
+ NextCloud::PERM_RENAME => $file->canRename($this->session),
+ NextCloud::PERM_MOVE => $file->canRename($this->session),
+ NextCloud::PERM_CREATE_FILES_DIRS => $file->canCreateHere($this->session),
+ ];
+
+ $permissions = array_filter($permissions, fn($a) => $a);
+ return implode('', array_keys($permissions));
+ case 'DAV::quota-available-bytes':
+ return Files::getRemainingQuota();
+ case 'DAV::quota-used-bytes':
+ return Files::getUsedQuota();
+ case NextCloud::PROP_OC_SIZE:
+ return $file->getRecursiveSize();
+ case WOPI::PROP_USER_NAME:
+ return $this->session->getUser()->name();
+ case WOPI::PROP_USER_ID:
+ return $this->session->getUser()->id;
+ case WOPI::PROP_READ_ONLY:
+ return $file->canWrite($this->session) ? false : true;
+ case WOPI::PROP_FILE_URL:
+ $id = gzcompress($uri);
+ $id = WOPI::base64_encode_url_safe($id);
+ return BASE_URL . 'wopi/files/' . $id;
+ default:
+ break;
+ }
+
+ return null;
+ }
+
+ /**
+ * @extends
+ */
+ public function propfind(string $uri, ?array $properties, int $depth): ?array
+ {
+ $this->populateRootCache();
+ $file = $this->load($uri);
+
+ if (!$file) {
+ return null;
+ }
+
+ if (null === $properties) {
+ $properties = array_merge(WebDAV::BASIC_PROPERTIES, ['DAV::getetag', NextCloud::PROP_OC_ID]);
+ }
+
+ $out = [];
+
+ // Generate a new token for WOPI, and provide also TTL
+ if (in_array(WOPI::PROP_TOKEN, $properties)) {
+ $out = $this->createWopiToken($uri);
+ unset($properties[WOPI::PROP_TOKEN], $properties[WOPI::PROP_TOKEN_TTL]);
+ }
+
+ foreach ($properties as $name) {
+ $v = $this->get_file_property($uri, $name, $depth);
+
+ if (null !== $v) {
+ $out[$name] = $v;
+ }
+ }
+
+ return $out;
+ }
+
+ public function put(string $uri, $pointer, ?string $hash_algo, ?string $hash): bool
+ {
+ if (!strpos($uri, '/')) {
+ throw new WebDAV_Exception('Impossible de créer un fichier ici', 403);
+ }
+
+ if (preg_match(self::PUT_IGNORE_PATTERN, basename($uri))) {
+ return false;
+ }
+
+ $target = Files::get($uri);
+
+ if ($target && $target->type === $target::TYPE_DIRECTORY) {
+ throw new WebDAV_Exception('Target is a directory', 409);
+ }
+
+ $new = !$target ? true : false;
+
+ if ($new && !File::canCreate($uri, $this->session)) {
+ throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de créer ce fichier', 403);
+ }
+ elseif (!$new && !$target->canWrite($this->session)) {
+ throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de modifier ce fichier', 403);
+ }
+
+ $h = $hash ? hash_init($hash_algo == 'MD5' ? 'md5' : 'sha1') : null;
+
+ while (!feof($pointer)) {
+ if ($h) {
+ hash_update($h, fread($pointer, 8192));
+ }
+ else {
+ fread($pointer, 8192);
+ }
+ }
+
+ if ($h) {
+ if (hash_final($h) != $hash) {
+ throw new WebDAV_Exception('The data sent does not match the supplied hash', 400);
+ }
+ }
+
+ // Check size
+ $size = ftell($pointer);
+
+ try {
+ Files::checkQuota($size);
+ }
+ catch (ValidationException $e) {
+ throw new WebDAV_Exception($e->getMessage(), 403);
+ }
+
+ rewind($pointer);
+
+ if ($new) {
+ $target = Files::createFromPointer($uri, $pointer);
+ }
+ else {
+ $target->store(compact('pointer'));
+ }
+
+ return $new;
+ }
+
+ /**
+ * @extends
+ */
+ public function delete(string $uri): void
+ {
+ if (!strpos($uri, '/')) {
+ throw new WebDAV_Exception('Ce répertoire ne peut être supprimé', 403);
+ }
+
+ $target = Files::get($uri);
+
+ if (!$target) {
+ throw new WebDAV_Exception('This file does not exist', 404);
+ }
+
+ if (!$target->canDelete($this->session)) {
+ throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de supprimer ce fichier', 403);
+ }
+
+ $target->moveToTrash();
+ }
+
+ protected function copymove(bool $move, string $uri, string $destination): bool
+ {
+ if (!strpos($uri, '/')) {
+ throw new WebDAV_Exception('Ce répertoire ne peut être modifié', 403);
+ }
+
+ $source = Files::get($uri);
+
+ if (!$source) {
+ throw new WebDAV_Exception('File not found', 404);
+ }
+
+ if (!$source->canMoveTo($destination, $this->session)) {
+ throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de déplacer ce fichier', 403);
+ }
+
+ if (!$move) {
+ if ($source->size > Files::getRemainingQuota()) {
+ throw new WebDAV_Exception('Your quota is exhausted', 403);
+ }
+ }
+
+ $overwritten = Files::exists($destination);
+
+ if ($overwritten) {
+ $this->delete($destination);
+ }
+
+ $method = $move ? 'rename' : 'copy';
+
+ $source->$method($destination);
+
+ return $overwritten;
+ }
+
+ /**
+ * @extends
+ */
+ public function copy(string $uri, string $destination): bool
+ {
+ return $this->copymove(false, $uri, $destination);
+ }
+
+ /**
+ * @extends
+ */
+ public function move(string $uri, string $destination): bool
+ {
+ return $this->copymove(true, $uri, $destination);
+ }
+
+ /**
+ * @extends
+ */
+ public function mkcol(string $uri): void
+ {
+ if (!strpos($uri, '/')) {
+ throw new WebDAV_Exception('Impossible de créer un répertoire ici', 403);
+ }
+
+ if (!File::canCreateDir($uri)) {
+ throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de créer un répertoire ici', 403);
+ }
+
+ if (Files::exists($uri)) {
+ throw new WebDAV_Exception('There is already a file with that name', 405);
+ }
+
+ if (!Files::exists(Utils::dirname($uri))) {
+ throw new WebDAV_Exception('The parent directory does not exist', 409);
+ }
+
+ Files::mkdir($uri);
+ }
+
+ /**
+ * @extends
+ */
+ public function touch(string $uri, \DateTimeInterface $datetime): bool
+ {
+ $file = Files::get($uri);
+
+ if (!$file) {
+ return false;
+ }
+
+ $file->touch($datetime);
+ return true;
+ }
+
+ protected function createWopiToken(string $uri)
+ {
+ $ttl = time()+(3600*10);
+ $session_id = $this->session->id();
+ $hash = WebDAV::hmac(compact('uri', 'ttl', 'session_id'), SECRET_KEY);
+ $data = sprintf('%s_%s_%s', $hash, $session_id, $ttl);
+
+ return [
+ WOPI::PROP_TOKEN => WOPI::base64_encode_url_safe($data),
+ WOPI::PROP_TOKEN_TTL => $ttl * 1000,
+ ];
+ }
+
+ public function getWopiURI(string $id, string $token): ?string
+ {
+ $id = WOPI::base64_decode_url_safe($id);
+ $uri = gzuncompress($id);
+ $token_decode = WOPI::base64_decode_url_safe($token);
+ $hash = strtok($token_decode, '_');
+ $session_id = strtok('_');
+ $ttl = (int) strtok('');
+ $check = WebDAV::hmac(compact('uri', 'ttl', 'session_id'), SECRET_KEY);
+
+ if (!hash_equals($hash, $check)) {
+ return null;
+ }
+
+ if ($ttl < time()) {
+ return null;
+ }
+
+ $this->session->setId($session_id);
+ $this->session->start(true);
+
+ if (!$this->session->isLogged()) {
+ return null;
+ }
+
+ return $uri;
+ }
+}
diff --git a/src/include/lib/Paheko/Files/WebDAV/WebDAV.php b/src/include/lib/Paheko/Files/WebDAV/WebDAV.php
new file mode 100644
index 0000000..b6bf7ff
--- /dev/null
+++ b/src/include/lib/Paheko/Files/WebDAV/WebDAV.php
@@ -0,0 +1,46 @@
+
+
+ ',
+ ADMIN_URL);
+ $out = str_replace('', $body, $out);
+ }
+
+ return $out;
+ }
+
+ public function log(string $message, ...$params)
+ {
+ Router::log('DAV: ' . $message, ...$params);
+ }
+}
diff --git a/src/include/lib/Paheko/Form.php b/src/include/lib/Paheko/Form.php
new file mode 100644
index 0000000..9b12366
--- /dev/null
+++ b/src/include/lib/Paheko/Form.php
@@ -0,0 +1,129 @@
+addError('Le fichier envoyé dépasse la taille autorisée');
+ }
+ }
+
+ public function run(callable $fn, ?string $csrf_key = null, ?string $redirect = null, bool $follow_redirect = false): bool
+ {
+ $js = false !== strpos($_SERVER['HTTP_ACCEPT'] ?? '', '/json');
+
+ try {
+ if (null !== $csrf_key && !\KD2\Form::tokenCheck($csrf_key)) {
+ throw new ValidationException('Une erreur est survenue, merci de bien vouloir renvoyer le formulaire.', 401);
+ }
+
+ call_user_func($fn);
+
+ if (null !== $redirect) {
+ if ($js) {
+ http_response_code(204);
+ exit;
+ }
+ elseif (array_key_exists('_dialog', $_GET)) {
+ Utils::reloadParentFrame($follow_redirect ? $redirect : null);
+ }
+
+ Utils::redirect($redirect);
+ }
+
+ return true;
+ }
+ catch (UserException $e) {
+ Form::reportUserException($e);
+
+ if ($js) {
+ http_response_code($e->getCode() >= 400 ? $e->getCode() : 400);
+ header('Content-Type: application/json; charset="utf-8"', true);
+ echo json_encode(['message' => $e->getMessage()]);
+ exit;
+ }
+
+ $this->addError($e);
+
+ return false;
+ }
+ }
+
+ static public function reportUserException(UserException $e): void
+ {
+ if (REPORT_USER_EXCEPTIONS === 2) {
+ throw $e;
+ }
+ elseif (REPORT_USER_EXCEPTIONS === 1) {
+ \KD2\ErrorManager::reportExceptionSilent($e);
+ }
+ }
+
+ public function runIf($condition, callable $fn, ?string $csrf_key = null, ?string $redirect = null, bool $follow_redirect = false): ?bool
+ {
+ if (is_string($condition) && empty($_POST[$condition])) {
+ return null;
+ }
+ elseif (is_bool($condition) && !$condition) {
+ return null;
+ }
+
+ return $this->run($fn, $csrf_key, $redirect, $follow_redirect);
+ }
+
+ public function hasErrors()
+ {
+ return (count($this->errors) > 0);
+ }
+
+ public function &getErrors()
+ {
+ return $this->errors;
+ }
+
+ public function addError($msg)
+ {
+ $this->errors[] = $msg;
+ }
+
+ public function getErrorMessages()
+ {
+ return $this->errors;
+ }
+
+ public function __invoke($key)
+ {
+ return \KD2\Form::get($key);
+ }
+
+ /**
+ * Returns a value from a custom list selector
+ * see CommonFunctions::input
+ */
+ static public function getSelectorValue($value) {
+ if (!is_array($value)) {
+ return $value;
+ }
+
+ $values = array_filter(array_keys($value));
+
+ if (count($values) == 1) {
+ return current($values);
+ }
+ elseif (!count($values)) {
+ return ''; // Empty
+ }
+ else {
+ return $values;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Install.php b/src/include/lib/Paheko/Install.php
new file mode 100644
index 0000000..e91969b
--- /dev/null
+++ b/src/include/lib/Paheko/Install.php
@@ -0,0 +1,494 @@
+query('PRAGMA compile_options;');
+
+ while ($row = $res->fetchArray(\SQLITE3_NUM)) {
+ if (0 !== strpos($row[0], 'ENABLE_')) {
+ continue;
+ }
+
+ $options .= substr($row[0], strlen('ENABLE_')) . ',';
+ }
+
+ (new HTTP)->POST(PING_URL, [
+ 'id' => sha1(WWW_URL . SECRET_KEY . ROOT),
+ 'version' => paheko_version(),
+ 'sqlite' => \SQLite3::version()['versionString'],
+ 'php' => PHP_VERSION,
+ 'sqlite_options' => trim($options, ', '),
+ ]);
+ }
+
+ /**
+ * Reset the database to empty and create a new user with the same password
+ */
+ static public function reset(Session $session, string $password, array $options = [])
+ {
+ $config = (object) Config::getInstance()->asArray();
+ $user = $session->getUser();
+
+ if (!$session->checkPassword($password, $user->password)) {
+ throw new UserException('Le mot de passe ne correspond pas.');
+ }
+
+ if (!trim($config->org_name)) {
+ throw new UserException('Le nom de l\'association est vide, merci de le renseigner dans la configuration.');
+ }
+
+ if (!trim($user->name())) {
+ throw new UserException('L\'utilisateur connecté ne dispose pas de nom, merci de le renseigner.');
+ }
+
+ if (!trim($user->email())) {
+ throw new UserException('L\'utilisateur connecté ne dispose pas d\'adresse e-mail, merci de la renseigner.');
+ }
+
+ $name = date('Y-m-d-His-') . 'avant-remise-a-zero';
+
+ Backup::create($name);
+
+ // Keep a backup file of files
+ if (FILE_STORAGE_BACKEND == 'FileSystem') {
+ $name = 'documents_' . $name . '.zip';
+ Files::zipAll(CACHE_ROOT . '/' . $name);
+ Files::callStorage('truncate');
+ @mkdir(FILE_STORAGE_CONFIG . '/documents');
+ @rename(CACHE_ROOT . '/' . $name, FILE_STORAGE_CONFIG . '/documents/' . $name);
+ }
+
+ Config::deleteInstance();
+ DB::getInstance()->close();
+ DB::deleteInstance();
+
+ file_put_contents(CACHE_ROOT . '/reset', json_encode([
+ 'password' => $session::hashPassword($password),
+ 'name' => $user->name(),
+ 'email' => $user->email(),
+ 'organization' => $config->org_name,
+ 'country' => $config->country,
+ ]));
+
+ rename(DB_FILE, sprintf(DATA_ROOT . '/association.%s.sqlite', date('Y-m-d-His-') . 'avant-remise-a-zero'));
+
+ self::showProgressSpinner('!install.php', 'Remise à zéro en cours…');
+ exit;
+ }
+
+ /**
+ * Continues reset after page reload
+ */
+ static public function checkReset()
+ {
+ if (!file_exists(CACHE_ROOT . '/reset')) {
+ return;
+ }
+
+ $data = json_decode(file_get_contents(CACHE_ROOT . '/reset'));
+
+ if (!$data) {
+ throw new \LogicException('Invalid reset data');
+ }
+
+ try {
+ // We can't use the real password, as it might not be valid (too short or compromised)
+ self::install($data->country ?? 'FR', $data->organization ?? 'Association', $data->name, $data->email, md5($data->password));
+
+ // Restore password
+ DB::getInstance()->preparedQuery('UPDATE users SET password = ? WHERE id = 1;', $data->password);
+ $session = Session::getInstance();
+ $session->logout();
+ $session->forceLogin(1);
+ }
+ catch (\Exception $e) {
+ Config::deleteInstance();
+ DB::getInstance()->close();
+ DB::deleteInstance();
+ Utils::safe_unlink(DB_FILE);
+ throw $e;
+ }
+
+ @unlink(CACHE_ROOT . '/reset');
+
+ Utils::redirect('!config/advanced/?msg=RESET');
+ }
+
+ static protected function assert(bool $assertion, string $message)
+ {
+ if (!$assertion) {
+ throw new ValidationException($message);
+ }
+ }
+
+ static public function installFromForm(array $source = null): void
+ {
+ if (null === $source) {
+ $source = $_POST;
+ }
+
+ self::assert(isset($source['name']) && trim($source['name']) !== '', 'Le nom de l\'association n\'est pas renseigné');
+
+ if (is_array(LOCAL_LOGIN)) {
+ $source['user_name'] = LOCAL_LOGIN['user']['_name'] ?? 'Administrateur';
+ $source['password'] = sha1(random_bytes(10));
+ $source['user_email'] = 'administrateur@association.example';
+ }
+ else {
+ self::assert(isset($source['user_name']) && trim($source['user_name']) !== '', 'Le nom du membre n\'est pas renseigné');
+ self::assert(isset($source['user_email']) && trim($source['user_email']) !== '', 'L\'adresse email du membre n\'est pas renseignée');
+ self::assert(isset($source['password']) && isset($source['password_confirmed']) && trim($source['password']) !== '', 'Le mot de passe n\'est pas renseigné');
+
+ self::assert((bool)filter_var($source['user_email'], FILTER_VALIDATE_EMAIL), 'Adresse email invalide');
+
+ self::assert(strlen($source['password']) >= User::MINIMUM_PASSWORD_LENGTH, 'Le mot de passe est trop court');
+ self::assert($source['password'] === $source['password_confirmed'], 'La vérification du mot de passe ne correspond pas');
+ }
+
+ try {
+ self::install($source['country'], $source['name'], $source['user_name'], $source['user_email'], $source['password']);
+ self::ping();
+ }
+ catch (\Exception $e) {
+ @unlink(DB_FILE);
+ throw $e;
+ }
+ }
+
+ static public function install(string $country_code, string $name, string $user_name, string $user_email, string $user_password): void
+ {
+ if (file_exists(DB_FILE)) {
+ throw new UserException('La base de données existe déjà.');
+ }
+
+ self::checkAndCreateDirectories();
+ Files::disableQuota();
+ $db = DB::getInstance();
+
+ $db->requireFeatures('cte', 'json_patch', 'fts4', 'date_functions_in_constraints', 'index_expressions', 'rename_column', 'upsert');
+
+ // Création de la base de données
+ $db->begin();
+ $db->exec('PRAGMA application_id = ' . DB::APPID . ';');
+ $db->setVersion(paheko_version());
+ $db->exec(file_get_contents(DB_SCHEMA));
+ $db->commit();
+
+ file_put_contents(SHARED_CACHE_ROOT . '/version', paheko_version());
+
+ $currency = $country_code == 'CH' ? 'CHF' : '€';
+
+ // Configuration de base
+ $config = Config::getInstance();
+ $config->setCreateFlag();
+ $config->import([
+ 'org_name' => $name,
+ 'org_email' => $user_email,
+ 'currency' => $currency,
+ 'country' => $country_code,
+ 'site_disabled' => true,
+ 'log_retention' => 365,
+ 'auto_logout' => 2*60,
+ 'analytical_set_all' => true,
+ 'file_versioning_policy' => 'min',
+ 'file_versioning_max_size' => 2,
+ ]);
+
+ $fields = DynamicFields::getInstance();
+ $fields->install();
+
+ // Create default category for common users
+ $cat = new Category;
+ $cat->setAllPermissions(Session::ACCESS_NONE);
+ $cat->importForm([
+ 'name' => 'Membres actifs',
+ 'perm_connect' => Session::ACCESS_READ,
+ ]);
+ $cat->save();
+
+ $config->set('default_category', $cat->id());
+
+ // Create default category for ancient users
+ $cat = new Category;
+ $cat->importForm([
+ 'name' => 'Anciens membres',
+ 'hidden' => 1,
+ ]);
+ $cat->setAllPermissions(Session::ACCESS_NONE);
+ $cat->save();
+
+ // Create default category for admins
+ $cat = new Category;
+ $cat->importForm([
+ 'name' => 'Administrateurs',
+ ]);
+ $cat->setAllPermissions(Session::ACCESS_ADMIN);
+ $cat->save();
+
+ // Create first user
+ $user = new User;
+ $user->set('id_category', $cat->id());
+ $user->importForm([
+ 'numero' => 1,
+ 'nom' => $user_name,
+ 'email' => $user_email,
+ 'pays' => 'FR',
+ ]);
+
+ $user->importSecurityForm(false, [
+ 'password' => $user_password,
+ 'password_confirmed' => $user_password,
+ ]);
+
+ $user->save();
+
+ $config->set('files', array_map(fn () => null, $config::FILES));
+
+ $welcome_text = sprintf("Bienvenue dans l'administration de %s !\n\nUtilisez le menu à gauche pour accéder aux différentes sections.\n\nSi vous êtes perdu, n'hésitez pas à consulter l'aide :-)", $name);
+
+ $config->setFile('admin_homepage', $welcome_text);
+
+ // Import accounting chart
+ $chart = Charts::installCountryDefault($country_code);
+
+ // Create an example saved search (users)
+ $query = (object) [
+ 'groups' => [[
+ 'operator' => 'AND',
+ 'conditions' => [
+ [
+ 'column' => 'lettre_infos',
+ 'operator' => '= 1',
+ 'values' => [],
+ ],
+ ],
+ ]],
+ 'order' => 'numero',
+ 'desc' => true,
+ 'limit' => '10000',
+ ];
+
+ $search = new Search;
+ $search->import([
+ 'label' => 'Inscrits à la lettre d\'information',
+ 'target' => $search::TARGET_USERS,
+ 'type' => $search::TYPE_JSON,
+ 'content' => json_encode($query),
+ ]);
+ $search->created = new \DateTime;
+ $search->save();
+
+ // Create an example saved search (accounting)
+ $query = (object) [
+ 'groups' => [[
+ 'operator' => 'AND',
+ 'conditions' => [
+ [
+ 'column' => 'p.code',
+ 'operator' => 'IS NULL',
+ 'values' => [],
+ ],
+ ],
+ ]],
+ 'order' => 't.id',
+ 'desc' => false,
+ 'limit' => '100',
+ ];
+
+
+ $search = new Search;
+ $search->import([
+ 'label' => 'Écritures sans projet',
+ 'target' => $search::TARGET_ACCOUNTING,
+ 'type' => $search::TYPE_JSON,
+ 'content' => json_encode($query),
+ ]);
+ $search->created = new \DateTime;
+ $search->save();
+
+ $config->save();
+
+ Plugins::refresh();
+ Modules::refresh();
+
+ // Install welcome plugin if available
+ $has_welcome_plugin = Plugins::exists('welcome');
+
+ if ($has_welcome_plugin) {
+ Plugins::install('welcome');
+ }
+
+ if (FILE_STORAGE_BACKEND != 'SQLite') {
+ Storage::sync();
+ }
+
+ Files::enableQuota();
+ }
+
+ static public function checkAndCreateDirectories()
+ {
+ // Vérifier que les répertoires vides existent, sinon les créer
+ $paths = [
+ DATA_ROOT,
+ CACHE_ROOT,
+ SHARED_CACHE_ROOT,
+ USER_TEMPLATES_CACHE_ROOT,
+ STATIC_CACHE_ROOT,
+ SMARTYER_CACHE_ROOT,
+ SHARED_USER_TEMPLATES_CACHE_ROOT,
+ ];
+
+ foreach ($paths as $path)
+ {
+ $index_file = $path . '/index.html';
+ Utils::safe_mkdir($path, 0777, true);
+
+ if (!is_dir($path))
+ {
+ throw new \RuntimeException('Le répertoire '.$path.' n\'existe pas ou n\'est pas un répertoire.');
+ }
+
+ // On en profite pour vérifier qu'on peut y lire et écrire
+ if (!is_writable($path) || !is_readable($path))
+ {
+ throw new \RuntimeException('Le répertoire '.$path.' n\'est pas accessible en lecture/écriture.');
+ }
+ if (file_exists($index_file) AND (!is_writable($index_file) || !is_readable($index_file))) {
+ throw new \RuntimeException('Le fichier ' . $index_file . ' n\'est pas accessible en lecture/écriture.');
+ }
+
+ // Some basic safety against misconfigured hosts
+ file_put_contents($index_file, '404 Not Found Not Found The requested URL was not found on this server.
');
+ }
+
+ return true;
+ }
+
+ static public function setLocalConfig(string $key, $value, bool $overwrite = true): void
+ {
+ $path = ROOT . DIRECTORY_SEPARATOR . CONFIG_FILE;
+
+ if (!is_writable(ROOT)) {
+ throw new \RuntimeException('Impossible de créer le fichier de configuration "'. CONFIG_FILE .'". Le répertoire "'. ROOT . '" n\'est pas accessible en écriture.');
+ }
+
+ $new_line = sprintf('const %s = %s;', $key, var_export($value, true));
+
+ if (@filesize($path)) {
+ $config = file_get_contents($path);
+
+ $pattern = sprintf('/^.*(?:const\s+%s|define\s*\(.*%1$s).*$/m', $key);
+
+ $config = preg_replace($pattern, $new_line, $config, -1, $count);
+
+ if ($count && !$overwrite) {
+ return;
+ }
+
+ if (!$count) {
+ $config = preg_replace('/\?>.*/s', '', $config);
+ $config .= PHP_EOL . $new_line . PHP_EOL;
+ }
+ }
+ else {
+ $config = '', Utils::getLocalURL($next)) : '';
+
+ printf('
+
+
+
+
+ %s
+
+
+
+
%s
+ ', $next, nl2br(htmlspecialchars($message)));
+
+ flush();
+ }
+}
diff --git a/src/include/lib/Paheko/Log.php b/src/include/lib/Paheko/Log.php
new file mode 100644
index 0000000..a42859e
--- /dev/null
+++ b/src/include/lib/Paheko/Log.php
@@ -0,0 +1,202 @@
+ 'Connexion refusée',
+ self::LOGIN_SUCCESS => 'Connexion réussie',
+ self::LOGIN_RECOVER => 'Mot de passe perdu',
+ self::LOGIN_PASSWORD_CHANGE => 'Modification de mot de passe',
+ self::LOGIN_CHANGE => 'Modification d\'identifiant',
+ self::LOGIN_AS => 'Connexion par un administrateur',
+
+ self::CREATE => 'Création',
+ self::DELETE => 'Suppression',
+ self::EDIT => 'Modification',
+ self::SENT => 'Envoi',
+
+ self::MESSAGE => '',
+ ];
+
+ static public function add(int $type, ?array $details = null, int $id_user = null): void
+ {
+ if (defined('Paheko\INSTALL_PROCESS')) {
+ return;
+ }
+
+ if ($type != self::LOGIN_FAIL) {
+ $keep = Config::getInstance()->log_retention;
+
+ // Don't log anything
+ if ($keep == 0) {
+ return;
+ }
+ }
+
+ if (isset($details['entity'])) {
+ $details['entity'] = str_replace('Paheko\Entities\\', '', $details['entity']);
+ }
+
+ $ip = Utils::getIP();
+ $session = Session::getInstance();
+ $id_user ??= Session::getUserId();
+
+ DB::getInstance()->insert('logs', [
+ 'id_user' => $id_user,
+ 'type' => $type,
+ 'details' => $details ? json_encode($details) : null,
+ 'ip_address' => $ip,
+ 'created' => new \DateTime,
+ ]);
+ }
+
+ static public function clean(): void
+ {
+ $config = Config::getInstance();
+ $db = DB::getInstance();
+
+ $days_delete = $config->log_retention;
+
+ // Delete old logs according to configuration
+ $db->exec(sprintf('DELETE FROM logs
+ WHERE type != %d AND type != %d AND created < datetime(\'now\', \'-%d days\');',
+ self::LOGIN_FAIL, self::LOGIN_RECOVER, $days_delete));
+
+ // Delete failed login attempts and reminders after 30 days
+ $db->exec(sprintf('DELETE FROM logs WHERE type = %d OR type = %d AND created < datetime(\'now\', \'-%d days\');',
+ self::LOGIN_FAIL, self::LOGIN_RECOVER, 30));
+ }
+
+ /**
+ * Returns TRUE if the current IP address has done too many failed login attempts
+ * @return int 1 if banned from logging in, -1 if a captcha should be presented, 0 if no restriction is in place
+ */
+ static public function isLocked(): int
+ {
+ $ip = Utils::getIP();
+
+ // is IP locked out?
+ $sql = sprintf('SELECT COUNT(*) FROM logs WHERE type = ? AND ip_address = ? AND created > datetime(\'now\', \'-%d seconds\');', self::LOCKOUT_DELAY);
+ $count = DB::getInstance()->firstColumn($sql, self::LOGIN_FAIL, $ip);
+
+ if ($count >= self::LOCKOUT_ATTEMPTS) {
+ return 1;
+ }
+
+ if ($count >= self::SOFT_LOCKOUT_ATTEMPTS) {
+ return -1;
+ }
+
+ return 0;
+ }
+
+ static public function list(array $params = []): DynamicList
+ {
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+
+ $columns = [
+ 'id_user' => [
+ ],
+ 'created' => [
+ 'label' => 'Date'
+ ],
+ 'identity' => [
+ 'label' => isset($params['id_self']) ? null : (isset($params['history']) ? 'Membre à l\'origine de la modification' : 'Membre'),
+ 'select' => $id_field,
+ ],
+ 'type_icon' => [
+ 'select' => null,
+ 'order' => null,
+ 'label' => '',
+ ],
+ 'type' => [
+ 'label' => 'Action',
+ ],
+ 'details' => [
+ 'label' => 'Détails',
+ ],
+ 'ip_address' => [
+ 'label' => 'Adresse IP',
+ ],
+ ];
+
+ $tables = 'logs LEFT JOIN users u ON u.id = logs.id_user';
+
+ if (isset($params['id_user'])) {
+ $conditions = 'logs.id_user = ' . (int)$params['id_user'];
+ }
+ elseif (isset($params['id_self'])) {
+ $conditions = sprintf('logs.id_user = %d AND type < 10', (int)$params['id_self']);
+ }
+ elseif (isset($params['history'])) {
+ $conditions = sprintf('logs.type IN (%d, %d, %d) AND json_extract(logs.details, \'$.entity\') = \'Users\\User\' AND json_extract(logs.details, \'$.id\') = %d', self::CREATE, self::EDIT, self::DELETE, (int)$params['history']);
+ }
+ else {
+ $conditions = '1';
+ }
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('created', true);
+ $list->setCount('COUNT(logs.id)');
+ $list->setModifier(function (&$row) {
+ //$row->created = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->created);
+ $row->details = $row->details ? json_decode($row->details) : null;
+ $row->type_label = $row->type == self::MESSAGE ? ($row->details->message ?? '') : self::ACTIONS[$row->type];
+
+ if (isset($row->details->entity)) {
+ $const = 'Paheko\Entities\\' . $row->details->entity . '::NAME';
+
+ if (defined($const)
+ && ($value = constant($const))) {
+ $row->entity_name = $value;
+ }
+
+ $const = 'Paheko\Entities\\' . $row->details->entity . '::PRIVATE_URL';
+
+ if (isset($row->details->id, $row->details->entity)
+ && $row->type !== self::DELETE
+ && defined($const)
+ && ($value = constant($const))) {
+ $row->entity_url = sprintf($value, $row->details->id);
+ }
+ }
+ });
+
+ return $list;
+ }
+}
diff --git a/src/include/lib/Paheko/Plugins.php b/src/include/lib/Paheko/Plugins.php
new file mode 100644
index 0000000..c55074c
--- /dev/null
+++ b/src/include/lib/Paheko/Plugins.php
@@ -0,0 +1,364 @@
+ 'text/css',
+ 'gif' => 'image/gif',
+ 'htm' => 'text/html',
+ 'html' => 'text/html',
+ 'ico' => 'image/x-ico',
+ 'jpe' => 'image/jpeg',
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'js' => 'application/javascript',
+ 'pdf' => 'application/pdf',
+ 'png' => 'image/png',
+ 'xml' => 'text/xml',
+ 'svg' => 'image/svg+xml',
+ 'webp' => 'image/webp',
+ 'md' => 'text/x-markdown',
+ ];
+
+ /**
+ * Set to false to disable signal firing
+ * @var boolean
+ */
+ static protected $signals = true;
+
+ static public function toggleSignals(bool $enabled)
+ {
+ self::$signals = $enabled;
+ }
+
+ static public function getPrivateURL(string $id, string $path = '')
+ {
+ return ADMIN_URL . 'p/' . $id . '/' . ltrim($path, '/');
+ }
+
+ static public function getPublicURL(string $id, string $path = '')
+ {
+ return WWW_URL . 'p/' . $id . '/' . ltrim($path, '/');
+ }
+
+ static public function getPath(string $name): ?string
+ {
+ if (file_exists(PLUGINS_ROOT . '/' . $name)) {
+ return PLUGINS_ROOT . '/' . $name;
+ }
+ elseif (file_exists(PLUGINS_ROOT . '/' . $name . '.tar.gz')) {
+ return 'phar://' . PLUGINS_ROOT . '/' . $name . '.tar.gz';
+ }
+
+ return null;
+ }
+
+ static public function routeStatic(string $name, string $uri): bool
+ {
+ $path = self::getPath($name);
+
+ if (!$path) {
+ throw new \RuntimeException('Invalid plugin: ' . $name);
+ }
+
+ if (!preg_match('!^(?:public/|admin/)!', $uri) || false !== strpos($uri, '..')) {
+ return false;
+ }
+
+ $path .= '/' . $uri;
+
+ if (!file_exists($path)) {
+ return false;
+ }
+
+ // Récupération du type MIME à partir de l'extension
+ $pos = strrpos($path, '.');
+ $ext = substr($path, $pos+1);
+
+ $mime = self::MIME_TYPES[$ext] ?? 'text/plain';
+
+ header('Content-Type: ' .$mime);
+ header('Cache-Control: public, max-age=3600');
+ header('Last-Modified: ' . date(DATE_RFC7231, filemtime($path)));
+
+ // Don't return Content-Length on OVH, as their HTTP 2.0 proxy is buggy
+ // @see https://fossil.kd2.org/paheko/tktview/8b342877cda6ef7023b16277daa0ec8e39d949f8
+ if (HOSTING_PROVIDER !== 'OVH') {
+ header('Content-Length: ' . filesize($path));
+ }
+
+ readfile($path);
+ return true;
+ }
+
+ static public function exists(string $name): bool
+ {
+ return self::getPath($name) !== null;
+ }
+
+ /**
+ * Fire a plugin signal
+ * @param string $name Signal name
+ * @param bool $stoppable Set to TRUE if the signal can be stopped
+ * @param array $in Set to a list of INcoming parameters
+ * @param array $out Set to a list of possible OUTgoing parameters (callbacks can still set any other keys in this array, just they might not be used then)
+ * @return Signal|null Signal if a signal was run, null if no signal was registered
+ */
+ static public function fire(string $name, bool $stoppable = false, array $in = [], array $out = []): ?Signal
+ {
+ if (!self::$signals) {
+ return null;
+ }
+
+ $signal = null;
+
+ // Process SYSTEM_SIGNALS first
+ foreach (SYSTEM_SIGNALS as $system_signal) {
+ if (key($system_signal) != $name) {
+ continue;
+ }
+
+ if (!is_callable(current($system_signal))) {
+ throw new \LogicException(sprintf('System signal: cannot call "%s" for signal "%s"', current($system_signal), $name));
+ }
+
+ $signal ??= new Signal($name, $stoppable, $in, $out);
+
+ call_user_func(current($system_signal), $signal, null);
+
+ if ($signal->isStopped()) {
+ return $signal;
+ }
+ }
+
+ $list = DB::getInstance()->iterate('SELECT s.* FROM plugins_signals AS s INNER JOIN plugins p ON p.name = s.plugin
+ WHERE s.signal = ? AND p.enabled = 1;', $name);
+
+ static $plugins = [];
+
+ foreach ($list as $row) {
+ $plugins[$row->plugin] ??= Plugins::get($row->plugin);
+ $plugin = $plugins[$row->plugin];
+
+ // Don't call plugins when the code has vanished
+ if (!$plugin->hasCode()) {
+ continue;
+ }
+
+ $callback = 'Paheko\\Plugin\\' . $row->callback;
+
+ // Ignore non-callable plugins
+ if (!is_callable($callback)) {
+ ErrorManager::reportExceptionSilent(new \LogicException(sprintf(
+ 'Plugin has registered signal "%s" but callback "%s" is not a callable',
+ $name,
+ $callback
+ )));
+ continue;
+ }
+
+ $signal ??= new Signal($name, $stoppable, $in, $out);
+
+ call_user_func($callback, $signal, $plugin);
+
+ if ($signal->isStopped()) {
+ return $signal;
+ }
+ }
+
+ return $signal;
+ }
+
+ static public function get(string $name): ?Plugin
+ {
+ return EM::findOne(Plugin::class, 'SELECT * FROM @TABLE WHERE name = ?;', $name);
+ }
+
+ static public function listInstalled(): array
+ {
+ $list = EM::getInstance(Plugin::class)->all('SELECT * FROM @TABLE ORDER BY label COLLATE NOCASE ASC;');
+
+ foreach ($list as $key => $p) {
+ try {
+ $p->selfCheck();
+ }
+ catch (ValidationException $e) {
+ if (self::removeBroken($p->name)) {
+ unset($list[$key]);
+ }
+ else {
+ $p->setBrokenMessage($e->getMessage());
+ }
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * Remove old/broken extensions if they are still installed somehow
+ */
+ static public function removeBroken(string $name): bool
+ {
+ if ($name === 'garradin_eu') {
+ DB::getInstance()->exec('
+ DELETE FROM plugins_signals WHERE plugin = \'garradin_eu\';
+ DELETE FROM plugins WHERE name = \'garradin_eu\';
+ ');
+ return true;
+ }
+
+ return false;
+ }
+
+ static public function refresh(): array
+ {
+ $db = DB::getInstance();
+ $existing = $db->getAssoc(sprintf('SELECT id, name FROM %s;', Plugin::TABLE));
+ $errors = [];
+
+ foreach ($existing as $name) {
+ $f = self::get($name);
+ try {
+ $f->updateFromINI();
+ $f->save();
+ }
+ catch (ValidationException $e) {
+ $errors[] = $name . ': ' . $e->getMessage();
+ }
+ }
+
+ return $errors;
+ }
+
+ static public function getInstallable(string $name): ?Plugin
+ {
+ if (!file_exists(PLUGINS_ROOT . '/' . $name) && !file_exists(PLUGINS_ROOT . '/' . $name . '.tar.gz')) {
+ return null;
+ }
+
+ $p = new Plugin;
+ $p->name = $name;
+ $p->updateFromINI();
+
+ try {
+ $p->selfCheck();
+ }
+ catch (ValidationException $e) {
+ $p->setBrokenMessage($e->getMessage());
+ }
+
+ return $p;
+ }
+
+ /**
+ * Liste les plugins téléchargés mais non installés
+ */
+ static public function listInstallable(bool $check_exists = true): array
+ {
+ $list = [];
+
+ if ($check_exists) {
+ $exists = DB::getInstance()->getAssoc('SELECT name, name FROM plugins;');
+ }
+ else {
+ $exists = [];
+ }
+
+ foreach (glob(PLUGINS_ROOT . '/*') as $file)
+ {
+ if (substr($file, 0, 1) == '.') {
+ continue;
+ }
+
+ if (is_dir($file) && file_exists($file . '/' . Plugin::META_FILE)) {
+ $file = basename($file);
+ $name = $file;
+ }
+ elseif (substr($file, -7) == '.tar.gz' && file_exists('phar://' . $file . '/' . Plugin::META_FILE)) {
+ $file = basename($file);
+ $name = substr($file, 0, -7);
+ }
+ else {
+ continue;
+ }
+
+ // Ignore existing plugins
+ if (in_array($name, $exists)) {
+ continue;
+ }
+
+ $list[$name] = self::getInstallable($name);
+ }
+
+ ksort($list);
+
+ return $list;
+ }
+
+ static public function install(string $name): Plugin
+ {
+ $p = new Plugin;
+ $p->name = $name;
+
+ if (!$p->hasFile($p::META_FILE)) {
+ throw new UserException(sprintf('Le plugin "%s" n\'est pas une extension Paheko : fichier plugin.ini manquant.', $name));
+ }
+
+ $p->updateFromINI();
+
+ $db = DB::getInstance();
+ $db->begin();
+ $p->set('enabled', true);
+ $p->save();
+
+ if ($p->hasFile($p::INSTALL_FILE)) {
+ $p->call($p::INSTALL_FILE, true);
+ }
+
+ $db->commit();
+ return $p;
+ }
+
+ /**
+ * Upgrade all plugins if required
+ * This is run after an upgrade, a database restoration, or in the Plugins page
+ */
+ static public function upgradeAllIfRequired(): bool
+ {
+ $i = 0;
+
+ foreach (self::listInstalled() as $plugin) {
+ // Ignore plugins if code is no longer available
+ if (!$plugin->isAvailable()) {
+ continue;
+ }
+
+ if ($plugin->needUpgrade()) {
+ $plugin->upgrade();
+ $i++;
+ }
+
+ unset($plugin);
+ }
+
+ return $i > 0;
+ }
+}
diff --git a/src/include/lib/Paheko/Search.php b/src/include/lib/Paheko/Search.php
new file mode 100644
index 0000000..d43dd79
--- /dev/null
+++ b/src/include/lib/Paheko/Search.php
@@ -0,0 +1,59 @@
+all($sql, ...$params);
+ }
+
+ static public function listAssoc(string $target, ?int $id_user): array
+ {
+ $out = [];
+
+ foreach (self::list($target, $id_user) as $row) {
+ $out[$row->id] = $row->label;
+ }
+
+ return $out;
+ }
+
+ static public function get(int $id): ?SE
+ {
+ return EM::findOneById(SE::class, $id);
+ }
+
+ static public function quick(string $target, string $query): DynamicList
+ {
+ $s = new SE;
+ $s->target = $target;
+ return $s->quick($query);
+ }
+
+ static public function fromSQL(string $sql): SE
+ {
+ $s = new SE;
+ $s->type = $s::TYPE_SQL;
+ $s->content = $sql;
+ $s->target = $s::TARGET_ALL;
+ return $s;
+ }
+}
diff --git a/src/include/lib/Paheko/Services/Fees.php b/src/include/lib/Paheko/Services/Fees.php
new file mode 100644
index 0000000..2967f39
--- /dev/null
+++ b/src/include/lib/Paheko/Services/Fees.php
@@ -0,0 +1,151 @@
+service_id = $id;
+ }
+
+ static public function get(int $id)
+ {
+ return EntityManager::findOneById(Fee::class, $id);
+ }
+
+ static public function updateYear(Year $old, Year $new): bool
+ {
+ $db = DB::getInstance();
+
+ if ($new->id_chart == $old->id_chart) {
+ $db->preparedQuery('UPDATE services_fees SET id_year = ? WHERE id_year = ?;', $new->id(), $old->id());
+ return true;
+ }
+ else {
+ $db->preparedQuery('UPDATE services_fees SET id_year = NULL, id_account = NULL WHERE id_year = ?;', $old->id());
+ return false;
+ }
+ }
+
+ /**
+ * If $user_id is specified, then it will return a column 'user_amount' containing the amount that this specific user should pay
+ */
+ static public function listAllByService(?int $user_id = null)
+ {
+ $db = DB::getInstance();
+
+ $sql = 'SELECT *, CASE WHEN amount THEN amount ELSE NULL END AS user_amount
+ FROM services_fees ORDER BY id_service, amount IS NULL, label COLLATE U_NOCASE;';
+ $result = $db->get($sql);
+
+ if (!$user_id) {
+ return $result;
+ }
+
+ foreach ($result as &$row) {
+ if (!$row->formula) {
+ continue;
+ }
+
+ $row = self::addUserAmountToObject($row, $user_id);
+
+ if (!empty($row->user_amount_error)) {
+ $row->user_amount = -1;
+ $row->label .= sprintf(' (**FORMULE DE CALCUL INVALIDE: %s**)', $row->user_amount_error);
+ $row->description .= "\n\n**MERCI DE CORRIGER LA FORMULE**";
+ }
+ }
+
+ return $result;
+ }
+
+ static public function addUserAmountToObject(\stdClass $object, int $user_id): \stdClass
+ {
+ if (!empty($object->amount)) {
+ $object->user_amount = $object->amount;
+ }
+
+ if (empty($object->formula)) {
+ return $object;
+ }
+
+ try {
+ $sql = sprintf('SELECT (%s) FROM users WHERE id = %d;', $object->formula, $user_id);
+ $object->user_amount = DB::getInstance()->firstColumn($sql);
+ }
+ catch (DB_Exception $e) {
+ $object->user_amount_error = $e->getMessage();
+ }
+
+ return $object;
+ }
+
+ static public function listGroupedById(): array
+ {
+ return EntityManager::getInstance(Fee::class)->allAssoc('SELECT * FROM services_fees ORDER BY label COLLATE U_NOCASE;', 'id');
+ }
+
+ public function listWithStats(): DynamicList
+ {
+ $db = DB::getInstance();
+ $hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY));
+
+ $sql = sprintf('DROP TABLE IF EXISTS fees_list_stats;
+ CREATE TEMP TABLE IF NOT EXISTS fees_list_stats (id_fee, id_user, ok, expired, paid);
+ INSERT INTO fees_list_stats SELECT
+ id_fee, id_user,
+ CASE WHEN (su.expiry_date IS NULL OR su.expiry_date >= date()) AND su.paid = 1 THEN 1 ELSE 0 END,
+ CASE WHEN su.expiry_date < date() THEN 1 ELSE 0 END,
+ paid
+ FROM services_users su
+ INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) su2 ON su2.id = su.id
+ INNER JOIN users u ON u.id = su.id_user WHERE u.%s',
+ $db->where('id_category', 'NOT IN', $hidden_cats));
+
+ $db->exec($sql);
+
+ $columns = [
+ 'id' => [],
+ 'formula' => [],
+ 'label' => [
+ 'label' => 'Tarif',
+ ],
+ 'amount' => [
+ 'label' => 'Montant',
+ 'select' => 'CASE WHEN formula IS NOT NULL THEN -1 WHEN amount IS NOT NULL THEN amount ELSE 0 END',
+ ],
+ 'nb_users_ok' => [
+ 'label' => 'Membres à jour',
+ 'order' => null,
+ 'select' => '(SELECT COUNT(DISTINCT id_user) FROM fees_list_stats WHERE id_fee = fees.id AND ok = 1)',
+ ],
+ 'nb_users_expired' => [
+ 'label' => 'Membres expirés',
+ 'order' => null,
+ 'select' => '(SELECT COUNT(DISTINCT id_user) FROM fees_list_stats WHERE id_fee = fees.id AND expired = 1)',
+ ],
+ 'nb_users_unpaid' => [
+ 'label' => 'Membres en attente de règlement',
+ 'order' => null,
+ 'select' => '(SELECT COUNT(DISTINCT id_user) FROM fees_list_stats WHERE id_fee = fees.id AND paid = 0)',
+ ],
+ ];
+
+ $list = new DynamicList($columns, 'services_fees AS fees', 'id_service = ' . $this->service_id);
+ $list->setPageSize(null);
+ $list->orderBy('label', false);
+ return $list;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Services/Reminders.php b/src/include/lib/Paheko/Services/Reminders.php
new file mode 100644
index 0000000..cd40a8b
--- /dev/null
+++ b/src/include/lib/Paheko/Services/Reminders.php
@@ -0,0 +1,155 @@
+get('SELECT s.label AS service_label, sr.* FROM services_reminders sr INNER JOIN services s ON s.id = sr.id_service
+ ORDER BY s.label COLLATE U_NOCASE;');
+ }
+
+ static public function get(int $id)
+ {
+ return EntityManager::findOneById(Reminder::class, $id);
+ }
+
+ static public function listSentForUser(int $user_id)
+ {
+ $columns = [
+ 'label' => [
+ 'label' => 'Activité',
+ 'select' => 's.label',
+ ],
+ 'delay' => [
+ 'label' => 'Délai du rappel',
+ 'select' => 'r.delay',
+ ],
+ 'date' => [
+ 'label' => 'Date d\'envoi du message',
+ 'select' => 'srs.sent_date',
+ ],
+ ];
+
+ $tables = 'services_reminders_sent srs
+ LEFT JOIN services_reminders r ON r.id = srs.id_reminder
+ INNER JOIN services s ON s.id = srs.id_service';
+ $conditions = sprintf('srs.id_user = %d', $user_id);
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy('date', true);
+ return $list;
+ }
+
+ static public function listSentForReminder(int $reminder_id)
+ {
+ return DB::getInstance()->get('SELECT srs.sent_date, r.delay, s.label, rs.id AS sent_id, s.id AS service_id
+ FROM services_reminders_sent srs
+ INNER JOIN services_reminders r ON r.id = srs.id_reminder
+ INNER JOIN services s ON s.id = srs.id_service
+ WHERE rs.id_reminder = ?;', $reminder_id);
+ }
+
+ static public function listForService(int $service_id)
+ {
+ return DB::getInstance()->get('SELECT * FROM services_reminders WHERE id_service = ? ORDER BY delay, subject;', $service_id);
+ }
+
+ static public function getPendingSQL(bool $due_only = true, string $conditions = '1')
+ {
+ $db = DB::getInstance();
+
+ $sql = 'SELECT
+ u.*, %s AS identity,
+ u.id AS id_user,
+ date(su.expiry_date, sr.delay || \' days\') AS reminder_date,
+ ABS(julianday(date()) - julianday(su.expiry_date)) AS nb_days,
+ MAX(sr.delay) AS delay, sr.subject, sr.body, s.label, s.description,
+ su.expiry_date, sr.id AS id_reminder, su.id_service, su.id_user,
+ sf.label AS fee_label, sf.amount, sf.formula
+ FROM services_reminders sr
+ INNER JOIN services s ON s.id = sr.id_service AND s.archived = 0
+ -- Select latest subscription to a service (MAX) only
+ INNER JOIN (SELECT MAX(su2.expiry_date) AS expiry_date, su2.id_user, su2.id_service, su2.id_fee FROM services_users AS su2 GROUP BY id_user, id_service) AS su ON s.id = su.id_service
+ -- Select fee
+ LEFT JOIN services_fees sf ON sf.id = su.id_fee
+ -- Join with users, but not ones part of a hidden category
+ INNER JOIN users u ON su.id_user = u.id
+ AND (%s)
+ AND (u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1))
+ -- Join with sent reminders to exclude users that already have received this reminder
+ LEFT JOIN (SELECT id, MAX(due_date) AS due_date, id_user, id_reminder FROM services_reminders_sent GROUP BY id_user, id_reminder) AS srs ON su.id_user = srs.id_user AND srs.id_reminder = sr.id
+ WHERE
+ (sr.not_before_date IS NULL OR sr.not_before_date <= date(su.expiry_date, sr.delay || \' days\'))
+ AND (srs.id IS NULL OR srs.due_date < date(su.expiry_date, (sr.delay - 1) || \' days\'))
+ AND %s
+ AND %s
+ GROUP BY su.id_user, sr.id_service
+ ORDER BY su.id_user';
+
+ $emails = DynamicFields::getEmailFields();
+ $emails = array_map(fn($e) => sprintf('u.%s IS NOT NULL', $db->quoteIdentifier($e)), $emails);
+ $emails = implode(' OR ', $emails);
+
+ $sql = sprintf($sql,
+ DynamicFields::getNameFieldsSQL('u'),
+ $emails,
+ $due_only ? 'date() > date(su.expiry_date, sr.delay || \' days\')' : '1',
+ $conditions
+ );
+
+ return $sql;
+ }
+
+ static public function createMessage(stdClass $reminder): ReminderMessage
+ {
+ $m = new ReminderMessage;
+ $m->import([
+ 'id_service' => $reminder->id_service,
+ 'id_user' => $reminder->id_user,
+ 'id_reminder' => $reminder->id_reminder,
+ 'due_date' => $reminder->reminder_date,
+ ]);
+
+ return $m;
+ }
+
+ /**
+ * Envoi des rappels automatiques par e-mail
+ * @return boolean TRUE en cas de succès
+ */
+ static public function sendPending(): void
+ {
+ $db = DB::getInstance();
+ $sql = self::getPendingSQL(true);
+
+ $date = new \DateTime;
+
+ $db->begin();
+ $body = null;
+
+ foreach ($db->iterate($sql) as $row) {
+ $m = self::createMessage($row);
+
+ // Create body user template only once
+ $body ??= $m->getBody($row);
+
+ $m->set('sent_date', $date);
+ $m->send($row, $body);
+ }
+
+ $db->commit();
+ }
+}
diff --git a/src/include/lib/Paheko/Services/Services.php b/src/include/lib/Paheko/Services/Services.php
new file mode 100644
index 0000000..6acea13
--- /dev/null
+++ b/src/include/lib/Paheko/Services/Services.php
@@ -0,0 +1,139 @@
+getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;');
+ }
+
+ static public function listAssocWithFees()
+ {
+ $out = [];
+
+ foreach (self::listGroupedWithFees(null, 2) as $service) {
+ $out[$service->label] = [
+ 's' . $service->id => '— Tous les tarifs —',
+ ];
+
+ foreach ($service->fees as $fee) {
+ $out[$service->label]['f' . $fee->id] = $fee->label;
+ }
+ }
+
+ return $out;
+ }
+
+ static public function count()
+ {
+ return DB::getInstance()->count(Service::TABLE, 1);
+ }
+
+ static public function listGroupedWithFees(?int $user_id = null)
+ {
+ $sql = 'SELECT
+ id, label, duration, start_date, end_date, description,
+ CASE WHEN end_date IS NOT NULL THEN end_date WHEN duration IS NOT NULL THEN date(\'now\', \'+\'||duration||\' days\') ELSE NULL END AS expiry_date
+ FROM services
+ WHERE archived = 0 ORDER BY label COLLATE U_NOCASE;';
+
+ $services = DB::getInstance()->getGrouped($sql);
+ $fees = Fees::listAllByService($user_id);
+ $out = [];
+
+ foreach ($services as $service) {
+ $out[$service->id] = $service;
+ $out[$service->id]->fees = [];
+ }
+
+ foreach ($fees as $fee) {
+ if (isset($out[$fee->id_service])) {
+ $out[$fee->id_service]->fees[] = $fee;
+ }
+ }
+
+ return $out;
+ }
+
+ static public function listArchivedWithStats(): DynamicList
+ {
+ $list = self::listWithStats();
+ $list->setConditions('archived = 1');
+ return $list;
+ }
+
+ static public function listWithStats(): DynamicList
+ {
+ $db = DB::getInstance();
+ $hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY));
+
+ $sql = sprintf('DROP TABLE IF EXISTS services_list_stats;
+ CREATE TEMP TABLE IF NOT EXISTS services_list_stats (id_service, id_user, ok, expired, paid);
+ INSERT INTO services_list_stats SELECT
+ id_service, id_user,
+ CASE WHEN (su.expiry_date IS NULL OR su.expiry_date >= date()) AND su.paid = 1 THEN 1 ELSE 0 END,
+ CASE WHEN su.expiry_date < date() THEN 1 ELSE 0 END,
+ paid
+ FROM services_users su
+ INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) su2 ON su2.id = su.id
+ INNER JOIN users u ON u.id = su.id_user WHERE u.%s',
+ $db->where('id_category', 'NOT IN', $hidden_cats));
+
+ $db->exec($sql);
+
+
+ $columns = [
+ 'id' => [],
+ 'duration' => [],
+ 'start_date' => [],
+ 'end_date' => [],
+ 'label' => [
+ 'label' => 'Activité',
+ ],
+ 'date' => [
+ 'label' => 'Période',
+ 'order' => 'start_date %s, duration %1$s',
+ 'select' => 'CASE WHEN start_date IS NULL THEN duration ELSE NULL END',
+ ],
+ 'nb_users_ok' => [
+ 'label' => 'Membres à jour',
+ 'order' => null,
+ 'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND ok = 1)',
+ ],
+ 'nb_users_expired' => [
+ 'label' => 'Membres expirés',
+ 'order' => null,
+ 'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND expired = 1)',
+ ],
+ 'nb_users_unpaid' => [
+ 'label' => 'Membres en attente de règlement',
+ 'order' => null,
+ 'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND paid = 0)',
+ ],
+ ];
+
+ $list = new DynamicList($columns, 'services', 'archived = 0');
+ $list->setPageSize(null);
+ $list->orderBy('label', false);
+ return $list;
+ }
+
+ static public function hasArchivedServices(): bool
+ {
+ return DB::getInstance()->test(Service::TABLE, 'archived = 1');
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Services/Services_User.php b/src/include/lib/Paheko/Services/Services_User.php
new file mode 100644
index 0000000..f07ff68
--- /dev/null
+++ b/src/include/lib/Paheko/Services/Services_User.php
@@ -0,0 +1,232 @@
+count(Service_User::TABLE, 'id_user = ?', $user_id);
+ }
+
+ static public function listDistinctForUser(int $user_id)
+ {
+ return DB::getInstance()->get('SELECT
+ s.label, MAX(su.date) AS last_date, su.expiry_date AS expiry_date, sf.label AS fee_label, su.paid, s.end_date,
+ CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END AS status,
+ CASE WHEN s.end_date < date() THEN 1 ELSE 0 END AS archived
+ FROM services_users su
+ INNER JOIN services s ON s.id = su.id_service
+ LEFT JOIN services_fees sf ON sf.id = su.id_fee
+ WHERE su.id_user = ?
+ AND s.archived = 0
+ GROUP BY su.id_service ORDER BY expiry_date DESC;', $user_id);
+ }
+
+ static public function perUserList(int $user_id, ?int $only_id = null, ?\DateTime $after = null): DynamicList
+ {
+ $columns = [
+ 'archived' => [
+ 'select' => 's.archived',
+ ],
+ 'id' => [
+ 'select' => 'su.id',
+ ],
+ 'id_account' => [
+ 'select' => 'sf.id_account',
+ ],
+ 'id_year' => [
+ 'select' => 'sf.id_year',
+ ],
+ 'account_code' => [
+ 'select' => 'a.code',
+ ],
+ 'has_transactions' => [
+ 'select' => 'tu.id_user',
+ ],
+ 'label' => [
+ 'select' => 's.label',
+ 'label' => 'Activité',
+ ],
+ 'fee' => [
+ 'label' => 'Tarif',
+ 'select' => 'sf.label',
+ ],
+ 'date' => [
+ 'label' => 'Date d\'inscription',
+ 'select' => 'su.date',
+ ],
+ 'expiry' => [
+ 'label' => 'Date d\'expiration',
+ 'select' => 'MAX(su.expiry_date)',
+ ],
+ 'paid' => [
+ 'label' => 'Payé',
+ 'select' => 'su.paid',
+ ],
+ 'amount' => [
+ 'label' => 'Reste à régler',
+ 'select' => 'CASE WHEN su.paid = 1 AND COUNT(tl.debit) = 0 THEN NULL
+ ELSE MAX(0, expected_amount - IFNULL(SUM(tl.debit), 0)) END',
+ ],
+ 'expected_amount' => [],
+ ];
+
+ $tables = 'services_users su
+ INNER JOIN services s ON s.id = su.id_service
+ LEFT JOIN services_fees sf ON sf.id = su.id_fee
+ LEFT JOIN acc_accounts a ON sf.id_account = a.id
+ LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
+ LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction';
+ $conditions = sprintf('su.id_user = %d', $user_id);
+
+ if ($only_id) {
+ $conditions .= sprintf(' AND su.id = %d', $only_id);
+ }
+
+ if ($after) {
+ $conditions .= sprintf(' AND su.date >= %s', DB::getInstance()->quote($after->format('Y-m-d')));
+ }
+
+ $list = new DynamicList($columns, $tables, $conditions);
+
+ $list->setExportCallback(function (&$row) {
+ $row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null;
+ });
+
+ $list->orderBy('date', true);
+ $list->groupBy('su.id');
+ $list->setCount('COUNT(DISTINCT su.id)');
+ return $list;
+ }
+
+ static protected function iterateImport(CSV_Custom $csv, array &$errors = null): \Generator
+ {
+ $number_field = DynamicFields::getNumberField();
+ $services = Services::listAssoc();
+ $fees = Fees::listGroupedById();
+
+ foreach ($csv->iterate() as $i => $row) {
+ try {
+ if (empty($row->$number_field)) {
+ throw new UserException('Aucun numéro de membre n\'a été indiqué');
+ }
+
+ $id_user = Users::getIdFromNumber($row->$number_field);
+
+ if (!$id_user) {
+ throw new UserException(sprintf('Le numéro de membre "%s" n\'existe pas', $row->$number_field));
+ }
+
+ $id_service = array_search($row->service, $services);
+
+ if (!$id_service) {
+ throw new UserException(sprintf('L\'activité "%s" n\'existe pas', $row->service));
+ }
+
+ if (empty($row->date)) {
+ throw new UserException('La date est vide');
+ }
+
+ $id_fee = null;
+
+ if (!empty($row->fee)) {
+ foreach ($fees as $fee) {
+ if (strcasecmp($fee->label, $row->fee) === 0 && $fee->id_service === $id_service) {
+ $id_fee = $fee->id;
+ break;
+ }
+ }
+
+ if (!$id_fee) {
+ throw new UserException(sprintf('Le tarif "%s" n\'existe pas pour cette activité', $row->fee));
+ }
+ }
+
+ $su = new Service_User;
+ $su->set('id_user', $id_user);
+ $su->set('id_service', $id_service);
+ $su->set('id_fee', $id_fee);
+ unset($row->fee, $row->service, $row->$number_field);
+
+ if (empty($row->paid) || strtolower(trim($row->paid)) === 'non') {
+ $row->paid = false;
+ }
+ else {
+ $row->paid = true;
+ }
+
+ $su->import((array)$row);
+
+ yield $i => $su;
+ }
+ catch (UserException $e) {
+ if (null !== $errors) {
+ $errors[] = sprintf('Ligne %d : %s', $i, $e->getMessage());
+ continue;
+ }
+
+ throw $e;
+ }
+ }
+ }
+
+ static public function import(CSV_Custom $csv): void
+ {
+ $db = DB::getInstance();
+ $db->begin();
+
+ foreach (self::iterateImport($csv) as $i => $su) {
+ try {
+ $su->save();
+ }
+ catch (UserException $e) {
+ throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()), 0, $e);
+ }
+ }
+
+ $db->commit();
+ }
+
+ static public function listImportColumns(): array
+ {
+ $number_field = DynamicFields::getNumberField();
+
+ return [
+ $number_field => 'Numéro de membre',
+ 'service' => 'Activité',
+ 'fee' => 'Tarif',
+ 'paid' => 'Payé ?',
+ 'expected_amount' => 'Montant à régler',
+ 'date' => 'Date d\'inscription',
+ 'expiry_date' => 'Date d\'expiration',
+ ];
+ }
+
+ static public function listMandatoryImportColumns(): array
+ {
+ $number_field = DynamicFields::getNumberField();
+
+ return [
+ $number_field,
+ 'service',
+ 'date',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Static_Cache.php b/src/include/lib/Paheko/Static_Cache.php
new file mode 100644
index 0000000..dba9797
--- /dev/null
+++ b/src/include/lib/Paheko/Static_Cache.php
@@ -0,0 +1,88 @@
+ (time() - (int)$expire)) ? false : true;
+ }
+
+ static public function get(string $id): string
+ {
+ $path = self::getPath($id);
+ return file_get_contents($path);
+ }
+
+ static public function display(string $id): void
+ {
+ $path = self::getPath($id);
+ readfile($path);
+ }
+
+ static public function exists(string $id): bool
+ {
+ return file_exists(self::getPath($id));
+ }
+
+ static public function remove(string $id): bool
+ {
+ $path = self::getPath($id);
+ return Utils::safe_unlink($path);
+ }
+}
diff --git a/src/include/lib/Paheko/Template.php b/src/include/lib/Paheko/Template.php
new file mode 100644
index 0000000..ab2aa1a
--- /dev/null
+++ b/src/include/lib/Paheko/Template.php
@@ -0,0 +1,451 @@
+assign('table_export', false);
+ $this->assign('pdf_export', false);
+
+ if ($session->isLogged()) {
+ if (isset($_GET['_pdf'])) {
+ $this->assign('pdf_export', true);
+ return $this->PDF($template);
+ }
+ elseif (isset($_GET['_export']) && $_GET['_export'] === 'test') {
+ $this->assign('table_export', true);
+ }
+ elseif (isset($_GET['_export'])) {
+ $this->assign('table_export', true);
+ $html = $this->fetch($template);
+
+ if (!stripos($html, '([^<]+)!', $html, $match)) {
+ $title = html_entity_decode(trim($match[1]));
+ }
+
+ return CSV::exportHTML($_GET['_export'], $html, $title);
+ }
+ }
+
+ return parent::display($template);
+ }
+
+ public function PDF(?string $template = null, ?string $title = null)
+ {
+ $out = $this->fetch($template);
+
+ if (!$title && preg_match('!(.*) !U', $out, $match)) {
+ $title = trim($match[1]);
+ }
+
+ header('Content-type: application/pdf');
+ header(sprintf('Content-Disposition: attachment; filename="%s.pdf"', Utils::safeFileName($title ?: 'Page')));
+ Utils::streamPDF($out);
+ return $this;
+ }
+
+ private function __clone()
+ {
+ }
+
+ public function __construct($template = null, Template &$parent = null)
+ {
+ parent::__construct($template, $parent);
+
+ if (null === $parent) {
+ if (self::$_instance !== null) {
+ throw new \LogicException('Instance already exists');
+ }
+ }
+ // For included templates just return a new instance,
+ // the singleton is only to get the 'master' Template object
+ else {
+ return $this;
+ }
+
+ Translate::extendSmartyer($this);
+
+ $cache_dir = SMARTYER_CACHE_ROOT;
+
+ if (!file_exists($cache_dir)) {
+ Utils::safe_mkdir($cache_dir, 0777, true);
+ }
+
+ $this->setTemplatesDir(ROOT . '/templates');
+ $this->setCompiledDir($cache_dir);
+ $this->setNamespace('Paheko');
+
+ $this->assign('version_hash', Utils::getVersionHash());
+
+ $this->assign('www_url', WWW_URL);
+ $this->assign('admin_url', ADMIN_URL);
+ $this->assign('admin_uri', ADMIN_URI);
+ $this->assign('base_url', BASE_URL);
+ $this->assign('help_pattern_url', HELP_PATTERN_URL);
+ $this->assign('help_url', sprintf(HELP_URL, str_replace('/admin/', '', Utils::getSelfURI(false))));
+ $this->assign('self_url', Utils::getSelfURI());
+ $this->assign('self_url_no_qs', Utils::getSelfURI(false));
+
+ $session = null;
+
+ if (!defined('Paheko\INSTALL_PROCESS')) {
+ $session = Session::getInstance();
+ $this->assign('config', Config::getInstance());
+ }
+ else {
+ $this->assign('config', null);
+ }
+
+ $is_logged = $session ? $session->isLogged() : null;
+
+ $this->assign('session', $session);
+ $this->assign('is_logged', $is_logged);
+ $this->assign('logged_user', $is_logged ? $session->getUser() : null);
+
+ $this->assign('dialog', isset($_GET['_dialog']) ? ($_GET['_dialog'] ?: true) : false);
+
+ $this->register_compile_function('continue', function (Smartyer $s, $pos, $block, $name, $raw_args) {
+ if ($block == 'continue')
+ {
+ return 'continue;';
+ }
+ });
+
+ $this->register_compile_function('use', function (Smartyer $s, $pos, $block, $name, $raw_args) {
+ if ($name == 'use')
+ {
+ return sprintf('use %s;', $raw_args);
+ }
+ });
+
+ $this->register_function('form_errors', [$this, 'formErrors']);
+
+ $this->register_function('size_meter', [$this, 'sizeMeter']);
+ $this->register_function('copy_button', [$this, 'copyButton']);
+ $this->register_function('custom_colors', [$this, 'customColors']);
+ $this->register_function('plugin_url', ['Paheko\Utils', 'plugin_url']);
+ $this->register_function('diff', [$this, 'diff']);
+ $this->register_function('display_permissions', [$this, 'displayPermissions']);
+
+ $this->register_function('csrf_field', function ($params) {
+ return Form::tokenHTML($params['key']);
+ });
+
+ $this->register_function('enable_upload_here', function ($params) {
+ $csrf_key = 'upload_file_' . md5($params['path']);
+ $url = Utils::getLocalURL('!common/files/upload.php?p=' . rawurlencode($params['path']));
+ return sprintf(' data-upload-url="%s" data-upload-token-value="%s" data-upload-token-name="%s" ',
+ htmlspecialchars($url),
+ Form::tokenGenerate($csrf_key),
+ Form::tokenFieldName($csrf_key),
+ );
+ });
+
+ $this->register_block('linkmenu', [CommonFunctions::class, 'linkmenu']);
+
+ $this->register_modifier('strlen', fn($a) => strlen($a ?? ''));
+ $this->register_modifier('format_weight', [Utils::class, 'format_weight']);
+ $this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
+ $this->register_modifier('get_country_name', ['Paheko\Utils', 'getCountryName']);
+ $this->register_modifier('abs', function($a) { return abs($a ?? 0); });
+ $this->register_modifier('percent_of', function($a, $b) { return !$b ? $b : round($a / $b * 100); });
+
+ $this->register_modifier('linkify_transactions', function ($str) {
+ $str = preg_replace_callback('/(?<=^|\s)(https?:\/\/.*?)(?=\s|$)/', function ($m) {
+ return sprintf('%1$s ', htmlspecialchars($m[1]));
+ }, $str);
+
+ return preg_replace_callback('/(?<=^|\s)#(\d+)(?=\s|$)/', function ($m) {
+ return sprintf('#%2$d ',
+ Utils::getLocalURL('!acc/transactions/details.php?id='),
+ $m[1]
+ );
+ }, $str);
+ });
+
+ $this->register_modifier('restore_snippet_markup', function ($str) {
+ return preg_replace('!<(/?mark)>!', '<$1>', $str);
+ });
+
+ foreach (CommonModifiers::PHP_MODIFIERS_LIST as $name => $params) {
+ $this->register_modifier($name, [CommonModifiers::class, $name]);
+ }
+
+ foreach (CommonModifiers::MODIFIERS_LIST as $key => $name) {
+ $this->register_modifier(is_int($key) ? $name : $key, is_int($key) ? [CommonModifiers::class, $name] : $name);
+ }
+
+ foreach (CommonFunctions::FUNCTIONS_LIST as $key => $name) {
+ $this->register_function(is_int($key) ? $name : $key, is_int($key) ? [CommonFunctions::class, $name] : $name);
+ }
+
+ $this->register_modifier('local_url', [Utils::class, 'getLocalURL']);
+
+ // Overwrite default money modifiers
+ $this->register_modifier('money', [CommonModifiers::class, 'money_html']);
+ $this->register_modifier('money_currency', [CommonModifiers::class, 'money_currency_html']);
+ }
+
+
+ protected function formErrors($params)
+ {
+ $form = $this->getTemplateVars('form');
+
+ if (!$form || !$form->hasErrors())
+ {
+ return '';
+ }
+
+ $errors = $form->getErrorMessages(!empty($params['membre']) ? true : false);
+
+ foreach ($errors as &$error) {
+ if ($error instanceof UserException) {
+ if ($html = $error->getHTMLMessage()) {
+ $message = $html;
+ }
+ else {
+ $message = nl2br($this->escape($error->getMessage()));
+ }
+
+ if ($error->hasDetails()) {
+ $message = '' . $message . ' ' . $error->getDetailsHTML();
+ }
+
+ $error = $message;
+ }
+ else {
+ $error = nl2br($this->escape($error));
+ }
+
+ /* Not used currently
+ $error = preg_replace_callback('/\[([^\]]*)\]\(([^\)]+)\)/',
+ fn ($m) => sprintf('%s ', Utils::getLocalURL($m[2]), $m[1] ?? $m[2])
+ );
+ */
+ }
+
+ return '' . implode(' ', $errors) . ' ';
+ }
+
+ protected function customColors()
+ {
+ $config = defined('Paheko\INSTALL_PROCESS') ? null : Config::getInstance();
+
+ $c1 = ADMIN_COLOR1;
+ $c2 = ADMIN_COLOR2;
+ $bg = ADMIN_BACKGROUND_IMAGE;
+
+ if (!FORCE_CUSTOM_COLORS && $config) {
+ $c1 = $config->get('color1') ?: $c1;
+ $c2 = $config->get('color2') ?: $c2;
+
+ if ($url = $config->fileURL('admin_background')) {
+ $bg = $url;
+ }
+ }
+
+ $out = '
+ ';
+
+ if ($config && $url = $config->fileURL('admin_css')) {
+ $out .= "\n" . sprintf(' ', $url);
+ }
+
+ return sprintf($out, CommonModifiers::css_hex_to_rgb($c1), CommonModifiers::css_hex_to_rgb($c2), $bg);
+ }
+
+ protected function diff(array $params)
+ {
+ if (isset($params['old'], $params['new'])) {
+ $diff = \KD2\SimpleDiff::diff_to_array(false, $params['old'], $params['new'], $params['context'] ?? 3);
+ }
+ else {
+ throw new \BadFunctionCallException('Paramètres old et new requis.');
+ }
+
+ $out = '';
+
+ if (isset($params['old_label'], $params['new_label'])) {
+ $out .= sprintf(
+ '%s %s ',
+ htmlspecialchars($params['old_label']),
+ htmlspecialchars($params['new_label'])
+ );
+ }
+
+ $out .= '';
+
+ $prev = key($diff);
+
+ foreach ($diff as $i=>$line)
+ {
+ if ($i > $prev + 1)
+ {
+ $out .= ' ';
+ }
+
+ list($type, $old, $new) = $line;
+
+ $class1 = $class2 = '';
+ $t1 = $t2 = '';
+
+ if ($type == \KD2\SimpleDiff::INS)
+ {
+ $class2 = 'ins';
+ $t2 = ' ';
+ $old = htmlspecialchars($old, ENT_QUOTES, 'UTF-8');
+ $new = htmlspecialchars($new, ENT_QUOTES, 'UTF-8');
+ }
+ elseif ($type == \KD2\SimpleDiff::DEL)
+ {
+ $class1 = 'del';
+ $t1 = ' ';
+ $old = htmlspecialchars($old, ENT_QUOTES, 'UTF-8');
+ $new = htmlspecialchars($new, ENT_QUOTES, 'UTF-8');
+ }
+ elseif ($type == \KD2\SimpleDiff::CHANGED)
+ {
+ $class1 = 'del';
+ $class2 = 'ins';
+ $t1 = ' ';
+ $t2 = ' ';
+
+ $lineDiff = \KD2\SimpleDiff::wdiff($old, $new);
+ $lineDiff = htmlspecialchars($lineDiff, ENT_QUOTES, 'UTF-8');
+
+ // Don't show new things in deleted line
+ $old = preg_replace('!\{\+(?:.*)\+\}!U', '', $lineDiff);
+ $old = str_replace(' ', ' ', $old);
+ $old = str_replace('-] [-', ' ', $old);
+ $old = preg_replace('!\[-(.*)-\]!U', '\\1', $old);
+
+ // Don't show old things in added line
+ $new = preg_replace('!\[-(?:.*)-\]!U', '', $lineDiff);
+ $new = str_replace(' ', ' ', $new);
+ $new = str_replace('+} {+', ' ', $new);
+ $new = preg_replace('!\{\+(.*)\+\}!U', '\\1 ', $new);
+ }
+ else
+ {
+ $old = htmlspecialchars($old, ENT_QUOTES, 'UTF-8');
+ $new = htmlspecialchars($new, ENT_QUOTES, 'UTF-8');
+ }
+
+ $out .= '';
+ $out .= ''.($i+1).' ';
+ $out .= ''.$t1.' ';
+ $out .= ''.$old.' ';
+ $out .= ''.$t2.' ';
+ $out .= ''.$new.' ';
+ $out .= ' ';
+
+ $prev = $i;
+ }
+
+ $out .= '
';
+ return $out;
+ }
+
+ protected function displayPermissions(array $params): string
+ {
+ $out = [];
+
+ if (isset($params['section'], $params['level'])) {
+ if (is_string($params['level'])) {
+ $params['level'] = Session::ACCESS_LEVELS[$params['level']];
+ }
+ $list = [$params['section'] => Category::PERMISSIONS[$params['section']]];
+ $perms = (object) ['perm_' . $params['section'] => $params['level']];
+ }
+ else {
+ $perms = $params['permissions'];
+ $list = Category::PERMISSIONS;
+ }
+
+ foreach ($list as $name => $config) {
+ $access = $perms->{'perm_' . $name};
+ $label = sprintf('%s : %s', $config['label'], $config['options'][$access]);
+ $out[$name] = sprintf('%s ', $access, $name, htmlspecialchars($label), $config['shape']);
+ }
+
+ return implode(' ', $out);
+ }
+
+ protected function copyButton(array $params): string
+ {
+ return sprintf('%s ', htmlspecialchars($params['label']));
+ }
+
+ // We cannot use here as Firefox sucks :(
+ protected function sizeMeter(array $params): string
+ {
+ $out = sprintf('<%s class="quota %s">', $params['tag'] ?? 'span', $params['class'] ?? '');
+
+ $attributes = '';
+
+ if (!empty($params['href'])) {
+ $params['meter_tag'] = 'a';
+ $attributes .= sprintf(' href="%s"', htmlspecialchars(Utils::getLocalURL($params['href'])));
+ }
+ else {
+ $params['meter_tag'] = 'span';
+ }
+
+ if (!empty($params['title'])) {
+ $attributes .= sprintf(' title="%s"', htmlspecialchars($params['title']));
+ }
+
+ $more = '';
+
+ if (isset($params['more'])) {
+ $more = '' . $params['more'] . ' ';
+ }
+
+ $text = sprintf($params['text'] ?? '%s', Utils::format_bytes($params['value']), Utils::format_bytes($params['total']));
+
+ $out .= sprintf('<%s class="meter" style="--quota-percent: %s" %s>%s %s%1$s>',
+ $params['meter_tag'],
+ round(100 * $params['value'] / ($params['total'] ?: 1)),
+ $attributes,
+ $text,
+ $more
+ );
+
+ $out .= sprintf('%s>', $params['tag'] ?? 'span');
+ return $out;
+ }
+}
diff --git a/src/include/lib/Paheko/Upgrade.php b/src/include/lib/Paheko/Upgrade.php
new file mode 100644
index 0000000..1b15f79
--- /dev/null
+++ b/src/include/lib/Paheko/Upgrade.php
@@ -0,0 +1,363 @@
+version();
+
+ if (version_compare($v, paheko_version(), '>='))
+ {
+ return false;
+ }
+
+ Install::checkAndCreateDirectories();
+
+ if (!$v || version_compare($v, self::MIN_REQUIRED_VERSION, '<'))
+ {
+ throw new UserException(sprintf("Votre version de Paheko est trop ancienne pour être mise à jour. Mettez à jour vers Paheko %s avant de faire la mise à jour vers cette version.", self::MIN_REQUIRED_VERSION));
+ }
+
+ if (Static_Cache::exists('upgrade'))
+ {
+ $path = Static_Cache::getPath('upgrade');
+ throw new UserException('Une mise à jour est déjà en cours.'
+ . PHP_EOL . 'Si celle-ci a échouée et que vous voulez ré-essayer, supprimez le fichier suivant:'
+ . PHP_EOL . $path);
+ }
+
+ return true;
+ }
+
+ static public function upgrade()
+ {
+ $db = DB::getInstance();
+ $v = $db->version();
+
+ // Rename namespace in config file, before starting any upgrade
+ if (version_compare($v, '1.3.0', '<')) {
+ $config_path = ROOT . '/' . CONFIG_FILE;
+
+ if (file_exists($config_path) && is_writable($config_path)) {
+ $contents = file_get_contents($config_path);
+
+ $new = strtr($contents, [
+ 'namespace Garradin' => 'namespace Paheko',
+ ' Garradin\\' => ' Paheko\\',
+ '\'Garradin\\' => '\'Paheko\\',
+ '"Garradin\\' => '"Paheko\\',
+ '\\Garradin\\' => '\\Paheko\\',
+ ]);
+
+ if ($new !== $contents) {
+ file_put_contents($config_path, $new);
+ Install::showProgressSpinner('!upgrade.php?a=' . time(), 'Suite de la mise à jour…');
+ exit;
+ }
+ }
+ }
+
+ Plugins::toggleSignals(false);
+
+ Static_Cache::store('upgrade', 'Updating');
+
+ // Créer une sauvegarde automatique
+ $backup_file = sprintf(DATA_ROOT . '/association.pre_upgrade-%s.sqlite', paheko_version());
+ Backup::make($backup_file);
+
+ // Extend execution time, just in case
+ if (false === strpos(@ini_get('disable_functions'), 'set_time_limit')) {
+ @set_time_limit(600);
+ }
+
+ @ini_set('max_execution_time', 600);
+
+ try {
+ if (version_compare($v, '1.1.21', '<')) {
+ $db->beginSchemaUpdate();
+ // Add id_analytical column to services_fees
+ $db->import(ROOT . '/include/data/1.1.21_migration.sql');
+ $db->commitSchemaUpdate();
+ }
+
+ if (version_compare($v, '1.1.22', '<')) {
+ $db->beginSchemaUpdate();
+ // Create acc_accounts_balances view
+ $db->import(ROOT . '/include/data/1.1.0_schema.sql');
+ $db->commitSchemaUpdate();
+ }
+
+ if (version_compare($v, '1.1.23', '<')) {
+ $db->begin();
+ // Create acc_accounts_projects_balances view
+ $db->import(ROOT . '/include/data/1.1.0_schema.sql');
+ $db->commit();
+ }
+
+ if (version_compare($v, '1.1.24', '<')) {
+ $db->begin();
+
+ // Delete acc_accounts_projects_ebalances view
+ $db->exec('DROP VIEW IF EXISTS acc_accounts_projects_balances;');
+
+ $db->commit();
+ }
+
+ if (version_compare($v, '1.1.25', '<')) {
+ $db->begin();
+
+ // Just add email tables
+ $db->import(ROOT . '/include/data/1.1.0_schema.sql');
+
+ // Rename signals
+ $db->import(ROOT . '/include/data/1.1.25_migration.sql');
+
+ $db->commit();
+ }
+
+ if (version_compare($v, '1.1.27', '<')) {
+ // Just add api_credentials tables
+ $db->import(ROOT . '/include/data/1.1.0_schema.sql');
+ }
+
+ if (version_compare($v, '1.1.28', '<')) {
+ $db->createFunction('html_decode', 'htmlspecialchars_decode');
+ $db->exec('UPDATE files_search SET content = html_decode(content) WHERE content IS NOT NULL;');
+ }
+
+ if (version_compare($v, '1.1.29', '<')) {
+ $db->import(ROOT . '/include/data/1.1.29_migration.sql');
+ }
+
+ if (version_compare($v, '1.1.30', '<')) {
+ require ROOT . '/include/migrations/1.1/30.php';
+ }
+
+ if (version_compare($v, '1.1.31', '<')) {
+ $db->import(ROOT . '/include/migrations/1.1/31.sql');
+ }
+
+ if (version_compare($v, '1.2.0', '<')) {
+ $db->beginSchemaUpdate();
+ $db->import(ROOT . '/include/migrations/1.2/1.2.0.sql');
+ Charts::updateInstalled('fr_pca_2018');
+ Charts::updateInstalled('fr_pca_1999');
+ Charts::updateInstalled('fr_pcc_2020');
+ Charts::updateInstalled('fr_pcg_2014');
+ Charts::updateInstalled('be_pcmn_2019');
+ $db->commitSchemaUpdate();
+ }
+
+ if (version_compare($v, '1.2.1', '<')) {
+ $db->beginSchemaUpdate();
+ $db->import(ROOT . '/include/migrations/1.2/1.2.1.sql');
+ Charts::resetRules(['FR', 'CH', 'BE']);
+ $db->commitSchemaUpdate();
+ }
+
+ if (version_compare($v, '1.2.2', '<')) {
+ require ROOT . '/include/migrations/1.2/1.2.2.php';
+ }
+
+ if (version_compare($v, '1.3.0-rc1', '<')) {
+ require ROOT . '/include/migrations/1.3/1.3.0.php';
+ }
+
+ if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc2', '<')) {
+ require ROOT . '/include/migrations/1.3/1.3.0-rc2.php';
+ }
+
+ if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc5', '<')) {
+ require ROOT . '/include/migrations/1.3/1.3.0-rc5.php';
+ }
+
+ if (version_compare($v, '1.3.0-rc7', '<')) {
+ require ROOT . '/include/migrations/1.3/1.3.0-rc7.php';
+ }
+
+ if (version_compare($v, '1.3.0-rc12', '<')) {
+ $db->import(ROOT . '/include/migrations/1.3/1.3.0-rc12.sql');
+ }
+
+ if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc13', '<')) {
+ $db->beginSchemaUpdate();
+ $db->import(ROOT . '/include/migrations/1.3/1.3.0-rc13.sql');
+ $db->commitSchemaUpdate();
+ }
+
+ if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc14', '<')) {
+ require ROOT . '/include/migrations/1.3/1.3.0-rc14.php';
+ }
+
+ if (version_compare($v, '1.3.0-rc15', '<')) {
+ $db->import(ROOT . '/include/migrations/1.3/1.3.0-rc15.sql');
+ }
+
+ if (version_compare($v, '1.3.2', '<')) {
+ $db->import(ROOT . '/include/migrations/1.3/1.3.2.sql');
+ }
+
+ if (version_compare($v, '1.3.3', '<')) {
+ $db->beginSchemaUpdate();
+ $db->import(ROOT . '/include/migrations/1.3/1.3.3.sql');
+ $db->commitSchemaUpdate();
+ }
+
+ if (version_compare($v, '1.3.5', '<')) {
+ $db->beginSchemaUpdate();
+ $db->import(ROOT . '/include/migrations/1.3/1.3.5.sql');
+ $db->commitSchemaUpdate();
+ }
+
+ if (version_compare($v, '1.3.6', '<')) {
+ require ROOT . '/include/migrations/1.3/1.3.6.php';
+ }
+
+ Plugins::upgradeAllIfRequired();
+
+ // Vérification de la cohérence des clés étrangères
+ $db->foreignKeyCheck();
+
+ // Delete local cached files
+ Utils::resetCache(USER_TEMPLATES_CACHE_ROOT);
+ Utils::resetCache(STATIC_CACHE_ROOT);
+
+ $cache_version_file = SHARED_CACHE_ROOT . '/version';
+ $cache_version = file_exists($cache_version_file) ? trim(file_get_contents($cache_version_file)) : null;
+
+ // Only delete system cache when it's required
+ if (paheko_version() !== $cache_version) {
+ Utils::resetCache(SMARTYER_CACHE_ROOT);
+ }
+
+ file_put_contents($cache_version_file, paheko_version());
+ $db->setVersion(paheko_version());
+
+ // reset last version check
+ $db->exec('UPDATE config SET value = NULL WHERE key = \'last_version_check\';');
+
+ Static_Cache::remove('upgrade');
+ }
+ catch (\Throwable $e)
+ {
+ if ($db->inTransaction()) {
+ $db->rollback();
+ }
+
+ $db->close();
+ rename($backup_file, DB_FILE);
+
+ Static_Cache::remove('upgrade');
+
+ if ($e instanceof UserException) {
+ $e = new \RuntimeException($e->getMessage(), 0, $e);
+ }
+
+ throw $e;
+ }
+
+ Install::ping();
+
+ $session = Session::getInstance();
+ $session->logout();
+ }
+
+ static public function getLatestVersion(): ?\stdClass
+ {
+ if (!ENABLE_TECH_DETAILS && !ENABLE_UPGRADES) {
+ return null;
+ }
+
+ $config = Config::getInstance();
+ $last = $config->get('last_version_check');
+
+ if ($last) {
+ $last = json_decode($last);
+ }
+
+ // Only check once every two weeks
+ if ($last && $last->time > (time() - 3600 * 24 * 5)) {
+ return $last;
+ }
+
+ return null;
+ }
+
+ static public function fetchLatestVersion(): ?\stdClass
+ {
+ if (!ENABLE_TECH_DETAILS && !ENABLE_UPGRADES) {
+ return null;
+ }
+
+ $config = Config::getInstance();
+ $last = $config->get('last_version_check');
+
+ if ($last) {
+ $last = json_decode($last);
+ }
+
+ // Only check once every two weeks
+ if ($last && $last->time > (time() - 3600 * 24 * 7)) {
+ return $last;
+ }
+
+ $current_version = paheko_version();
+ $last = (object) ['time' => time(), 'version' => null];
+ $config->set('last_version_check', json_encode($last));
+ $config->save();
+
+ $last->version = self::getInstaller()->latest();
+
+ if (version_compare($last->version, $current_version, '<=')) {
+ $last->version = null;
+ }
+
+ $config->set('last_version_check', json_encode($last));
+ $config->save();
+
+ return $last;
+ }
+
+ static public function getInstaller(): FossilInstaller
+ {
+ if (!isset(self::$installer)) {
+ $i = new FossilInstaller(WEBSITE, ROOT, CACHE_ROOT, '!^paheko-(.*)\.tar\.gz$!');
+ $i->setPublicKeyFile(ROOT . '/pubkey.asc');
+
+ if (0 === ($pos = strpos(CACHE_ROOT, ROOT))) {
+ $i->addIgnoredPath(substr(CACHE_ROOT, strlen(ROOT) + 1));
+ }
+
+ if (0 === ($pos = strpos(DATA_ROOT, ROOT))) {
+ $i->addIgnoredPath(substr(DATA_ROOT, strlen(ROOT) + 1));
+ }
+
+ if (0 === ($pos = strpos(SHARED_CACHE_ROOT, ROOT))) {
+ $i->addIgnoredPath(substr(SHARED_CACHE_ROOT, strlen(ROOT) + 1));
+ }
+
+ $i->addIgnoredPath('config.local.php');
+ self::$installer = $i;
+ }
+
+ return self::$installer;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/UserException.php b/src/include/lib/Paheko/UserException.php
new file mode 100644
index 0000000..87f55ef
--- /dev/null
+++ b/src/include/lib/Paheko/UserException.php
@@ -0,0 +1,68 @@
+message = $message;
+ }
+
+ public function getHTMLMessage(): ?string {
+ return $this->html_message;
+ }
+
+ public function setHTMLMessage(string $html): void {
+ $this->html_message = $html;
+ }
+
+ public function setDetails($details) {
+ $this->details = $details;
+ }
+
+ public function getDetails() {
+ return $this->details;
+ }
+
+ public function hasDetails(): bool {
+ return $this->details !== null;
+ }
+
+ public function getDetailsHTML() {
+ if (func_num_args() == 1) {
+ $details = func_get_arg(0);
+ }
+ else {
+ $details = $this->details;
+ }
+
+ if (null === $details) {
+ return '(nul) ';
+ }
+
+ if ($details instanceof \DateTimeInterface) {
+ return $details->format('d/m/Y');
+ }
+
+ if (!is_array($details)) {
+ return nl2br(htmlspecialchars($details));
+ }
+
+ $out = '';
+
+ foreach ($details as $key => $value) {
+ $out .= sprintf('%s %s ', htmlspecialchars($key), $this->getDetailsHTML($value));
+ }
+
+ $out .= '
';
+
+ return $out;
+ }
+}
diff --git a/src/include/lib/Paheko/UserTemplate/CommonFunctions.php b/src/include/lib/Paheko/UserTemplate/CommonFunctions.php
new file mode 100644
index 0000000..67a8863
--- /dev/null
+++ b/src/include/lib/Paheko/UserTemplate/CommonFunctions.php
@@ -0,0 +1,897 @@
+$name) && !is_null($source->$name)) {
+ $current_value = $source->$name;
+ }
+ elseif (isset($source) && is_array($source) && isset($source[$name])) {
+ $current_value = $source[$name];
+ }
+ elseif (isset($default) && ($type != 'checkbox' || empty($_POST))) {
+ $current_value = $default;
+ }
+
+ if ($type == 'date' || $type === 'time') {
+ if ((is_string($current_value) && !preg_match('!^\d+:\d+$!', $current_value)) || is_int($current_value)) {
+ try {
+ $current_value = Entity::filterUserDateValue((string)$current_value);
+ }
+ catch (ValidationException $e) {
+ $current_value = null;
+ }
+ }
+
+ if (is_object($current_value) && $current_value instanceof \DateTimeInterface) {
+ if ($type == 'date') {
+ $current_value = $current_value->format('d/m/Y');
+ }
+ else {
+ $current_value = $current_value->format('H:i');
+ }
+ }
+ }
+ elseif ($type == 'time' && is_object($current_value) && $current_value instanceof \DateTimeInterface) {
+ $current_value = $current_value->format('H:i');
+ }
+ elseif ($type == 'password') {
+ $current_value = null;
+ }
+ elseif ($type == 'time' && is_string($current_value)) {
+ if ($v = \DateTime::createFromFormat('!Y-m-d H:i:s', $current_value)) {
+ $current_value = $v->format('H:i');
+ }
+ elseif ($v = \DateTime::createFromFormat('!Y-m-d H:i', $current_value)) {
+ $current_value = $v->format('H:i');
+ }
+ }
+
+ $attributes['id'] = 'f_' . preg_replace('![^a-z0-9_-]!i', '', $name);
+ $attributes['name'] = $name;
+
+ if (!isset($attributes['autocomplete']) && ($type == 'money' || $type == 'password')) {
+ $attributes['autocomplete'] = 'off';
+ }
+
+ if ($type == 'radio' || $type == 'checkbox' || $type == 'radio-btn') {
+ $attributes['id'] .= '_' . (strlen($value) > 30 ? md5($value) : preg_replace('![^a-z0-9_-]!i', '', $value));
+
+ if ($current_value == $value && $current_value !== null) {
+ $attributes['checked'] = 'checked';
+ }
+
+ $attributes['value'] = $value;
+ }
+ elseif ($type == 'date') {
+ $type = 'text';
+ $attributes['placeholder'] = 'JJ/MM/AAAA';
+ $attributes['data-input'] = 'date';
+ $attributes['size'] = 12;
+ $attributes['maxlength'] = 10;
+ $attributes['pattern'] = '\d\d?/\d\d?/\d{4}';
+ }
+ elseif ($type == 'time') {
+ $type = 'text';
+ $attributes['placeholder'] = 'HH:MM';
+ $attributes['data-input'] = 'time';
+ $attributes['size'] = 8;
+ $attributes['maxlength'] = 5;
+ $attributes['pattern'] = '\d\d?:\d\d?';
+ }
+ elseif ($type == 'year') {
+ $type = 'number';
+ $attributes['size'] = 4;
+ $attributes['maxlength'] = 4;
+ $attributes['pattern'] = '\d';
+ }
+ elseif ($type == 'weight') {
+ $type = 'number';
+ $attributes['placeholder'] = '1,312';
+ $attributes['size'] = 10;
+ $attributes['step'] = '0.001';
+ $suffix = ' kg';
+
+ if (null !== $current_value && !$current_value_from_user) {
+ $current_value = str_replace(',', '.', Utils::format_weight($current_value));
+ }
+ }
+ elseif ($type == 'money') {
+ $attributes['class'] = rtrim('money ' . ($attributes['class'] ?? ''));
+ }
+
+ // Create attributes string
+ if (!empty($attributes['required'])) {
+ $attributes['required'] = 'required';
+ }
+ else {
+ unset($attributes['required']);
+ }
+
+ if (!empty($attributes['autofocus'])) {
+ $attributes['autofocus'] = 'autofocus';
+ }
+ else {
+ unset($attributes['autofocus']);
+ }
+
+ if (!empty($attributes['disabled'])) {
+ $attributes['disabled'] = 'disabled';
+ unset($attributes['required']);
+ }
+ else {
+ unset($attributes['disabled']);
+ }
+
+ if (!empty($attributes['readonly'])) {
+ $attributes['readonly'] = 'readonly';
+ }
+ else {
+ unset($attributes['readonly']);
+ }
+
+ if (!empty($attributes['required']) || !empty($params['prefix_required'])) {
+ $required_label = ' (obligatoire) ';
+ }
+ else {
+ $required_label = ' (facultatif) ';
+ }
+
+ $attributes_string = $attributes;
+
+ array_walk($attributes_string, function (&$v, $k) {
+ $v = sprintf('%s="%s"', $k, htmlspecialchars((string)$v));
+ });
+
+ $attributes_string = implode(' ', $attributes_string);
+
+ if (isset($label)) {
+ $label = htmlspecialchars((string)$label);
+ $label = preg_replace_callback('!\[icon=([\w-]+)\]!', fn ($match) => self::icon(['shape' => $match[1]]), $label);
+ }
+
+ if ($type === 'radio-btn') {
+ if (!empty($attributes['disabled'])) {
+ $attributes['class'] = ($attributes['class'] ?? '') . ' disabled';
+ }
+
+ $radio = self::input(array_merge($params, ['type' => 'radio', 'label' => null, 'help' => null, 'disabled' => $attributes['disabled'] ?? null]));
+
+ $input = sprintf('%s
+
%s %s
+ ',
+ $attributes['class'] ?? '',
+ $radio,
+ $attributes['id'],
+ $label,
+ isset($params['help']) ? '' . nl2br(htmlspecialchars($params['help'])) . '
' : ''
+ );
+
+ unset($help, $label);
+ }
+ elseif ($type === 'select') {
+ $input = sprintf('', $attributes_string);
+
+ if (empty($attributes['required']) || isset($attributes['default_empty'])) {
+ $input .= sprintf('%s ', $attributes['default_empty'] ?? '');
+ }
+
+ if (!isset($options)) {
+ throw new \RuntimeException('Missing "options" parameter');
+ }
+
+ foreach ($options as $_key => $_value) {
+ $selected = null !== $current_value && ($current_value == $_key);
+ $input .= sprintf('%s ', $_key, $selected ? ' selected="selected"' : '', htmlspecialchars((string)$_value));
+ }
+
+ $input .= ' ';
+ }
+ elseif ($type === 'select_groups') {
+ $input = sprintf('', $attributes_string);
+
+ if (empty($attributes['required'])) {
+ $input .= ' ';
+ }
+
+ foreach ($options as $optgroup => $suboptions) {
+ if (is_array($suboptions)) {
+ $input .= sprintf('', htmlspecialchars((string)$optgroup));
+
+ foreach ($suboptions as $_key => $_value) {
+ $input .= sprintf('%s ', $_key, $current_value == $_key ? ' selected="selected"' : '', htmlspecialchars((string)$_value));
+ }
+
+ $input .= ' ';
+ }
+ else {
+ $input .= sprintf('%s ', $optgroup, $current_value == $optgroup ? ' selected="selected"' : '', htmlspecialchars((string)$suboptions));
+ }
+ }
+
+ $input .= ' ';
+ }
+ elseif ($type === 'textarea') {
+ $input = sprintf('%s ', $attributes_string, htmlspecialchars((string)$current_value));
+ }
+ elseif ($type === 'list') {
+ $multiple = !empty($attributes['multiple']);
+ $can_delete = $multiple || !empty($attributes['can_delete']);
+ $values = '';
+ $delete_btn = self::button(['shape' => 'delete']);
+
+ if (null !== $current_value && (is_array($current_value) || is_object($current_value))) {
+ foreach ($current_value as $v => $l) {
+ if (empty($l) || trim($l) === '') {
+ continue;
+ }
+
+ $values .= sprintf(' %3$s %s ', htmlspecialchars((string)$name), htmlspecialchars((string)$v), htmlspecialchars((string)$l), $can_delete ? $delete_btn : '');
+ }
+ }
+
+ $button = self::button([
+ 'shape' => $multiple ? 'plus' : 'menu',
+ 'label' => $multiple ? 'Ajouter' : 'Sélectionner',
+ 'required' => $attributes['required'] ?? null,
+ 'value' => Utils::getLocalURL($attributes['target']),
+ 'data-multiple' => $multiple ? '1' : '0',
+ 'data-can-delete' => (int) $can_delete,
+ 'data-name' => $name,
+ 'data-max' => $attributes['max'] ?? 0,
+ ]);
+
+ $input = sprintf('%s%s ', htmlspecialchars($attributes['id']), $button, $values);
+ }
+ elseif ($type === 'money') {
+ if (null !== $current_value && !$current_value_from_user) {
+ $current_value = Utils::money_format($current_value, ',', '');
+ }
+
+ if ((string) $current_value === '0') {
+ $current_value = '';
+ }
+
+ $currency = Config::getInstance()->currency;
+ $input = sprintf('%s ', $attributes_string, htmlspecialchars((string) $current_value), $currency);
+ }
+ else {
+ $value = isset($attributes['value']) ? '' : sprintf(' value="%s"', htmlspecialchars((string)$current_value));
+ $input = sprintf(' ', $type, $attributes_string, $value);
+ }
+
+ if ($type === 'file') {
+ $input .= sprintf(' ', Utils::return_bytes(Utils::getMaxUploadSize()));
+ }
+ elseif ($type === 'checkbox') {
+ $input = sprintf(' ', preg_replace('/(?=\[|$)/', '_present', $name, 1)) . $input;
+ }
+ elseif (!empty($copy)) {
+ $input .= sprintf(' ', $params['name']);
+ }
+
+ $input .= $suffix;
+
+ // No label? then we only want the input without the widget
+ if (empty($label)) {
+ if (!array_key_exists('label', $params) && ($type == 'radio' || $type == 'checkbox')) {
+ $input .= sprintf(' ', $attributes['id']);
+ }
+
+ return $input;
+ }
+
+ $out = '';
+
+ if (!empty($params['prefix_title'])) {
+ $out .= sprintf('%s %s ',
+ $attributes['id'],
+ htmlspecialchars($params['prefix_title']),
+ $required_label
+ );
+ }
+
+ if (!empty($params['prefix_help'])) {
+ $out .= sprintf('%s ',
+ htmlspecialchars($params['prefix_help'])
+ );
+ }
+
+ $label = sprintf('%s ', $attributes['id'], $label);
+
+ if ($type == 'radio' || $type == 'checkbox') {
+ $out .= sprintf('%s %s', $input, $label);
+
+ if (isset($help)) {
+ $out .= sprintf(' (%s) ', htmlspecialchars($help));
+ }
+
+ $out .= ' ';
+ }
+ else {
+ $out .= sprintf('%s%s %s ', $label, $required_label, $input);
+
+ if ($type == 'file' && empty($params['no_size_limit'])) {
+ $out .= sprintf('Taille maximale : %s ', Utils::format_bytes(Utils::getMaxUploadSize()));
+ }
+
+ if (isset($help)) {
+ $out .= sprintf('%s ', nl2br(htmlspecialchars($help)));
+ }
+ }
+
+ return $out;
+ }
+
+ static public function icon(array $params): string
+ {
+ if (isset($params['shape']) && isset($params['html']) && $params['html'] == false) {
+ return Utils::iconUnicode($params['shape']);
+ }
+
+ if (!isset($params['shape']) && !isset($params['url'])) {
+ throw new \RuntimeException('Missing parameter: shape or url');
+ }
+
+ $html = '';
+
+ if (isset($params['url'])) {
+ $html = self::getIconHTML(['icon' => $params['url']]);
+ unset($params['url']);
+ }
+
+ $html .= htmlspecialchars($params['label'] ?? '');
+ unset($params['label']);
+
+ self::setIconAttribute($params);
+
+ $attributes = array_diff_key($params, ['shape']);
+ $attributes = array_map(fn($v, $k) => sprintf('%s="%s"', $k, htmlspecialchars($v)),
+ $attributes, array_keys($attributes));
+
+ $attributes = implode(' ', $attributes);
+
+ return sprintf('%s ', $attributes, $html);
+ }
+
+ static public function link(array $params): string
+ {
+ $href = $params['href'];
+ $label = $params['label'];
+ $prefix = $params['prefix'] ?? '';
+
+ // href can be prefixed with '!' to make the URL relative to ADMIN_URL
+ if (substr($href, 0, 1) == '!') {
+ $href = ADMIN_URL . substr($params['href'], 1);
+ }
+
+ // propagate _dialog param if we are in an iframe
+ if (isset($_GET['_dialog']) && !isset($params['target'])) {
+ $href .= (strpos($href, '?') === false ? '?' : '&') . '_dialog';
+ }
+
+ if (!isset($params['class'])) {
+ $params['class'] = '';
+ }
+
+ unset($params['href'], $params['label'], $params['prefix']);
+
+ array_walk($params, function (&$v, $k) {
+ $v = sprintf('%s="%s"', $k, htmlspecialchars((string)$v));
+ });
+
+ $params = implode(' ', $params);
+
+ $label = $label ? sprintf('%s ', htmlspecialchars($label)) : '';
+
+ return sprintf('%s%s ', htmlspecialchars($href), $params, $prefix, $label);
+ }
+
+ static public function button(array $params): string
+ {
+ $label = isset($params['label']) ? htmlspecialchars((string)$params['label']) : '';
+ unset($params['label']);
+
+ self::setIconAttribute($params);
+
+ if (!isset($params['type'])) {
+ $params['type'] = 'button';
+ }
+
+ if (!isset($params['class'])) {
+ $params['class'] = '';
+ }
+
+ if (isset($params['name']) && !isset($params['value'])) {
+ $params['value'] = 1;
+ }
+
+ $prefix = '';
+ $suffix = '';
+
+ if (isset($params['icon'])) {
+ $prefix = self::getIconHTML($params);
+ unset($params['icon'], $params['icon_html']);
+ }
+
+ if (isset($params['csrf_key'])) {
+ $suffix .= Form::tokenHTML($params['csrf_key']);
+ unset($params['csrf_key']);
+ }
+
+ $params['class'] .= ' icn-btn';
+
+ // Remove NULL params
+ $params = array_filter($params);
+
+ array_walk($params, function (&$v, $k) {
+ $v = sprintf('%s="%s"', $k, htmlspecialchars((string)$v));
+ });
+
+ $params = implode(' ', $params);
+
+ return sprintf('%s%s %s', $params, $prefix, $label, $suffix);
+ }
+
+ static public function linkbutton(array $params): string
+ {
+ self::setIconAttribute($params);
+
+ if (isset($params['icon']) || isset($params['icon_html'])) {
+ $params['prefix'] = self::getIconHTML($params);
+ unset($params['icon'], $params['icon_html']);
+ }
+
+ if (!isset($params['class'])) {
+ $params['class'] = '';
+ }
+
+ $params['class'] .= ' icn-btn';
+
+ return self::link($params);
+ }
+
+ static protected function getIconHTML(array $params): string
+ {
+ if (isset($params['icon_html'])) {
+ return '' . $params['icon_html'] . ' ';
+ }
+
+ return sprintf(' ',
+ htmlspecialchars(Utils::getLocalURL($params['icon']))
+ );
+ }
+
+ static protected function setIconAttribute(array &$params): void
+ {
+ if (isset($params['shape'])) {
+ $params['data-icon'] = Utils::iconUnicode($params['shape']);
+ }
+
+ unset($params['shape']);
+ }
+
+ static public function exportmenu(array $params): string
+ {
+ $url = $params['href'] ?? Utils::getSelfURI();
+ $suffix = $params['suffix'] ?? 'export=';
+
+ $url = str_replace([$suffix . 'csv', $suffix . 'ods', $suffix . 'xlsx'], '', $url);
+ $url = rtrim($url, '?&');
+
+ if (false !== strpos($url, '?')) {
+ $url .= '&';
+ }
+ else {
+ $url .= '?';
+ }
+
+ $url .= $suffix;
+
+ $xlsx = $params['xlsx'] ?? null;
+
+ if (null === $xlsx) {
+ $xlsx = !empty(CALC_CONVERT_COMMAND);
+ }
+
+ if (!empty($params['form'])) {
+ $name = $params['name'] ?? 'export';
+ $out = self::button(['value' => 'csv', 'shape' => 'export', 'label' => 'Export CSV', 'name' => $name, 'type' => 'submit']);
+ $out .= self::button(['value' => 'ods', 'shape' => 'export', 'label' => 'Export LibreOffice', 'name' => $name, 'type' => 'submit']);
+
+ if ($xlsx) {
+ $out .= self::button(['value' => 'xlsx', 'shape' => 'export', 'label' => 'Export Excel', 'name' => $name, 'type' => 'submit']);
+ }
+ }
+ else {
+ $out = self::linkButton(['href' => $url . 'csv', 'label' => 'Export CSV', 'shape' => 'export']);
+ $out .= ' ' . self::linkButton(['href' => $url . 'ods', 'label' => 'Export LibreOffice', 'shape' => 'export']);
+
+ if ($xlsx !== false) {
+ $out .= ' ' . self::linkButton(['href' => $url . 'xlsx', 'label' => 'Export Excel', 'shape' => 'export']);
+ }
+ }
+
+ $params = array_merge($params, ['shape' => 'export', 'label' => $params['label'] ?? 'Export…']);
+ return self::linkmenu($params, $out);
+ }
+
+ static public function linkmenu(array $params, ?string $content): string
+ {
+ if (null === $content) {
+ return '';
+ }
+
+ if (!empty($params['right'])) {
+ $params['class'] = 'menu-btn-right';
+ }
+
+ $out = sprintf('
+ ';
+
+ return $out;
+ }
+
+ static public function delete_form(array $params): string
+ {
+ if (!isset($params['legend'], $params['warning'], $params['csrf_key'])) {
+ throw new \InvalidArgumentException('Missing parameter: legend, warning and csrf_key are required');
+ }
+
+ $tpl = Template::getInstance();
+ $tpl->assign($params);
+ return $tpl->fetch('common/delete_form.tpl');
+ }
+
+ static public function edit_user_field(array $params): string
+ {
+ if (isset($params['field'])) {
+ $field = $params['field'];
+ }
+ else {
+ $name = $params['name'] ?? $params['key'] ?? null;
+
+ if (null === $name) {
+ throw new \RuntimeException('Missing "name" parameter');
+ }
+
+ $field = DynamicFields::get($name);
+ }
+
+ if (!($field instanceof DynamicField)) {
+ throw new \LogicException('This field does not exist.');
+ }
+
+ $context = $params['context'] ?? 'module';
+
+ if (!in_array($context, ['user_edit', 'admin_new', 'admin_edit', 'module'])) {
+ throw new \InvalidArgumentException('Invalid "context" parameter value: ' . $context);
+ }
+
+ $source = $params['user'] ?? $params['source'] ?? null;
+
+ $name = $field->name;
+ $type = $field->type;
+
+ // The password must be changed using a specific field
+ if ($field->system & $field::PASSWORD) {
+ return '';
+ }
+ // Files are managed out of the form
+ elseif ($type == 'file') {
+ return '';
+ }
+ // VIRTUAL columns cannot be edited
+ elseif ($type == 'virtual') {
+ return '';
+ }
+ elseif ($context === 'user_edit' && $field->user_access_level === Session::ACCESS_NONE) {
+ return '';
+ }
+ elseif ($context === 'user_edit' && $field->user_access_level === Session::ACCESS_READ) {
+ $v = self::user_field(['name' => $name, 'value' => $params['user']->$name]);
+ return sprintf('%s %s ', $field->label, $v ?: 'Non renseigné ');
+ }
+
+ $params = [
+ 'type' => $type,
+ 'name' => $name,
+ 'label' => $field->label,
+ 'source' => $source,
+ 'disabled' => !empty($disabled),
+ 'required' => $field->required,
+ 'help' => $field->help,
+ // Fix for autocomplete, lpignore is for Lastpass
+ 'autocomplete' => 'off',
+ 'data-lpignore' => 'true',
+ ];
+
+ // Multiple choice checkboxes is a specific thingy
+ if ($type == 'multiple') {
+ $options = $field->options;
+
+ if (isset($_POST[$name]) && is_array($_POST[$name])) {
+ $value = 0;
+
+ foreach ($_POST[$name] as $k => $v) {
+ if (array_key_exists($k, $options) && !empty($v)) {
+ $value |= 0x01 << $k;
+ }
+ }
+ }
+ else {
+ $value = $params['source']->$name ?? null;
+ }
+
+ // Forcer la valeur à être un entier (depuis PHP 7.1)
+ $value = (int)$value;
+ $has_title = false;
+ $out = '';
+
+ foreach ($options as $k => $v)
+ {
+ $b = 0x01 << (int)$k;
+
+ $p = [
+ 'type' => 'checkbox',
+ 'label' => $v,
+ 'value' => $v,
+ 'default' => ($value & $b) ? $v : null,
+ 'name' => sprintf('%s[%d]', $name, $k),
+ ];
+
+ if (!$has_title) {
+ $has_title = true;
+ $p['prefix_title'] = $field->label;
+ $p['prefix_help'] = $field->help;
+ $p['prefix_required'] = $field->required;
+ }
+
+ $out .= CommonFunctions::input($p);
+ }
+
+ return $out;
+ }
+ elseif ($type == 'select') {
+ $params['options'] = array_combine($field->options, $field->options);
+ $params['default_empty'] = '—';
+ }
+ elseif ($type == 'country') {
+ $params['type'] = 'select';
+ $params['options'] = Utils::getCountryList();
+ $params['default'] = Config::getInstance()->get('country');
+ }
+ elseif ($type == 'checkbox') {
+ $params['value'] = 1;
+ $params['label'] = 'Oui';
+ $params['prefix_title'] = $field->label;
+ }
+ elseif ($field->system & $field::NUMBER && $context === 'admin_new') {
+ $params['default'] = DB::getInstance()->firstColumn(sprintf('SELECT MAX(%s) + 1 FROM %s;', $name, User::TABLE));
+ $params['required'] = false;
+ }
+ elseif ($type === 'number') {
+ $params['step'] = '1';
+ $params['pattern'] = '\\d+';
+ }
+ elseif ($type === 'decimal') {
+ $params['type'] = 'number';
+ $params['step'] = 'any';
+ }
+ elseif ($type === 'datalist') {
+ $options = '';
+
+ foreach ($field->options as $value) {
+ $options .= sprintf('%s ', htmlspecialchars($value));
+ }
+
+ $params['type'] = 'text';
+ $params['list'] = 'list-' . $params['name'];
+ $params['suffix'] = sprintf('%s ', $params['list'], $options);
+ }
+
+ if ($field->default_value === 'NOW()') {
+ $params['default'] = new \DateTime;
+ }
+ elseif (!empty($field->default_value)) {
+ $params['default'] = $field->default_value;
+ }
+
+ $out = CommonFunctions::input($params);
+
+ if (($context === 'admin_new' || $context === 'admin_edit') && $field->system & $field::LOGIN) {
+ $out .= '(Sera utilisé comme identifiant de connexion si le membre a le droit de se connecter.) ';
+ }
+
+ if ($context === 'admin_new' && $field->system & $field::NUMBER) {
+ $out .= 'Doit être unique, laisser vide pour que le numéro soit attribué automatiquement. ';
+ }
+ elseif ($context === 'admin_edit' && $field->system & $field::NUMBER) {
+ $out .= 'Doit être unique pour chaque membre. ';
+ }
+
+ return $out;
+ }
+
+ static public function user_field(array $params): string
+ {
+ if (isset($params['field'])) {
+ $field = $params['field'];
+ }
+ else {
+ $name = $params['name'] ?? $params['key'] ?? null;
+
+ if (null === $name) {
+ throw new \RuntimeException('Missing "name" parameter');
+ }
+
+ $field = DynamicFields::get($name);
+ }
+
+ if ($field && !($field instanceof DynamicField)) {
+ throw new \LogicException('This field does not exist.');
+ }
+
+ $v = $params['value'] ?? null;
+
+ $out = '';
+
+ if (!$field) {
+ $out = htmlspecialchars((string)$v);
+ }
+ elseif ($field->type == 'checkbox') {
+ $out = $v ? 'Oui' : 'Non';
+ }
+ elseif (null === $v) {
+ return '';
+ }
+ elseif ($field->type == 'file') {
+ if (!$v) {
+ return '';
+ }
+
+ $files = explode(';', $v);
+ $count = 0;
+ $label = '';
+
+ foreach ($files as $path) {
+ if (!preg_match('!\.(?:png|jpe?g|gif|webp)$!i', $path)) {
+ $count++;
+ continue;
+ }
+ elseif ($label !== '') {
+ $count++;
+ continue;
+ }
+
+ $url = BASE_URL . $path . '?150px';
+ $label .= sprintf(
+ ' ',
+ htmlspecialchars($url),
+ htmlspecialchars($field->label)
+ );
+ }
+
+ if ($count) {
+ $label .= ($count != count($files) ? '+' : '')
+ . ($count == 1 ? '1 fichier' : $count . ' fichiers');
+ }
+
+ if ($label !== '') {
+ if (isset($params['files_href'])) {
+ $label = sprintf('%s ', Utils::getLocalURL($params['files_href']), $label);
+ }
+
+ $out = '' . $label . '
';
+ }
+ }
+ elseif ($field->type === 'password') {
+ $out = '*****';
+ }
+ elseif ($field->type === 'email' && empty($params['link_name_id'])) {
+ $out = '' . htmlspecialchars($v) . ' ';
+ }
+ elseif ($field->type === 'tel' && empty($params['link_name_id'])) {
+ $out = '' . htmlspecialchars(CommonModifiers::format_phone_number($v)) . ' ';
+ }
+ elseif ($field->type === 'url' && empty($params['link_name_id'])) {
+ $out ='' . htmlspecialchars($v) . ' ';
+ }
+ elseif ($field->type === 'number' || $field->type === 'decimal') {
+ $out = str_replace('.', ',', htmlspecialchars($v));
+ }
+ else {
+ $v = $field->getStringValue($v);
+ $out = nl2br(htmlspecialchars((string) $v));
+ }
+
+ if (!empty($params['link_name_id']) && ($name === 'identity' || ($field && $field->isName() && substr($out, 0, 2) !== '%s ', Utils::getLocalURL('!users/details.php?id=' . (int)$params['link_name_id']), $out);
+ }
+
+ return $out;
+ }
+}
diff --git a/src/include/lib/Paheko/UserTemplate/CommonModifiers.php b/src/include/lib/Paheko/UserTemplate/CommonModifiers.php
new file mode 100644
index 0000000..767b9f9
--- /dev/null
+++ b/src/include/lib/Paheko/UserTemplate/CommonModifiers.php
@@ -0,0 +1,416 @@
+ ['string', '?int='],
+ 'htmlentities' => ['string', 'int=', '?string=', 'bool='],
+ 'htmlspecialchars' => ['string', 'int=', '?string=', 'bool='],
+ 'trim' => ['string', 'string='],
+ 'ltrim' => ['string', 'string='],
+ 'rtrim' => ['string', 'string='],
+ 'md5' => ['string', 'bool='],
+ 'sha1' => ['string', 'bool='],
+ 'nl2br' => ['string', 'bool='],
+ 'strlen' => ['string'],
+ 'strpos' => ['string', 'string', 'int='],
+ 'strrpos' => ['string', 'string', 'int='],
+ 'wordwrap' => ['string', 'int=', 'string=', 'bool='],
+ 'strip_tags' => ['string', '?array='],
+ 'boolval' => ['mixed'],
+ 'intval' => ['mixed'],
+ 'floatval' => ['mixed'],
+ 'strval' => ['mixed'],
+ 'substr' => ['string', 'int', '?int='],
+ 'http_build_query' => ['array', 'string=', '?string=', 'int='],
+ 'str_getcsv' => ['string', 'string=', 'string=', 'string='],
+ ];
+
+ /**
+ * Used for PHP modifiers
+ */
+ static public function __callStatic(string $name, array $arguments)
+ {
+ if (!array_key_exists($name, self::PHP_MODIFIERS_LIST)) {
+ throw new \LogicException('Invalid method: ' . $name);
+ }
+
+ // Verify arguments
+ foreach (self::PHP_MODIFIERS_LIST[$name] as $i => $param) {
+ $nullable = false;
+ $optional = false;
+
+ if (substr($param, -1) === '=') {
+ $optional = true;
+ $param = substr($param, 0, -1);
+ }
+
+ if (substr($param, 0, 1) === '?') {
+ $nullable = true;
+ $param = substr($param, 1);
+ }
+
+ if (!array_key_exists($i, $arguments)) {
+ // Stop at first optional and not provided argument
+ if ($optional) {
+ break;
+ }
+
+ throw new \BadMethodCallException(sprintf('Missing parameter %d of type %s', $i+1, $param));
+ }
+
+ $value =& $arguments[$i];
+
+ if ($nullable && (null === $value || '' === $value)) {
+ $value = null;
+ }
+ elseif ($param !== 'mixed') {
+ settype($value, $param);
+ }
+ }
+
+ unset($value, $i, $param);
+
+ return call_user_func_array($name, $arguments);
+ }
+
+ const MODIFIERS_LIST = [
+ 'protect_contact',
+ 'markdown',
+ 'money',
+ 'money_raw',
+ 'money_currency',
+ 'money_html',
+ 'money_currency_html',
+ 'relative_date',
+ 'relative_date_short',
+ 'date_short',
+ 'date_long',
+ 'date_hour',
+ 'date',
+ 'strftime',
+ 'size_in_bytes' => [Utils::class, 'format_bytes'],
+ 'typo',
+ 'css_hex_to_rgb',
+ 'css_hex_extract_hsv',
+ 'toupper',
+ 'tolower',
+ 'ucwords',
+ 'ucfirst',
+ 'lcfirst',
+ 'abs',
+ 'format_phone_number',
+ ];
+
+ static public function protect_contact(?string $contact, ?string $type = null): string
+ {
+ if (!trim($contact))
+ return '';
+
+ if ($type == 'mail' || strpos($contact, '@')) {
+ $user = strtok($contact, '@');
+ $domain = strtok('.');
+ $ext = strtok('');
+
+ return sprintf(' ',
+ htmlspecialchars($user), htmlspecialchars($domain), htmlspecialchars($ext));
+ }
+ else {
+ $label = preg_replace_callback('/[a-zA-Z0-9@]/', fn ($match) => '' . ord($match[0]) . ';', htmlspecialchars($contact));
+ $url = htmlspecialchars($type ? $type . ':' : '') . $label;
+ return sprintf('%s ', $url, $label);
+ }
+ }
+
+ static public function markdown($str): string
+ {
+ $md = new Markdown(null, null);
+ return $md->render($str);
+ }
+
+ static public function money($number, bool $hide_empty = true, bool $force_sign = false, bool $html = false): string
+ {
+ if ($hide_empty && !$number) {
+ return '';
+ }
+
+ $sign = ($force_sign && $number > 0) ? '+' : '';
+
+ $out = $sign . Utils::money_format($number, ',', $html ? ' ' : ' ', $hide_empty);
+
+ if ($html) {
+ $out = sprintf('%s ', $out);
+ }
+
+ return $out;
+ }
+
+ static public function money_raw($number, bool $hide_empty = true): string
+ {
+ return Utils::money_format($number, ',', '', $hide_empty);
+ }
+
+ static public function money_currency($number, bool $hide_empty = true, bool $force_sign = false, bool $html = false): string
+ {
+ $out = self::money($number, $hide_empty, $force_sign, $html);
+
+ if ($out !== '') {
+ $out .= ($html ? ' ' : ' ') . Config::getInstance()->get('currency');
+ }
+
+ return $out;
+ }
+
+ static public function money_html($number, bool $hide_empty = true, bool $force_sign = false): string
+ {
+ return self::money($number, $hide_empty, $force_sign, true);
+ }
+
+ static public function money_currency_html($number, bool $hide_empty = true, bool $force_sign = false): string
+ {
+ return '' . self::money_currency($number, $hide_empty, $force_sign, true) . ' ';
+ }
+
+ static public function date_long($ts, bool $with_hour = false): ?string
+ {
+ return Utils::strftime_fr($ts, '%A %e %B %Y' . ($with_hour ? ' à %Hh%M' : ''));
+ }
+
+ static public function date_short($ts, bool $with_hour = false): ?string
+ {
+ return Utils::shortDate($ts, $with_hour);
+ }
+
+ static public function date_hour($ts, bool $minutes_only_if_required = false): ?string
+ {
+ $ts = Utils::get_datetime($ts);
+
+ if (null === $ts) {
+ return null;
+ }
+
+ if ($minutes_only_if_required && $ts->format('i') == '00') {
+ return $ts->format('H\h');
+ }
+ else {
+ return $ts->format('H\hi');
+ }
+ }
+
+ static public function strftime($ts, string $format, string $locale = 'fr'): ?string
+ {
+ if ($locale == 'fr') {
+ return Utils::strftime_fr($ts, $format);
+ }
+
+ $ts = Utils::get_datetime($ts);
+
+ if (!$ts) {
+ return $ts;
+ }
+
+ return @strftime($format, $ts->getTimestamp());
+ }
+
+ static public function date($ts, string $format = null, string $locale = 'fr'): ?string
+ {
+ if (null === $format) {
+ $format = 'd/m/Y à H:i';
+ }
+ elseif (preg_match('/^DATE_[\w\d]+$/', $format)) {
+ $format = constant('DateTime::' . $format);
+ }
+
+ if ($locale == 'fr') {
+ return Utils::date_fr($ts, $format);
+ }
+
+ $ts = Utils::get_datetime($ts);
+ return date($format, $ts);
+ }
+
+ static public function relative_date($ts, bool $with_hour = false): string
+ {
+ $day = null;
+
+ if (null === $ts) {
+ return '';
+ }
+
+ $date = Utils::get_datetime($ts);
+
+ if ($date->format('Ymd') == date('Ymd'))
+ {
+ $day = 'aujourd\'hui';
+ }
+ elseif ($date->format('Ymd') == date('Ymd', strtotime('yesterday')))
+ {
+ $day = 'hier';
+ }
+ elseif ($date->format('Ymd') == date('Ymd', strtotime('tomorrow')))
+ {
+ $day = 'demain';
+ }
+ elseif ($date->getTimestamp() > time() - 3600*24*7 && $date->getTimestamp() < time()) {
+ $day = sprintf('il y a %d jours', round((time() - $date->getTimestamp()) / (3600*24)));
+ }
+ elseif ($date->format('Y') == date('Y'))
+ {
+ $day = strtolower(Utils::strftime_fr($date, '%A %e %B'));
+ }
+ else
+ {
+ $day = strtolower(Utils::strftime_fr($date, '%e %B %Y'));
+ }
+
+ if ($with_hour)
+ {
+ $hour = $date->format('H\hi');
+ return sprintf('%s, %s', $day, $hour);
+ }
+
+ return $day;
+ }
+
+ static public function relative_date_short($ts, bool $with_hour = false): string
+ {
+ $day = null;
+
+ if (null === $ts) {
+ return '';
+ }
+
+ $date = Utils::get_datetime($ts);
+
+ if ($date->format('Ymd') == date('Ymd'))
+ {
+ $day = 'aujourd\'hui';
+ }
+ elseif ($date->format('Ymd') == date('Ymd', strtotime('yesterday')))
+ {
+ $day = 'hier';
+ }
+ elseif ($date->format('Ymd') == date('Ymd', strtotime('tomorrow')))
+ {
+ $day = 'demain';
+ }
+ elseif ($date->getTimestamp() > time() - 3600*24*7) {
+ $day = sprintf('il y a %d jours', round((time() - $date->getTimestamp()) / (3600*24)));
+ }
+ elseif ($date->format('Y') == date('Y'))
+ {
+ $day = strtolower(Utils::strftime_fr($date, '%e %B'));
+ }
+ else
+ {
+ $day = strtolower(Utils::strftime_fr($date, '%d/%m/%Y'));
+ }
+
+ if ($with_hour)
+ {
+ $hour = $date->format('H\hi');
+ return sprintf('%s, %s', $day, $hour);
+ }
+
+ return $day;
+ }
+
+ static public function typo($str, $locale = 'fr')
+ {
+ $str = preg_replace('/[\h]*([?!:»;])(?=\s|$)/us', "\xc2\xa0\\1", $str);
+ $str = preg_replace('/(?<=^|\s)([«])[\h]*/u', "\\1\xc2\xa0", $str);
+ return $str;
+ }
+
+ static public function css_hex_to_rgb($str): ?string {
+ $hex = sscanf((string)$str, '#%02x%02x%02x');
+
+ if (empty($hex)) {
+ return null;
+ }
+
+ return implode(', ', $hex);
+ }
+
+ static public function css_hex_extract_hsv($str): array {
+ list($h, $s, $v) = Utils::rgbToHsv($str);
+ $h = (int)$h;
+ $s = floor(100 * $s);
+ $v = floor(100 * $v);
+ return compact('h', 's', 'v');
+ }
+
+ static public function toupper($str): string
+ {
+ return function_exists('mb_strtoupper') ? mb_strtoupper($str) : strtoupper($str);
+ }
+
+ static public function tolower($str): string
+ {
+ return function_exists('mb_strtolower') ? mb_strtolower($str) : strtolower($str);
+ }
+
+ static public function ucwords($str): string
+ {
+ return function_exists('mb_convert_case') ? mb_convert_case($str, \MB_CASE_TITLE) : ucwords($str);
+ }
+
+ static public function ucfirst($str): string
+ {
+ return function_exists('mb_strtoupper') ? mb_strtoupper(mb_substr($str, 0, 1)) . mb_substr($str, 1) : ucfirst($str);
+ }
+
+ static public function lcfirst($str): string
+ {
+ return function_exists('mb_strtolower') ? mb_strtolower(mb_substr($str, 0, 1)) . mb_substr($str, 1) : ucfirst($str);
+ }
+
+ static public function abs($in)
+ {
+ if (false !== strpos((string)$in, '.')) {
+ $in = (float) $in;
+ }
+ else {
+ $in = (int) $in;
+ }
+
+ return abs($in);
+ }
+
+ static public function format_phone_number($n)
+ {
+ if (empty($n)) {
+ return '';
+ }
+
+ $country = Config::getInstance()->get('country');
+
+ if ($country !== 'FR') {
+ return $n;
+ }
+
+ if ($n[0] === '0' && strlen($n) === 10) {
+ $n = preg_replace('!(\d{2})!', '\\1 ', $n);
+ }
+
+ return $n;
+ }
+}
diff --git a/src/include/lib/Paheko/UserTemplate/Functions.php b/src/include/lib/Paheko/UserTemplate/Functions.php
new file mode 100644
index 0000000..5eb9258
--- /dev/null
+++ b/src/include/lib/Paheko/UserTemplate/Functions.php
@@ -0,0 +1,828 @@
+ [self::class, 'compile_break'],
+ ':continue' => [self::class, 'compile_continue'],
+ ':return' => [self::class, 'compile_return'],
+ ];
+
+ /**
+ * Compile function to break inside a loop
+ */
+ static public function compile_break(string $name, string $params, UserTemplate $tpl, int $line)
+ {
+ $in_loop = false;
+ foreach ($tpl->_stack as $element) {
+ if ($element[0] == $tpl::SECTION) {
+ $in_loop = true;
+ break;
+ }
+ }
+
+ if (!$in_loop) {
+ throw new Brindille_Exception(sprintf('Error on line %d: break can only be used inside a section', $line));
+ }
+
+ return '';
+ }
+
+ /**
+ * Compile function to continue inside a loop
+ */
+ static public function compile_continue(string $name, string $params, UserTemplate $tpl, int $line)
+ {
+ $in_loop = 0;
+ foreach ($tpl->_stack as $element) {
+ if ($element[0] == $tpl::SECTION) {
+ $in_loop++;
+ }
+ }
+
+ $i = ctype_digit(trim($params)) ? (int)$params : 1;
+
+ if ($in_loop < $i) {
+ throw new Brindille_Exception(sprintf('Error on line %d: continue can only be used inside a section', $line));
+ }
+
+ return sprintf('', $i);
+ }
+
+ static public function compile_return(string $name, string $params, UserTemplate $tpl, int $line)
+ {
+ return '';
+ }
+
+ static public function admin_header(array $params): string
+ {
+ $tpl = Template::getInstance();
+ $tpl->assign($params);
+
+ if (Session::getInstance()->isLogged()) {
+ $tpl->assign('plugins_menu', Extensions::listMenu(Session::getInstance()));
+ }
+
+ return $tpl->fetch('_head.tpl');
+ }
+
+ static public function admin_footer(array $params): string
+ {
+ $tpl = Template::getInstance();
+ $tpl->assign($params);
+ return $tpl->fetch('_foot.tpl');
+ }
+
+ static public function password_input(): string
+ {
+ $tpl = Template::getInstance();
+ return $tpl->fetch('users/_password_form.tpl');
+ }
+
+ static public function save(array $params, UserTemplate $tpl, int $line): void
+ {
+ if (!$tpl->module) {
+ throw new Brindille_Exception('Module name could not be found');
+ }
+
+ $table = 'module_data_' . $tpl->module->name;
+
+ if (!empty($params['key'])) {
+ if ($params['key'] == 'uuid') {
+ $params['key'] = Utils::uuid();
+ }
+
+ $field = 'key';
+ $where_value = $params['key'];
+ }
+ elseif (!empty($params['id'])) {
+ $field = 'id';
+ $where_value = $params['id'];
+ }
+ else {
+ $where_value = null;
+ $field = null;
+ }
+
+ $key = $params['key'] ?? null;
+ $id = $params['id'] ?? null;
+ $assign_new_id = $params['assign_new_id'] ?? null;
+ $validate = $params['validate_schema'] ?? null;
+ $validate_only = $params['validate_only'] ?? null;
+
+ unset($params['key'], $params['id'], $params['assign_new_id'], $params['validate_schema'], $params['validate_only']);
+
+ $db = DB::getInstance();
+
+ if ($key == 'config') {
+ $result = $db->firstColumn(sprintf('SELECT config FROM %s WHERE name = ?;', Module::TABLE), $tpl->module->name);
+ }
+ else {
+ $db->exec(sprintf('
+ CREATE TABLE IF NOT EXISTS %s (
+ id INTEGER NOT NULL PRIMARY KEY,
+ key TEXT NULL,
+ document TEXT NOT NULL
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS %1$s_key ON %1$s (key);', $table));
+
+ if ($field) {
+ $result = $db->firstColumn(sprintf('SELECT document FROM %s WHERE %s;', $table, ($field . ' = ?')), $where_value);
+ }
+ else {
+ $result = null;
+ }
+ }
+
+ // Merge before update
+ if ($result) {
+ $result = json_decode((string) $result, true);
+ $params = array_merge($result, $params);
+ }
+
+ if (!empty($validate)) {
+ $schema = self::_readFile($validate, 'validate_schema', $tpl, $line);
+
+ if ($validate_only && is_string($validate_only)) {
+ $validate_only = explode(',', $validate_only);
+ $validate_only = array_map('trim', $validate_only);
+ }
+ else {
+ $validate_only = null;
+ }
+
+ try {
+ $s = JSONSchema::fromString($schema);
+
+ if ($validate_only) {
+ $s->validateOnly($params, $validate_only);
+ }
+ else {
+ $s->validate($params);
+ }
+ }
+ catch (\RuntimeException $e) {
+ throw new Brindille_Exception(sprintf("ligne %d: impossible de valider le schéma:\n%s\n\n%s",
+ $line, $e->getMessage(), json_encode($params, JSON_PRETTY_PRINT)));
+ }
+ }
+
+ $value = json_encode($params);
+
+ if ($key == 'config') {
+ $db->update(Module::TABLE, ['config' => $value], 'name = :name', ['name' => $tpl->module->name]);
+ return;
+ }
+
+ $document = $value;
+ if (!$result) {
+ $db->insert($table, compact('id', 'document', 'key'));
+
+ if ($assign_new_id) {
+ $tpl->assign($assign_new_id, $db->lastInsertId());
+ }
+ }
+ else {
+ $db->update($table, compact('document'), sprintf('%s = :match', $field), ['match' => $where_value]);
+ }
+ }
+
+ static public function delete(array $params, UserTemplate $tpl, int $line): void
+ {
+ if (!$tpl->module) {
+ throw new Brindille_Exception('Module name could not be found');
+ }
+
+ $db = DB::getInstance();
+ $table = 'module_data_' . $tpl->module->name;
+
+ // No table? No problem!
+ if (!$db->test('sqlite_master', 'name = ? AND type = \'table\'', $table)) {
+ return;
+ }
+
+ $where = [];
+ $args = [];
+ $i = 0;
+
+ foreach ($params as $key => $value) {
+ if ($key[0] == ':') {
+ $args[substr($key, 1)] = $value;
+ }
+ elseif ($key == 'where') {
+ $where[] = Sections::_moduleReplaceJSONExtract($value, $table);
+ }
+ else {
+ if ($key == 'id') {
+ $value = (int) $value;
+ }
+
+ if ($key !== 'id' && $key !== 'key') {
+ $args['key_' . $i] = '$.' . $key;
+ $key = sprintf('json_extract(document, :key_%d)', $i);
+ }
+
+ $where[] = $key . ' = :value_' . $i;
+ $args['value_' . $i] = $value;
+ $i++;
+ }
+ }
+
+ $where = implode(' AND ', $where);
+ $db->delete($table, $where, $args);
+ }
+
+ static public function captcha(array $params, UserTemplate $tpl, int $line)
+ {
+ $secret = md5(SECRET_KEY . Utils::getSelfURL(false));
+
+ if (isset($params['html'])) {
+ $c = Security::createCaptcha($secret, $params['lang'] ?? 'fr');
+ return sprintf('Merci d\'écrire %s en chiffres :
+
+ ',
+ $c['spellout'], $c['hash']);
+ }
+ elseif (isset($params['assign_hash']) && isset($params['assign_number'])) {
+ $c = Security::createCaptcha($secret, $params['lang'] ?? 'fr');
+ $tpl->assign($params['assign_hash'], $c['hash']);
+ $tpl->assign($params['assign_number'], $c['spellout']);
+ }
+ elseif (isset($params['verify'])) {
+ $hash = $_POST['f_c_43'] ?? '';
+ $number = $_POST['f_c_42'] ?? '';
+ }
+ elseif (array_key_exists('verify_number', $params)) {
+ $hash = $params['verify_hash'] ?? '';
+ $number = $params['verify_number'] ?? '';
+ }
+ else {
+ throw new Brindille_Exception(sprintf('Line %d: no valid arguments supplied for "captcha" function', $line));
+ }
+
+ $error = 'Réponse invalide à la vérification anti-robot';
+
+ if (!Security::checkCaptcha($secret, trim($hash), trim($number))) {
+ if (isset($params['assign_error'])) {
+ $tpl->assign($params['assign_error'], $error);
+ }
+ else {
+ throw new UserException($error);
+ }
+ }
+ }
+
+ static public function mail(array $params, UserTemplate $ut, int $line)
+ {
+ if (empty($params['to'])) {
+ throw new Brindille_Exception(sprintf('Ligne %d: argument "to" manquant pour la fonction "mail"', $line));
+ }
+
+ if (empty($params['subject'])) {
+ throw new Brindille_Exception(sprintf('Ligne %d: argument "subject" manquant pour la fonction "mail"', $line));
+ }
+
+ if (empty($params['body'])) {
+ throw new Brindille_Exception(sprintf('Ligne %d: argument "body" manquant pour la fonction "mail"', $line));
+ }
+
+ if (!empty($params['block_urls']) && preg_match('!https?://!', $params['subject'] . $params['body'])) {
+ throw new UserException('Merci de ne pas inclure d\'adresse web (http:…) dans le message');
+ }
+
+ $attachments = [];
+
+ if (!empty($params['attach_file'])) {
+ $attachments = (array) $params['attach_file'];
+
+ foreach ($attachments as &$file) {
+ $f = Files::get($file);
+
+ if (!$f) {
+ throw new UserException(sprintf('Le fichier à joindre "%s" n\'existe pas', $file));
+ }
+
+ if (!$f->canRead()) {
+ throw new UserException(sprintf('Vous n\'avez pas le droit d\'accéder au fichier à joindre "%s"', $file));
+ }
+
+ $file = $f;
+ }
+
+ unset($file);
+ }
+ elseif (!empty($params['attach_from'])) {
+ if (empty($ut->module)) {
+ throw new UserException('"attach_from" can only be called from within a module');
+ }
+
+ $attachments = (array) $params['attach_from'];
+
+ foreach ($attachments as &$file) {
+ $file = self::getFilePath($file, 'attach_from', $ut, $line);
+ $file = $ut->fetchToAttachment($file);
+ }
+
+ unset($file);
+ }
+
+ static $external = 0;
+ static $internal = 0;
+
+ if (is_string($params['to'])) {
+ $params['to'] = [$params['to']];
+ }
+
+ if (!count($params['to'])) {
+ throw new Brindille_Exception(sprintf('Ligne %d: aucune adresse destinataire n\'a été précisée pour la fonction "mail"', $line));
+ }
+
+ foreach ($params['to'] as &$to) {
+ $to = trim($to);
+ Email::validateAddress($to);
+ }
+
+ unset($to);
+
+ // Restrict sending recipients
+ if (!$ut->isTrusted()) {
+ $db = DB::getInstance();
+ $email_field = DynamicFields::getFirstEmailField();
+ $internal_count = $db->count('users', $db->where($email_field, 'IN', $params['to']));
+ $external_count = count($params['to']) - $internal_count;
+
+ if (($external_count + $external) > 1) {
+ throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe est limité à un envoi par page', $line));
+ }
+
+ if (($internal_count + $internal) > 10) {
+ throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse interne est limité à 10 envois par page', $line));
+ }
+
+ if ($external_count
+ && preg_match_all('!(https?://.*?)(?=\s|$)!', $params['subject'] . ' ' . $params['body'], $match, PREG_PATTERN_ORDER)) {
+ $config = Config::getInstance();
+
+ foreach ($match[1] as $m) {
+ $allowed = false;
+
+ if (0 === strpos($m, WWW_URL)) {
+ $allowed = true;
+ }
+ elseif (0 === strpos($m, BASE_URL)) {
+ $allowed = true;
+ }
+ elseif ($config->org_web && 0 === strpos($m, $config->org_web)) {
+ $allowed = true;
+ }
+
+ if (!$allowed) {
+ throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe interdit l\'utilisation d\'une adresse web autre que le site de l\'association : %s', $line, $m));
+ }
+ }
+ }
+ }
+
+ $context = count($params['to']) == 1 ? Emails::CONTEXT_PRIVATE : Emails::CONTEXT_BULK;
+ Emails::queue($context, $params['to'], null, $params['subject'], $params['body'], $attachments);
+
+ if (!$ut->isTrusted()) {
+ $internal += $internal_count;
+ $external_count += $external_count;
+ }
+ }
+
+ static public function debug(array $params, UserTemplate $tpl)
+ {
+ if (!count($params)) {
+ $params = $tpl->getAllVariables();
+ }
+
+ $dump = htmlspecialchars(ErrorManager::dump($params));
+
+ // FIXME: only send back HTML when content-type is text/html, or send raw text
+ $out = sprintf('%s ', $dump);
+
+ if (!empty($params['stop'])) {
+ echo $out; exit;
+ }
+
+ return $out;
+ }
+
+ static public function error(array $params, UserTemplate $tpl, int $line)
+ {
+ if (isset($params['admin'])) {
+ throw new Brindille_Exception($params['admin']);
+ }
+
+ throw new UserException($params['message'] ?? 'Erreur du module');
+ }
+
+ static protected function getFilePath(?string $path, string $arg_name, UserTemplate $ut, int $line)
+ {
+ if (empty($path)) {
+ throw new Brindille_Exception(sprintf('Ligne %d: argument "%s" manquant', $line, $arg_name));
+ }
+
+ if (substr($path, 0, 2) == './') {
+ $path = Utils::dirname($ut->_tpl_path) . substr($path, 1);
+ }
+ elseif (substr($path, 0, 1) != '/') {
+ $path = Utils::dirname($ut->_tpl_path) . '/' . $path;
+ }
+
+ $parts = explode('/', $path);
+ $out = [];
+
+ foreach ($parts as $part) {
+ if ($part == '..') {
+ array_pop($out);
+ }
+ else {
+ $out[] = $part;
+ }
+ }
+
+ $out = implode('/', $out);
+
+ if (preg_match('!\.\.|://|/\.|^\.!', $out)) {
+ throw new Brindille_Exception(sprintf('Ligne %d: argument "%s" invalide', $line, $arg_name));
+ }
+
+ return $out;
+ }
+
+ static public function _readFile(string $file, string $arg_name, UserTemplate $ut, int $line): string
+ {
+ $path = self::getFilePath($file ?? null, $arg_name, $ut, $line);
+
+ $file = Files::get(File::CONTEXT_MODULES . '/' . $path);
+
+ if ($file) {
+ $content = $file->fetch();
+ }
+ elseif (file_exists(ROOT . '/modules/' . $path)) {
+ $content = file_get_contents(ROOT . '/modules/' . $path);
+ }
+ else {
+ throw new Brindille_Exception(sprintf('Ligne %d : le fichier appelé "%s" n\'existe pas', $line, $path));
+ }
+
+ return $content;
+ }
+
+ static public function read(array $params, UserTemplate $ut, int $line): string
+ {
+ $content = self::_readFile($params['file'] ?? '', 'file', $ut, $line);
+
+ if (!empty($params['assign'])) {
+ $ut::__assign(['var' => $params['assign'], 'value' => $content], $ut, $line);
+ return '';
+ }
+
+ return $content;
+ }
+
+ static public function signature(): string
+ {
+ $file = Config::getInstance()->file('signature');
+
+ if (!$file) {
+ return '';
+ }
+
+ // We can't just use the image URL as it would not be accessible by PDF programs
+ $url = 'data:image/png;base64,' . base64_encode($file->fetch());
+
+ return sprintf(' ', $url);
+ }
+
+ static public function include(array $params, UserTemplate $ut, int $line): void
+ {
+ $path = self::getFilePath($params['file'] ?? null, 'file', $ut, $line);
+
+ // Avoid recursive loops
+ $from = $ut->get('included_from') ?? [];
+
+ if (in_array($path, $from)) {
+ throw new Brindille_Exception(sprintf('Ligne %d : boucle infinie d\'inclusion détectée : %s', $line, $path));
+ }
+
+ try {
+ $include = new UserTemplate($path);
+ $include->setParent($ut);
+ }
+ catch (\InvalidArgumentException $e) {
+ throw new Brindille_Exception(sprintf('Ligne %d : fonction "include" : le fichier à inclure "%s" n\'existe pas', $line, $path));
+ }
+
+ $params['included_from'] = array_merge($from, [$path]);
+
+ $include->assignArray(array_merge($ut->getAllVariables(), $params));
+
+ if (!empty($params['capture']) && preg_match('/^[a-z0-9_]+$/', $params['capture'])) {
+ $ut::__assign([$params['capture'] => $include->fetch()], $ut, $line);
+ }
+ else {
+ $include->display();
+ }
+
+ if (isset($params['keep'])) {
+ $keep = explode(',', $params['keep']);
+ $keep = array_map('trim', $keep);
+
+ foreach ($keep as $name) {
+ // Transmit variables
+ $ut::__assign(['var' => $name, 'value' => $include->get($name)], $ut, $line);
+ }
+ }
+
+ // Transmit nocache to parent template
+ if ($include->get('nocache')) {
+ $ut::__assign(['nocache' => true], $ut, $line);
+ }
+ }
+
+ static public function http(array $params, UserTemplate $tpl): void
+ {
+ if (headers_sent()) {
+ return;
+ }
+
+ if (isset($params['redirect'])) {
+
+ if (isset($params['code'])) {
+ http_response_code((int)$params['code']);
+ }
+
+ Utils::redirectDialog($params['redirect']);
+ }
+
+ if (isset($params['code'])) {
+ $tpl->setHeader('code', $params['code']);
+ }
+
+ if (!empty($params['type'])) {
+ if ($params['type'] == 'pdf') {
+ $params['type'] = 'application/pdf';
+ }
+
+ $tpl->setHeader('type', $params['type']);
+ }
+
+ if (isset($params['download'])) {
+ $tpl->setHeader('disposition', 'attachment');
+ $tpl->setHeader('filename', $params['download']);
+ }
+ elseif (isset($params['inline'])) {
+ $tpl->setHeader('disposition', 'inline');
+ $tpl->setHeader('filename', $params['inline']);
+ }
+ }
+
+ static public function _getFormKey(): string
+ {
+ return 'form_' . md5(Utils::getSelfURI(false));
+ }
+
+ /**
+ * @override
+ */
+ static public function button(array $params): string
+ {
+ // Always add CSRF protection when a submit button is present in the form
+ if (isset($params['type']) && $params['type'] == 'submit') {
+ $key = self::_getFormKey();
+ $params['csrf_key'] = $key;
+ }
+
+ return CommonFunctions::button($params);
+ }
+
+ /**
+ * @override
+ */
+ static public function delete_form(array $params, UserTemplate $tpl): string
+ {
+ $params['csrf_key'] = self::_getFormKey();
+ return self::form_errors([], $tpl) . CommonFunctions::delete_form($params);
+ }
+
+ static public function form_errors(array $params, UserTemplate $tpl): string
+ {
+ if (($e = $tpl->get('form_errors')) && is_array($e)) {
+ return sprintf('%s
', nl2br(htmlspecialchars(implode("\n", $e))));
+ }
+
+ return '';
+ }
+
+ static public function redirect(array $params): void
+ {
+ if (isset($params['force'])) {
+ Utils::redirectDialog($params['force']);
+ }
+ elseif (isset($_GET['_dialog'])) {
+ Utils::reloadParentFrame();
+ }
+ else {
+ Utils::redirectDialog($params['to'] ?? null);
+ }
+ }
+
+ static public function admin_files(array $params, UserTemplate $ut): string
+ {
+ if (empty($ut->module)) {
+ throw new Brindille_Exception('Module could not be found');
+ }
+
+ $tpl = Template::getInstance();
+
+ if (!isset($params['edit'])) {
+ $params['edit'] = false;
+ }
+
+ if (!isset($params['upload'])) {
+ $params['upload'] = $params['edit'];
+ }
+
+ if (isset($params['path']) && preg_match('!/\.|\.\.!', $params['path'])) {
+ throw new Brindille_Exception(sprintf('Line %d: "path" parameter is invalid: "%s"', $line, $params['path']));
+ }
+
+ $path = isset($params['path']) && preg_match('/^[a-z0-9_-]+$/i', $params['path']) ? '/' . $params['path'] : '';
+
+ $tpl->assign($params);
+ $tpl->assign('path', $ut->module->storage_root() . $path);
+ return '
Fichiers joints ' . $tpl->fetch('common/files/_context_list.tpl') . '';
+ }
+
+ static public function delete_file(array $params, UserTemplate $ut, int $line): void
+ {
+ if (empty($ut->module)) {
+ throw new Brindille_Exception('Module could not be found');
+ }
+
+ if (empty($params['path'])) {
+ throw new Brindille_Exception(sprintf('Line %d: "path" parameter is missing or empty', $line));
+ }
+
+ if (preg_match('!/\.|\.\.!', $params['path'])) {
+ throw new Brindille_Exception(sprintf('Line %d: "path" parameter is invalid: "%s"', $line, $params['path']));
+ }
+
+ Files::delete($ut->module->storage_root() . '/' . $params['path']);
+ }
+
+ static public function api(array $params, UserTemplate $ut, int $line): void
+ {
+ if (empty($params['path'])) {
+ throw new Brindille_Exception('"path" parameter is missing');
+ }
+
+ if (empty($params['method'])) {
+ throw new Brindille_Exception('"method" parameter is missing');
+ }
+
+ $path = trim($params['path'], '/');
+ $method = strtoupper($params['method']);
+ $assign = $params['assign'] ?? null;
+ $assign_code = $params['assign_code'] ?? null;
+ $fail = $params['fail'] ?? true;
+ $access_level = null;
+ $url = null;
+
+ unset($params['method'], $params['path'], $params['assign'], $params['assign_code'], $params['fail']);
+
+ if (isset($params['url'])) {
+ if (empty($params['user'])) {
+ throw new Brindille_Exception('"user" parameter is missing');
+ }
+
+ if (empty($params['password'])) {
+ throw new Brindille_Exception('"password" parameter is missing');
+ }
+
+ $url = str_replace('://', '://' . $params['user'] . '@' . $params['password'], $params['url']);
+ unset($params['user'], $params['password'], $params['url']);
+ }
+ else {
+ $access_level = $params['access'] ?? 'admin';
+ unset($params['access_level']);
+ }
+
+ $body = null;
+
+ if ($method !== 'GET' && array_key_exists('body', $params)) {
+ $body = $params['body'];
+ unset($params['body']);
+ }
+
+ $code = null;
+
+ // External HTTP request
+ if ($url) {
+ $http = new HTTP;
+ $url = rtrim($url, '/') . '/' . $path;
+ $body ??= json_encode($params);
+
+ if ($method === 'POST') {
+ $r = $http->POST($url, $body, $http::JSON);
+ }
+ else {
+ $r = $http->GET($url);
+ }
+
+ if ($fail && $r->fail) {
+ $body = json_decode($r->body);
+ throw new Brindille_Exception(sprintf('External API request failed: %d - %s', $r->status, $body->error ?? $r->error));
+ }
+
+ $code = $r->status;
+ $return = $r->body;
+
+ if ($assign && isset($r->headers['Content-Type']) && false !== strpos($r->headers['Content-Type'], '/json')) {
+ $return = @json_decode($return);
+ }
+ }
+ // Internal request
+ else {
+ $api = new API($method, $path, $params);
+
+ if ($access_level) {
+ $api->setAccessLevelByName($access_level);
+ }
+
+ $api->setAllowedFilesRoot($ut->module->storage_root());
+
+ try {
+ $return = $api->route();
+ }
+ catch (APIException $e) {
+ if ($fail) {
+ throw new Brindille_Exception(sprintf('Internal API request failed: %d - %s', $e->getCode(), $e->getMessage()));
+ }
+ }
+ }
+
+ if ($assign_code) {
+ $ut->assign($assign_code, $code);
+ }
+
+ if ($assign) {
+ $ut->assign($assign, $return);
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/UserTemplate/Modifiers.php b/src/include/lib/Paheko/UserTemplate/Modifiers.php
new file mode 100644
index 0000000..6cecf05
--- /dev/null
+++ b/src/include/lib/Paheko/UserTemplate/Modifiers.php
@@ -0,0 +1,607 @@
+ [Utils::class, 'moneyToInteger'],
+ 'array_transpose' => [Utils::class, 'array_transpose'],
+ 'check_email',
+ 'gettype',
+ 'arrayval',
+ 'explode',
+ 'implode',
+ 'keys',
+ 'values',
+ 'has',
+ 'has_key',
+ 'in',
+ 'key_in',
+ 'sort',
+ 'ksort',
+ 'max',
+ 'min',
+ 'quote_sql_identifier',
+ 'quote_sql',
+ 'sql_where',
+ 'sql_user_fields',
+ 'urlencode',
+ 'count_words',
+ 'or',
+ 'uuid',
+ 'key',
+ ];
+
+ const MODIFIERS_WITH_INSTANCE_LIST = [
+ 'map',
+ ];
+
+ const LEADING_NUMBER_REGEXP = '/^([\d.]+)\s*[.\)]\s*/';
+
+ static public function replace($str, $find, $replace = null): string
+ {
+ if (is_array($find) && null === $replace) {
+ return strtr($str, $find);
+ }
+
+ return str_replace((string)$find, (string)$replace, (string)$str);
+ }
+
+ static public function regexp_replace($str, $pattern, $replace)
+ {
+ return preg_replace((string) $pattern, (string) $replace, (string) $str);
+ }
+
+ static public function regexp_match($str, $pattern)
+ {
+ return (int) preg_match((string) $pattern, (string) $str);
+ }
+
+ static public function match($str, $pattern)
+ {
+ return (int) (stripos($str, $pattern) !== false);
+ }
+
+ static public function check_email($str)
+ {
+ if (!trim((string)$str)) {
+ return false;
+ }
+
+ try {
+ Email::validateAddress((string)$str);
+ }
+ catch (UserException $e) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * UTF-8 aware intelligent substr
+ * @param string $str UTF-8 string
+ * @param integer $length Maximum string length
+ * @param string $placeholder Placeholder text to append at the string if it has been cut
+ * @param boolean $strict_cut If true then will cut in the middle of words
+ * @return string String cut to $length or shorter
+ * @example |truncate:10:" (click to read more)":true
+ */
+ static public function truncate($str, $length = 80, $placeholder = '…', $strict_cut = false): string
+ {
+ if (mb_strlen($str) <= $length) {
+ return $str;
+ }
+
+ $str = mb_substr($str, 0, $length);
+
+ if (!$strict_cut) {
+ $cut = preg_replace('/[^\s.,;!?]*$/su', '', $str);
+
+ if (trim($cut) == '') {
+ $cut = $str;
+ }
+
+ $str = $cut;
+ }
+
+ return trim($str) . $placeholder;
+ }
+
+ static public function excerpt($str, $length = 600): string
+ {
+ $str = strip_tags($str);
+ $str = self::truncate($str, $length);
+ $str = preg_replace("/\n{2,}/", '', $str);
+ return '
' . $str . '
';
+ }
+
+ static public function atom_date($date)
+ {
+ return Utils::date_fr($date, DATE_ATOM);
+ }
+
+ static public function xml_escape($str)
+ {
+ return htmlspecialchars($str, ENT_XML1 | ENT_QUOTES);
+ }
+
+ static public function json_decode($str)
+ {
+ return json_decode($str, true);
+ }
+
+ static public function json_encode($obj)
+ {
+ return json_encode($obj, JSON_PRETTY_PRINT);
+ }
+
+ static public function minify(string $str, string $language = 'js'): string
+ {
+ // Remove comments
+ $str = preg_replace('!/\*.*?\*/!s', '', $str);
+
+ if ($language === 'css') {
+ static $regexp = <<<'EOS'
+ (?six)
+ # quotes
+ (
+ "(?:[^"\\]++|\\.)*+"
+ | '(?:[^'\\]++|\\.)*+'
+ )
+ |
+ # ; before } (and the spaces after it while we're here)
+ \s*+ ; \s*+ ( } ) \s*+
+ |
+ # all spaces around meta chars/operators
+ \s*+ ( [*$~^|]?+= | [{};,>~+-] | !important\b ) \s*+
+ |
+ # spaces right of ( [ :
+ ( [[(:] ) \s++
+ |
+ # spaces left of ) ]
+ \s++ ( [])] )
+ |
+ # spaces left (and right) of :
+ \s++ ( : ) \s*+
+ # but not in selectors: not followed by a {
+ (?!
+ (?>
+ [^{}"']++
+ | "(?:[^"\\]++|\\.)*+"
+ | '(?:[^'\\]++|\\.)*+'
+ )*+
+ {
+ )
+ |
+ # spaces at beginning/end of string
+ ^ \s++ | \s++ \z
+ |
+ # double spaces to single
+ (\s)\s+
+EOS;
+
+ $str = preg_replace('%' . $regexp . '%', '$1$2$3$4$5$6$7$8', $str);
+ }
+ else {
+ $str = preg_replace('!\s{2,}!', ' ', $str);
+ $str = preg_replace('!(\{|\(|\[)\s+!', '$1', $str);
+ $str = preg_replace('!\s+(\}|\)|\])!', '$1', $str);
+ }
+
+ return $str;
+ }
+
+ static public function remove_leading_number($str): string
+ {
+ return preg_replace(self::LEADING_NUMBER_REGEXP, '', trim($str));
+ }
+
+ static public function get_leading_number($str): ?string
+ {
+ $match = preg_match(self::LEADING_NUMBER_REGEXP, $str, $match);
+ return $match[1] ?? null;
+ }
+
+ static public function spell_out_number($number, string $locale = 'fr_FR', string $currency = 'euros'): string
+ {
+ $number = str_replace(',', '.', $number);
+ $number = strtok($number, '.');
+ $decimals = strtok('');
+
+ $out = numfmt_create($locale, \NumberFormatter::SPELLOUT)->format((float) $number);
+ $out .= ' ' . $currency;
+
+ if ($decimals > 0) {
+ $out .= sprintf(' et %s cents', numfmt_create($locale, \NumberFormatter::SPELLOUT)->format((float) $decimals));
+ }
+
+ return trim($out);
+ }
+
+ static public function parse_date($value)
+ {
+ if ($value instanceof \DateTimeInterface) {
+ return $value->format('Y-m-d');
+ }
+
+ if (empty($value) || !is_string($value)) {
+ return null;
+ }
+
+ if (preg_match('!^\d{2}/\d{2}/\d{2}$!', $value)) {
+ return \DateTime::createFromFormat('!d/m/y', $value)->format('Y-m-d');
+ }
+ elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $value)) {
+ return \DateTime::createFromFormat('!d/m/Y', $value)->format('Y-m-d');
+ }
+ elseif (preg_match('!^\d{4}-\d{2}-\d{2}$!', $value)) {
+ return $value;
+ }
+ else {
+ return false;
+ }
+ }
+
+ static public function parse_datetime($value)
+ {
+ if ($value instanceof \DateTimeInterface) {
+ return $value->format('Y-m-d H:i:s');
+ }
+
+ if (empty($value) || !is_string($value)) {
+ return null;
+ }
+
+ if (preg_match('!^\d{2}/\d{2}/\d{4}$\s+\d{2}:\d{2}!', $value)) {
+ return \DateTime::createFromFormat('!d/m/Y', $value)->format('Y-m-d H:i');
+ }
+ elseif (preg_match('!^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(:\d{2})?$!', $value, $match)) {
+ return $value . (isset($match[1]) ? '' : ':00');
+ }
+ else {
+ return false;
+ }
+ }
+
+ static public function parse_time($value)
+ {
+ if ($value instanceof \DateTimeInterface) {
+ return $value->format('H:i');
+ }
+
+ if (empty($value) || !is_string($value)) {
+ return null;
+ }
+
+ if (false !== strpos($value, ':')) {
+ $t = explode(':', $value);
+ }
+ elseif (false !== strpos($value, 'h')) {
+ $t = explode('h', $value);
+ }
+ else {
+ return null;
+ }
+
+ if (empty($t[0]) || !ctype_digit($t[0]) || $t[0] < 0 || $t[0] > 23) {
+ return false;
+ }
+
+ if (empty($t[1]) || !ctype_digit($t[1]) || $t[1] < 0 || $t[1] > 59) {
+ return false;
+ }
+
+ return sprintf('%02d:%02d', $t[0], $t[1]);
+ }
+
+ static public function math(string $expression, ... $params)
+ {
+ static $tokens_list = [
+ 'function' => '(?:round|ceil|floor|cos|sin|tan|asin|acos|atan|sinh|cosh|tanh|abs|max|min|exp|sqrt|log10|log|pi|random_int)\(',
+ 'open' => '\(',
+ 'close' => '\)',
+ 'number' => '-?\d+(?:[\.]\d+)?',
+ 'sign' => '[+\-\*\/%]',
+ 'separator' => ',',
+ 'space' => '\s+',
+ ];
+
+ // Treat comma as dot in strings
+ foreach ($params as &$param) {
+ $param = str_replace(',', '.', (string)$param);
+ }
+
+ unset($param);
+
+ $expression = vsprintf($expression, $params);
+
+ try {
+ $tokens = Brindille::tokenize($expression, $tokens_list);
+ }
+ catch (\InvalidArgumentException $e) {
+ throw new Brindille_Exception('Invalid value: ' . $e->getMessage());
+ }
+
+ $stack = [];
+ $expression = '';
+
+ foreach ($tokens as $i => $token) {
+ if ($token->type == 'function') {
+ $stack[] = ['function' => $token->value, 'value' => &$value];
+ }
+ elseif ($token->type == 'open') {
+ $stack[] = ['function' => null, 'value' => &$value];
+ }
+ elseif ($token->type == 'close') {
+ $last = array_pop($stack);
+
+ if (!$last) {
+ throw new Brindille_Exception('Invalid closing parenthesis, on position ' . $token->offset);
+ }
+ }
+ elseif ($token->type == 'separator') {
+ if (empty(end($stack)['function'])) {
+ throw new Brindille_Exception('Invalid comma outside of a function, on position ' . $token->offset);
+ }
+ }
+ elseif ($token->type === 'number') {
+ // Add spaces around numbers, so that 0--1 is treated as 0 - -1 = 0 + 1
+ $token->value = ' ' . $token->value . ' ';
+ }
+ elseif ($token->type === 'sign') {
+ if ($tokens[$i-1]->type === 'sign') {
+ throw new Brindille_Exception('Invalid sign following a sign, on position ' . $token->offset);
+ }
+ }
+
+ $expression .= $token->value;
+ }
+
+ if (count($stack)) {
+ throw new Brindille_Exception('Unmatched open parenthesis, on position ' . $token->offset);
+ }
+
+ try {
+ return @eval('return ' . $expression . ';') ?: 0;
+ }
+ catch (\Throwable $e) {
+ throw new Brindille_Exception(sprintf('Syntax error: "%s" (in "%s")', $e->getMessage(), $expression), 0, $e);
+ }
+ }
+
+ static public function map(UserTemplate $tpl, int $line, $array, string $modifier, ...$params): array
+ {
+ if (!is_array($array)) {
+ throw new Brindille_Exception('Supplied argument is not an array');
+ }
+
+ $callback = null;
+
+ if (!$tpl->checkModifierExists($modifier)) {
+ throw new Brindille_Exception('Unknown modifier: ' . $modifier);
+ }
+
+ $out = [];
+
+ foreach ($array as $key => $value) {
+ $out[$key] = $tpl->callModifier($modifier, $line, $value, ...$params);
+ }
+
+ return $out;
+ }
+
+ static public function gettype($v): string
+ {
+ $type = gettype($v);
+
+ switch($type) {
+ case 'object':
+ return 'array';
+ case 'double':
+ return 'float';
+ case 'NULL':
+ return 'null';
+ case 'resource':
+ case 'resource (closed)':
+ case 'unknown type':
+ throw new \LogicException('Unexpected type: ' . $type);
+ default:
+ return $type;
+ }
+ }
+
+ static public function arrayval($v): array
+ {
+ return (array) $v;
+ }
+
+ static public function explode($string, string $separator): array
+ {
+ return explode($separator, (string)$string);
+ }
+
+ static public function implode($array, string $separator): ?string
+ {
+ if (!is_array($array) && !is_object($array)) {
+ return $array;
+ }
+
+ return implode($separator, (array) $array);
+ }
+
+ static public function keys($array)
+ {
+ return array_keys((array)$array);
+ }
+
+ static public function key($array, $key)
+ {
+ return $array[$key] ?? null;
+ }
+
+ static public function values($array)
+ {
+ return array_values((array)$array);
+ }
+
+ static public function has($in, $value, $strict = false)
+ {
+ return in_array($value, (array)$in, $strict);
+ }
+
+ static public function in($value, $array, $strict = false)
+ {
+ return in_array($value, (array)$array, $strict);
+ }
+
+ static public function has_key($in, $key)
+ {
+ return array_key_exists($key, (array)$in);
+ }
+
+ static public function key_in($key, $array)
+ {
+ return array_key_exists($key, (array)$array);
+ }
+
+ static public function ksort($value)
+ {
+ $value = (array)$value;
+ uksort($value, 'strnatcasecmp');
+ return $value;
+ }
+
+ static public function sort($value)
+ {
+ $value = (array)$value;
+ natcasesort($value);
+ return $value;
+ }
+
+ static public function max($value)
+ {
+ return max((array)$value);
+ }
+
+ static public function min($value)
+ {
+ return min((array)$value);
+ }
+
+ static public function quote_sql_identifier($in, string $prefix = '')
+ {
+ if (null === $in) {
+ return '';
+ }
+
+ $db = DB::getInstance();
+
+ if ($prefix) {
+ $prefix = $db->quoteIdentifier($prefix) . '.';
+ }
+
+ if (is_array($in) || is_object($in)) {
+ return array_map(fn($a) => $prefix . $db->quoteIdentifier($a), (array) $in);
+ }
+
+ return $prefix . $db->quoteIdentifier($in);
+ }
+
+ static public function quote_sql($in)
+ {
+ if (null === $in) {
+ return '';
+ }
+
+ $db = DB::getInstance();
+
+ if (is_array($in) || is_object($in)) {
+ return array_map([$db, 'quote'], (array) $in);
+ }
+
+ return $db->quote($in);
+ }
+
+ static public function sql_where(...$args)
+ {
+ return DB::getInstance()->where(...$args);
+ }
+
+ static public function sql_user_fields($list, string $prefix = '', string $glue = ' '): string
+ {
+ $db = DB::getInstance();
+ $prefix = $prefix ? $db->quoteIdentifier($prefix) . '.' : '';
+ $out = [];
+ $glue = $db->quote($glue);
+
+ foreach ((array) $list as $field) {
+ if (!DynamicFields::get($field)) {
+ continue;
+ }
+
+ $out[] = sprintf('COALESCE(%s || %s%s, \'\')', $glue, $prefix, $db->quoteIdentifier($field));
+ }
+
+ if (!count($out)) {
+ return 'NULL';
+ }
+
+ return sprintf('LTRIM(%s, %s)', implode(' || ', $out), $glue);
+ }
+
+ static public function urlencode($str): string
+ {
+ return rawurlencode($str ?? '');
+ }
+
+ static public function count_words($str): int
+ {
+ return preg_match_all('/\S+/u', $str);
+ }
+
+ static public function or($in, $else)
+ {
+ if (empty($in) || (is_string($in) && trim($in) === '')) {
+ return $else;
+ }
+
+ return $in;
+ }
+
+ static public function uuid()
+ {
+ return Utils::uuid();
+ }
+}
diff --git a/src/include/lib/Paheko/UserTemplate/Modules.php b/src/include/lib/Paheko/UserTemplate/Modules.php
new file mode 100644
index 0000000..ba37b06
--- /dev/null
+++ b/src/include/lib/Paheko/UserTemplate/Modules.php
@@ -0,0 +1,465 @@
+begin();
+
+ $existing = $db->getAssoc(sprintf('SELECT id, name FROM %s;', Module::TABLE));
+ $list = self::listRaw();
+
+ $create = array_diff($list, $existing);
+ $delete = array_diff($existing, $list);
+ $existing = array_diff($list, $create);
+
+ $errors = [];
+
+ foreach ($create as $name) {
+ try {
+ self::create($name);
+ }
+ catch (ValidationException $e) {
+ $errors[] = $name . ': ' . $e->getMessage();
+ }
+ }
+
+ foreach ($delete as $name) {
+ self::get($name)->delete();
+ }
+
+ foreach ($existing as $name) {
+ try {
+ $f = self::get($name);
+ $f->updateFromINI();
+ $f->save();
+ $f->updateTemplates();
+ }
+ catch (ValidationException $e) {
+ $errors[] = $name . ': ' . $e->getMessage();
+ }
+ }
+
+ if (!$db->test(Module::TABLE, 'web = 1 AND enabled = 1')) {
+ $db->exec('UPDATE modules SET enabled = 1 WHERE id = (SELECT id FROM modules WHERE web = 1 ORDER BY system DESC LIMIT 1);');
+ }
+
+ $db->commit();
+
+ return $errors;
+ }
+
+ static public function refreshEnabledModules(): void
+ {
+ $db = DB::getInstance();
+ $db->begin();
+
+ foreach (self::list() as $module) {
+ try {
+ $module->updateFromINI();
+ $module->save();
+ $module->updateTemplates();
+ }
+ catch (ValidationException $e) {
+ // Ignore errors here
+ }
+ }
+
+ $db->commit();
+ }
+
+ /**
+ * List modules names from locally installed directories
+ */
+ static public function listRaw(bool $include_installed = true): array
+ {
+ $list = [];
+
+ // First list modules bundled
+ foreach (glob(Module::DIST_ROOT . '/*') as $file) {
+ if (!is_dir($file)) {
+ continue;
+ }
+
+ $name = Utils::basename($file);
+ $list[$name] = $name;
+ }
+
+ if ($include_installed) {
+ // Then add modules in files
+ foreach (Files::list(Module::ROOT) as $file) {
+ if ($file->type != $file::TYPE_DIRECTORY) {
+ continue;
+ }
+
+ $list[$file->name] = $file->name;
+ }
+ }
+
+ sort($list);
+ return $list;
+ }
+
+ /**
+ * List locally installed modules, directly from the filesystem, without creating them in the database cache
+ * (used in Install form)
+ */
+ static public function listLocal(): array
+ {
+ $list = self::listRaw(false);
+ $out = [];
+
+ foreach ($list as $name) {
+ $m = new Module;
+ $m->name = $name;
+
+ if (!$m->updateFromINI(false)) {
+ continue;
+ }
+
+ $out[$name] = $m;
+ }
+
+ return $out;
+ }
+
+ static public function create(string $name): ?Module
+ {
+ $module = new Module;
+ $module->name = $name;
+
+ if (!$module->updateFromINI()) {
+ return null;
+ }
+
+ $module->save();
+ $module->updateTemplates();
+ return $module;
+ }
+
+ /**
+ * List modules from the database
+ */
+ static public function list(): array
+ {
+ return EM::getInstance(Module::class)->all('SELECT * FROM @TABLE ORDER BY label COLLATE NOCASE ASC;');
+ }
+
+ static public function snippetsAsString(string $snippet, array $variables = []): string
+ {
+ return implode("\n", self::snippets($snippet, $variables));
+ }
+
+ static public function snippets(string $snippet, array $variables = []): array
+ {
+ $out = [];
+
+ foreach ($variables as &$var) {
+ if (is_object($var) && $var instanceof User) {
+ $var = $var->asModuleArray();
+ }
+ }
+
+ unset($var);
+
+ foreach (self::listForSnippet($snippet) as $module) {
+ // Maybe the cache was wrong and the template doesn't exist anymore
+ if (!$module->hasFile($snippet)) {
+ continue;
+ }
+
+ $out[$module->name] = $module->fetch($snippet, $variables);
+ }
+
+ return array_filter($out, fn($a) => trim($a) !== '');
+ }
+
+ static public function listForSnippet(string $snippet): array
+ {
+ return EM::getInstance(Module::class)->all('SELECT f.* FROM @TABLE f
+ INNER JOIN modules_templates t ON t.id_module = f.id
+ WHERE t.name = ? AND f.enabled = 1
+ ORDER BY f.label COLLATE NOCASE ASC;', $snippet);
+ }
+
+ static public function get(string $name): ?Module
+ {
+ return EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE name = ?;', $name);
+ }
+
+ static public function isEnabled(string $name): bool
+ {
+ return (bool) EM::getInstance(Module::class)->col('SELECT 1 FROM @TABLE WHERE name = ? AND enabled = 1;', $name);
+ }
+
+ static public function getWeb(): Module
+ {
+ $module = EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE web = 1 AND enabled = 1 LIMIT 1;');
+
+ // Just in case
+ if (!$module) {
+ $module = EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE web = 1 LIMIT 1;');
+
+ if (!$module) {
+ // Maybe we need to rescan modules?
+ self::refresh();
+ $module = EM::findOne(Module::class, 'SELECT * FROM @TABLE WHERE web = 1 LIMIT 1;');
+
+ if (!$module) {
+ throw new \LogicException('No web module exists');
+ }
+ }
+
+ $module->set('enabled', true);
+ $module->save();
+ }
+
+ return $module;
+ }
+
+ static public function route(string $uri): void
+ {
+ $page = null;
+ $path = null;
+ $has_local_file = null;
+ $has_dist_file = null;
+
+ // We are looking for a module
+ if (substr($uri, 0, 2) == 'm/') {
+ $path = substr($uri, 2);
+ $name = strtok($path, '/');
+ $path = strtok('');
+ $module = self::get($name);
+
+ if (!$module) {
+ http_response_code(404);
+ throw new UserException('This page does not exist.');
+ }
+ }
+ // Or: we are looking for the "web" module
+ else {
+ $module = self::getWeb();
+ }
+
+ // If path ends with trailing slash, then ask for index.html
+ if (!$path || substr($path, -1) == '/') {
+ $path .= 'index.html';
+ }
+
+ // Find out web path
+ if ($module->web && $module->enabled && substr($uri, 0, 2) !== 'm/') {
+ $uri = rawurldecode($uri);
+
+ if ($uri == '') {
+ $path = 'index.html';
+ }
+ elseif ($module->hasLocalFile($uri)) {
+ $path = $uri;
+ $has_local_file = true;
+ }
+ elseif ($module->hasDistFile($uri)) {
+ $path = $uri;
+ $has_dist_file = true;
+ }
+ elseif (($page = Web::getByURI($uri)) && $page->status == Page::STATUS_ONLINE) {
+ $path = $page->template();
+ $page = $page->asTemplateArray();
+ }
+ else {
+ $path = '404.html';
+ }
+ }
+ // 404 if module is not enabled, except for icon
+ elseif (!$module->enabled && !$module->system && $path != Module::ICON_FILE) {
+ http_response_code(404);
+ throw new UserException('This page is currently disabled.');
+ }
+
+ // Restrict access
+ if (isset($module->restrict_section, $module->restrict_level)) {
+ $session = Session::getInstance();
+
+ if (!$session->isLogged()) {
+ Utils::redirect('!login.php');
+ }
+
+ $session->requireAccess($module->restrict_section, $module->restrict_level);
+ }
+
+ $has_local_file ??= $module->hasLocalFile($path);
+ $has_dist_file ??= !$has_local_file && $module->hasDistFile($path);
+
+ // Check if the file actually exists in the module
+ if (!$has_local_file && !$has_dist_file) {
+
+ http_response_code(404);
+ throw new UserException('This path does not exist, sorry.');
+ }
+
+ $module->serve($path, $has_local_file, compact('uri', 'page'));
+ }
+
+ static public function import(string $path, bool $overwrite = false): ?Module
+ {
+ $zip = new ZipReader;
+
+ try {
+ $zip->open($path);
+ }
+ catch (\OutOfBoundsException $e) {
+ throw new \InvalidArgumentException('Invalid ZIP file: ' . $e->getMessage(), 0, $e);
+ }
+
+ $module_name = null;
+ $files = [];
+
+ foreach ($zip->iterate() as $name => $file) {
+ if ($name == 'modules' || $file['dir']) {
+ continue;
+ }
+
+ if (strpos($name, 'modules/') !== 0) {
+ continue;
+ }
+
+ $_mod = strtok(substr($name, strlen('modules/')), '/');
+ $_name = strtok('');
+
+ if (!$module_name) {
+ if (!$_mod || !preg_match(Module::VALID_NAME_REGEXP, $_mod)) {
+ throw new \InvalidArgumentException('Invalid module name (allowed: [a-z][a-z0-9]*(_[a-z0-9])*): ' . $_mod);
+ }
+
+ $module_name = $_mod;
+ }
+ elseif ($module_name !== $_mod) {
+ throw new \InvalidArgumentException('Two different modules names found.');
+ }
+
+ $files[$_name] = $name;
+ }
+
+ if (!$module_name || !count($files)) {
+ throw new \InvalidArgumentException('No module found in archive');
+ }
+
+ if (!array_key_exists('module.ini', $files)) {
+ throw new \InvalidArgumentException('Missing "module.ini" file in module');
+ }
+
+ $base = File::CONTEXT_MODULES . '/' . $module_name;
+
+ if (Files::exists($base) && !$overwrite) {
+ return null;
+ }
+
+ try {
+ $module = self::get($module_name);
+
+ if (!$module) {
+ $module = new Module;
+ $module->name = $module_name;
+ }
+
+ foreach ($files as $local_name => $source) {
+ $content = $zip->fetch($source);
+
+ // Don't store file if it already exists and is the same
+ if ($module->hasLocalFile($local_name) && ($file = Files::get($base . '/' . $local_name))) {
+ if ($file->md5 == md5($content)) {
+ continue;
+ }
+ }
+
+ // Same for dist file
+ if ($dist_file = $module->fetchDistFile($local_name)) {
+ if (md5($dist_file) == md5($content)) {
+ continue;
+ }
+ }
+
+ Files::createFromString($base . '/' . $local_name, $content);
+ }
+
+ if (!$module->updateFromINI()) {
+ throw new ValidationException('Le fichier module.ini est invalide.');
+ }
+
+ $module->save();
+ $module->updateTemplates();
+
+ return $module;
+ }
+ catch (ValidationException $e) {
+ $dir = Files::get($base);
+
+ // Delete any extracted files so far
+ if ($dir) {
+ $dir->delete();
+ }
+
+ throw new \InvalidArgumentException('Invalid file: ' . $e->getMessage(), 0, $e);
+ }
+ catch (\Exception $e) {
+ $dir = Files::get($base);
+
+ // Delete any extracted files so far
+ if ($dir) {
+ $dir->delete();
+ }
+
+ throw $e;
+ }
+ finally {
+ unset($zip);
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/UserTemplate/Sections.php b/src/include/lib/Paheko/UserTemplate/Sections.php
new file mode 100644
index 0000000..38dfc0f
--- /dev/null
+++ b/src/include/lib/Paheko/UserTemplate/Sections.php
@@ -0,0 +1,1361 @@
+ [self::class, 'selectStart'],
+ '/select' => [self::class, 'selectEnd'],
+ '#form' => [self::class, 'formStart'],
+ '/form' => [self::class, 'formEnd'],
+ 'else:form' => [self::class, 'formElse'],
+ '#capture' => [self::class, 'captureStart'],
+ '/capture' => [self::class, 'captureEnd'],
+ ];
+
+ const SQL_RESERVED_PARAMS = [
+ 'select',
+ 'tables',
+ 'where',
+ 'group',
+ 'having',
+ 'order',
+ 'begin',
+ 'limit',
+ 'assign',
+ 'debug',
+ 'count',
+ ];
+
+ /**
+ * List of tables and columns that are restricted in SQL queries
+ *
+ * ~column means the column will always be returned as NULL
+ * -column or !table means trying to access this column or table will return an error
+ * see KD2/DB/SQLite3 code for details
+ *
+ * Note: column restrictions are only possible with PHP >= 8.0
+ */
+ const SQL_TABLES = [
+ // Allow access to all tables
+ '*' => null,
+ // Restrict access to private fields in users
+ 'users' => ['~password', '~pgp_key', '~otp_secret'],
+ // Restrict access to some private tables
+ '!emails' => null,
+ '!emails_queue' => null,
+ '!compromised_passwords_cache' => null,
+ '!compromised_passwords_cache_ranges' => null,
+ '!api_credentials' => null,
+ '!plugins_signals' => null,
+ '!config' => null,
+ '!users_sessions' => null,
+ '!logs' => null,
+ ];
+
+ static protected $_cache = [];
+
+ static public function selectStart(string $name, string $sql, UserTemplate $tpl, int $line): string
+ {
+ $sql = strtok($sql, ';');
+ $extra_params = strtok('');
+
+ $i = 0;
+ $params = '';
+
+ $sql = preg_replace_callback('/\{(.*?)\}/', function ($match) use (&$params, &$i) {
+ // Raw SQL
+ if ('!' === substr($match[1], 0, 1)) {
+ $params .= ' !' . $i . '=' . substr($match[1], 1);
+ return '!' . $i++;
+ }
+ else {
+ $params .= ' :p' . $i . '=' . $match[1];
+ return ':p' . $i++;
+ }
+ }, $sql);
+
+ $sql = 'SELECT ' . $sql;
+ $sql = var_export($sql, true);
+
+ $params .= ' sql=' . $sql . ' ' . $extra_params;
+
+ return $tpl->_section('sql', $params, $line);
+ }
+
+ static public function selectEnd(string $name, string $params, UserTemplate $tpl, int $line): string
+ {
+ return $tpl->_close('sql', '{{/select}}');
+ }
+
+ static public function formStart(string $name, string $params_str, UserTemplate $tpl, int $line): string
+ {
+ $tpl->_push($tpl::SECTION, 'form');
+
+ $params = $tpl->_parseArguments($params_str, $line);
+
+ if (isset($params['on'])
+ && ($on = $tpl->getValueFromArgument($params['on']))
+ && preg_match('/^[a-z0-9-]+$/i', $on)) {
+ $if = sprintf('$_POST[%s]', var_export($on, true));
+ }
+ else {
+ $if = '$_POST';
+ }
+
+ unset($params['on']);
+ $params = $tpl->_exportArguments($params);
+
+ return sprintf('';
+ /*
+ . sprintf('$params = %s; ', $params)
+ . '$form_errors = []; '
+ . 'if (!\KD2\Form::check(\'form_\' . $hash, $rules, $form_errors)) { '
+ . '$this->assign(\'form_errors\', \KD2\Form::getErrorMessages($form_errors, \'fr\')); '
+ . '} ?>';
+ */
+ }
+
+ static public function formElse(string $name, string $params_str, UserTemplate $tpl, int $line): string
+ {
+ return 'assign(\'form_errors\', [$e->getMessage()]); '
+ . '?>';
+ }
+
+ static public function formEnd(string $name, string $params_str, UserTemplate $tpl, int $line): string
+ {
+ if ($tpl->_lastName() !== 'form') {
+ throw new Brindille_Exception(sprintf('"%s": block closing does not match last block "%s" opened', $name . $params_str, $tpl->_lastName()));
+ }
+
+ $type = $tpl->_lastType();
+ $tpl->_pop();
+
+ $out = '';
+
+ if ($type === $tpl::SECTION) {
+ $out .= self::formElse($name, $params_str, $tpl, $line);
+ }
+
+ $out .= '';
+
+ $out = str_replace(' ?>_parseArguments($params_str, $line);
+
+ if (!isset($params['assign']) || !is_string($params['assign'])) {
+ throw new Brindille_Exception(sprintf('"%s": missing "assign" parameter', $name));
+ }
+
+ $assign = $tpl->getValueFromArgument($params['assign']);
+
+ $tpl->_push($tpl::SECTION, 'capture');
+
+ return sprintf('',
+ var_export($assign, true));
+ }
+
+ static public function captureEnd(string $name, string $params_str, UserTemplate $tpl, int $line): string
+ {
+ $last = $tpl->_lastName();
+
+ if ($last !== 'capture') {
+ throw new Brindille_Exception(sprintf('"%s": block closing does not match last block "%s" opened', $name . $params_str, $last));
+ }
+
+ $tpl->_pop();
+
+ return sprintf('assign(array_pop($capture_assign), ob_get_clean()); ?>');
+ }
+
+ static protected function _debug(string $str): void
+ {
+ echo sprintf('%s ', htmlspecialchars($str));
+ }
+
+ static protected function _debugExplain(string $sql): void
+ {
+ $explain = '';
+
+ try {
+ $r = DB::getInstance()->get('EXPLAIN QUERY PLAN ' . $sql);
+
+ foreach ($r as $e) {
+ $explain .= $e->detail . "\n";
+ }
+ }
+ catch (DB_Exception $e) {
+ $explain = 'Error: ' . $e->getMessage();
+ }
+
+ self::_debug($explain);
+ }
+
+ static protected function cache(string $id, callable $callback)
+ {
+ if (!array_key_exists($id, self::$_cache)) {
+ self::$_cache[$id] = $callback();
+ }
+
+ return self::$_cache[$id];
+ }
+
+ /**
+ * Creates indexes for json_extract expressions
+ */
+ static protected function _createModuleIndexes(string $table, string $where): void
+ {
+ preg_match_all('/json_extract\s*\(\s*document\s*,\s*(?:\'(.*?)\'|\"(.*?)\")\s*\)/', $where, $match, PREG_SET_ORDER);
+
+ if (!count($match)) {
+ return;
+ }
+
+ $search_params = [];
+
+ foreach ($match as $m) {
+ $search_params[$m[2] ?? $m[1]] = $m[0];
+ }
+
+ if (!count($search_params)) {
+ return;
+ }
+
+ ksort($search_params);
+ $hash = sha1(implode('', array_keys($search_params)));
+
+ $db = DB::getInstance();
+
+ try {
+ $db->exec(sprintf('CREATE INDEX IF NOT EXISTS %s_auto_%s ON %1$s (%s);', $table, $hash, implode(', ', $search_params)));
+ }
+ catch (DB_Exception $e) {
+ throw new Brindille_Exception(sprintf("à la ligne %d, impossible de créer l'index, erreur SQL :\n%s\n\nRequête exécutée :\n%s", $line, $db->lastErrorMsg(), $sql));
+ }
+ }
+
+ static public function load(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $name = $params['module'] ?? ($tpl->module->name ?? null);
+
+ if (!$name) {
+ throw new Brindille_Exception('Unique module name could not be found');
+ }
+
+ $table = 'module_data_' . $name;
+ $params['tables'] = $table;
+
+ $db = DB::getInstance();
+ $has_table = $db->test('sqlite_master', 'type = \'table\' AND name = ?', $table);
+
+ if (!$has_table) {
+ return;
+ }
+
+ $delete_table = null;
+
+ // Cannot use json_each with authorizer before SQLite 3.41.0
+ // @see https://sqlite.org/forum/forumpost/d28110be11
+ if (isset($params['each']) && !$db->hasFeatures('json_each_readonly')) {
+ $t = 'module_tmp_each' . md5($params['each']);
+
+ // We create a temporary table, to get around authorizer issues in SQLite
+ $db->exec(sprintf('DROP TABLE IF EXISTS %s; CREATE TEMP TABLE IF NOT EXISTS %1$s (id, key, value, document);', $t));
+ $db->exec(sprintf('INSERT INTO %s SELECT a.id, a.key, value, a.document FROM %s AS a, json_each(a.document, %s);',
+ $t, $table, $db->quote('$.' . trim($params['each']))
+ ));
+
+ $params['tables'] = $t;
+ $params['select'] = 'value';
+ unset($params['each']);
+ }
+ elseif (isset($params['each'])) {
+ $params['tables'] = sprintf('%s AS a, json_each(a.document, %s)', $table, $db->quote('$.' . trim($params['each'])));
+ unset($params['each']);
+ }
+
+ if (!isset($params['where'])) {
+ $params['where'] = '1';
+ }
+ else {
+ $params['where'] = self::_moduleReplaceJSONExtract($params['where'], $table);
+ }
+
+ if (array_key_exists('key', $params)) {
+ $params['where'] .= ' AND key = :key';
+ $params['limit'] = 1;
+ $params[':key'] = $params['key'];
+ unset($params['key']);
+ }
+ elseif (array_key_exists('id', $params)) {
+ $params['where'] .= ' AND id = :id';
+ $params['limit'] = 1;
+ $params[':id'] = $params['id'];
+ unset($params['id']);
+ }
+
+ // Replace '$.name = "value"' parameters with json_extract
+ foreach ($params as $key => $value) {
+ $k = substr($key, 0, 1);
+ if ($k == ':' || in_array($key, self::SQL_RESERVED_PARAMS)) {
+ continue;
+ }
+
+ if (is_bool($value)) {
+ $v = '= ' . (int) $value;
+ }
+ elseif (null === $value) {
+ $v = 'IS NULL';
+ }
+ else {
+ $v = sprintf(':quick_%s', sha1($key));
+ $params[$v] = $value;
+ $v = '= ' . $v;
+ }
+
+ $params['where'] .= sprintf(' AND json_extract(document, %s) %s', $db->quote('$.' . $key), $v);
+ unset($params[$key]);
+ }
+
+ $s = 'id, key, document AS json';
+
+ if (isset($params['select'])) {
+ $params['select'] = $s . ', ' . self::_moduleReplaceJSONExtract($params['select'], $table);
+ }
+ else {
+ $params['select'] = $s;
+ }
+
+ if (isset($params['group'])) {
+ $params['group'] = self::_moduleReplaceJSONExtract($params['group'], $table);
+ }
+
+ if (isset($params['having'])) {
+ $params['having'] = self::_moduleReplaceJSONExtract($params['having'], $table);
+ }
+
+ if (isset($params['order'])) {
+ $params['order'] = self::_moduleReplaceJSONExtract($params['order'], $table);
+ }
+
+ // Try to create an index if required
+ self::_createModuleIndexes($table, $params['where']);
+
+ $assign = $params['assign'] ?? null;
+ unset($params['assign']);
+
+ $query = self::sql($params, $tpl, $line);
+
+ foreach ($query as $row) {
+ if (isset($row['json'])) {
+ $json = json_decode($row['json'], true);
+
+ if (is_array($json)) {
+ unset($row['json']);
+ $row = array_merge($row, $json);
+ }
+ }
+
+ if (isset($assign)) {
+ $tpl::__assign(['var' => $assign, 'value' => $row], $tpl, $line);
+ }
+
+ yield $row;
+ }
+ }
+
+ static protected function _getModuleColumnsFromSchema(string $schema, ?string $columns, UserTemplate $tpl, int $line): array
+ {
+ $schema = Functions::_readFile($schema, 'schema', $tpl, $line);
+ $schema = json_decode($schema, true);
+
+ if (!$schema) {
+ throw new Brindille_Exception(sprintf("ligne %d: impossible de lire le schéma:\n%s",
+ $line, json_last_error_msg()));
+ }
+
+ if (empty($schema['properties'])) {
+ return [];
+ }
+
+ $out = [];
+
+ if (null !== $columns) {
+ $columns = explode(',', $columns);
+ $columns = array_map('trim', $columns);
+ }
+ else {
+ $columns = array_keys($schema['properties']);
+ }
+
+ foreach ($columns as $key) {
+ $rule = $schema['properties'][$key] ?? null;
+
+ // This column is not in the schema
+ if (!$rule) {
+ continue;
+ }
+
+ $types = is_array($rule['type']) ? $rule['type'] : [$rule['type']];
+
+ $out[$key] = [
+ 'label' => $rule['description'] ?? null,
+ 'select' => sprintf('json_extract(document, \'$.%s\')', $key),
+ '_json_decode' => in_array('array', $types) || in_array('object', $types),
+ ];
+ }
+
+ return $out;
+ }
+
+ static public function _moduleReplaceJSONExtract(string $str, string $table): string
+ {
+ $str = str_replace('@TABLE', $table, $str);
+
+ if (!strstr($str, '$')) {
+ return $str;
+ }
+
+ $db = DB::getInstance();
+
+ return preg_replace_callback(
+ '/(?:([\w\d]+)\.)?\$(\$[\[\.][\w\d\.\[\]#]+)/',
+ fn ($m) => sprintf('json_extract(%sdocument, %s)',
+ !empty($m[1]) ? $db->quote($m[1]) . '.' : '',
+ $db->quote($m[2])
+ ),
+ $str
+ );
+ }
+
+ static public function list(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ if (empty($params['schema']) && empty($params['select'])) {
+ throw new Brindille_Exception('Missing schema parameter');
+ }
+
+ $name = $params['module'] ?? ($tpl->module->name ?? null);
+
+ if (!$name) {
+ throw new Brindille_Exception('Unique module name could not be found');
+ }
+
+ $table = 'module_data_' . $name;
+
+ $db = DB::getInstance();
+ $has_table = $db->test('sqlite_master', 'type = \'table\' AND name = ?', $table);
+
+ if (!$has_table) {
+ return;
+ }
+
+ if (!isset($params['where'])) {
+ $where = '1';
+ }
+ else {
+ $where = self::_moduleReplaceJSONExtract($params['where'], $table);
+ }
+
+ $columns = [];
+
+ if (!empty($params['select'])) {
+ foreach (explode(';', $params['select']) as $i => $c) {
+ $c = trim($c);
+
+ $pos = strripos($c, ' AS ');
+
+ if ($pos) {
+ $select = trim(substr($c, 0, $pos));
+ $label = str_replace("''", "'", trim(substr($c, $pos + 5), ' \'"'));
+ }
+ else {
+ $select = $c;
+ $label = null;
+ }
+
+ if ($select === '*') {
+ throw new Brindille_Exception(sprintf('Line %d: "*" cannot be used in "select" parameter', $line));
+ }
+
+ $select = self::_moduleReplaceJSONExtract($select, $table);
+
+ $columns['col' . ($i + 1)] = compact('label', 'select');
+ }
+
+ if (isset($params['order'])) {
+ if (!is_int($params['order']) && !ctype_digit($params['order'])) {
+ throw new Brindille_Exception(sprintf('Line %d: "order" parameter must be the number of the column (starting from 1)', $line));
+ }
+
+ $params['order'] = 'col' . (int)$params['order'];
+ }
+ }
+ else {
+ $columns = self::_getModuleColumnsFromSchema($params['schema'], $params['columns'] ?? null, $tpl, $line);
+ }
+
+ $columns['id'] = [];
+ $columns['key'] = [];
+ $columns['document'] = [];
+
+ $list = new DynamicList($columns, $table);
+
+ static $reserved_keywords = ['max', 'order', 'desc', 'debug', 'explain', 'schema', 'columns', 'select', 'where', 'module', 'disable_user_ordering', 'check'];
+
+ foreach ($params as $key => $value) {
+ if ($key[0] == ':') {
+ if (false !== strpos($where, $key)) {
+ $list->setParameter(substr($key, 1), $value);
+ }
+ }
+ elseif (!in_array($key, $reserved_keywords)) {
+ $hash = sha1($key);
+ $where .= sprintf(' AND json_extract(document, %s) = :quick_%s', $db->quote('$.' . $key), $hash);
+ $list->setParameter('quick_' . $hash, $value);
+ }
+ }
+
+ $list->setConditions($where);
+ $list->setPageSize((int) ($params['max'] ?? 50));
+
+ if (isset($params['order'])) {
+ $list->orderBy($params['order'], $params['desc'] ?? false);
+ }
+
+ $list->setModifier(function(&$row) use ($columns) {
+ $row->original = clone $row;
+ unset($row->original->id, $row->original->key, $row->original->document);
+
+ // Decode arrays/objects
+ foreach ($columns as $name => $column) {
+ if (!empty($column['_json_decode']) && isset($row->$name) && is_string($row->$name)) {
+ $row->$name = json_decode($row->$name, true);
+ }
+ }
+
+ if (null !== $row->document) {
+ $row = array_merge(json_decode($row->document, true), (array)$row);
+ }
+ else {
+ $row = (array) $row;
+ }
+ });
+
+ $list->setExportCallback(function(&$row) {
+ $row = $row['original'];
+ });
+
+ // Try to create an index if required
+ self::_createModuleIndexes($table, $where);
+
+ if (empty($params['disable_user_ordering'])) {
+ $list->loadFromQueryString();
+ }
+
+ if (!empty($params['debug'])) {
+ self::_debug($list->SQL());
+ }
+
+ if (!empty($params['explain'])) {
+ self::_debugExplain($list->SQL());
+ }
+
+ try {
+ $i = $list->iterate();
+
+ // If there is nothing to iterate, just stop
+ if (!$i->valid()) {
+ return;
+ }
+ }
+ catch (DB_Exception $e) {
+ throw new Brindille_Exception(sprintf("Line %d: invalid SQL query: %s\nQuery: %s", $line, $e->getMessage(), $list->SQL()));
+ }
+
+ $tpl = Template::getInstance();
+
+ /*
+ FIXME: Export is broken currently
+ $export_url = Utils::getSelfURI();
+ $export_url .= strstr($export_url, '?') ? '&export=' : '?export=';
+ printf('%s
', $tpl->widgetExportMenu(['href' => $export_url, 'class' => 'menu-btn-right']));
+ */
+
+ $tpl->assign(compact('list'));
+ $tpl->assign('check', $params['check'] ?? false);
+ $tpl->assign('disable_user_ordering', $params['disable_user_ordering'] ?? false);
+ $tpl->display('common/dynamic_list_head.tpl');
+
+ yield from $i;
+
+ echo '';
+ echo '
';
+
+ echo $list->getHTMLPagination();
+ }
+
+ static protected function getAccountCodeCondition($codes, string $column = 'code')
+ {
+ if (!is_array($codes)) {
+ $codes = explode(',', $codes);
+ }
+
+ $db = DB::getInstance();
+
+ foreach ($codes as &$code) {
+ $code = $column . ' LIKE ' . $db->quote($code);
+ }
+
+ unset($code);
+
+ return implode(' OR ', $codes);
+ }
+
+ static public function balances(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $db = DB::getInstance();
+
+ $params['where'] ??= '';
+ $params['tables'] = 'acc_accounts_balances';
+
+ if (isset($params['codes'])) {
+ $params['where'] .= sprintf(' AND (%s)', self::getAccountCodeCondition($params['codes']));
+ unset($params['codes']);
+ }
+
+ if (isset($params['year'])) {
+ $params['where'] .= ' AND id_year = :year';
+ $params[':year'] = $params['year'];
+ unset($params['year']);
+ }
+
+ $params['select'] = $params['select'] ?? 'SUM(credit) AS credit, SUM(debit) AS debit, SUM(balance) AS balance, label, code';
+
+ return self::sql($params, $tpl, $line);
+ }
+
+ static public function accounts(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $db = DB::getInstance();
+
+ $params['where'] ??= '';
+ $params['tables'] = 'acc_accounts';
+
+ if (isset($params['codes'])) {
+ $params['codes'] = explode(',', $params['codes']);
+
+ foreach ($params['codes'] as &$code) {
+ $code = 'code LIKE ' . $db->quote($code);
+ }
+
+ $params['where'] .= sprintf(' AND (%s)', implode(' OR ', $params['codes']));
+
+ unset($code, $params['codes']);
+ }
+ elseif (isset($params['id'])) {
+ $params['where'] .= ' AND id = :id';
+ $params[':id'] = (int) $params['id'];
+ unset($params['id']);
+ }
+
+ return self::sql($params, $tpl, $line);
+ }
+
+ static public function years(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['tables'] = 'acc_years';
+
+ $params['where'] ??= '';
+
+ if (isset($params['closed'])) {
+ $params['where'] .= sprintf(' AND closed = %d', $params['closed']);
+ unset($params['closed']);
+ }
+
+ return self::sql($params, $tpl, $line);
+ }
+
+ static public function users(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+
+ $db = DB::getInstance();
+
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+ $login_field = DynamicFields::getLoginField();
+ $number_field = DynamicFields::getNumberField();
+ $email_field = DynamicFields::getFirstEmailField();
+
+ if (empty($params['select'])) {
+ $params['select'] = 'u.*';
+ }
+
+ $params['select'] .= sprintf(', u.id AS id, %s AS _name, u.%s AS _login, u.%s AS _number, u.%s AS _email',
+ $id_field,
+ $db->quoteIdentifier($login_field),
+ $db->quoteIdentifier($number_field),
+ $db->quoteIdentifier($email_field)
+ );
+
+ $params['tables'] = 'users_view AS u';
+
+ if (isset($params['id']) && is_array($params['id'])) {
+ $params['id'] = array_map('intval', $params['id']);
+ $params['where'] .= ' AND u.' . $db->where('id', $params['id']);
+ unset($params['id']);
+ }
+ elseif (isset($params['id'])) {
+ $params['where'] .= ' AND u.id = :id';
+ $params[':id'] = (int) $params['id'];
+ unset($params['id']);
+ }
+ elseif (isset($params['id_parent'])) {
+ $params['where'] .= ' AND u.id_parent = :id_parent';
+ $params[':id_parent'] = (int) $params['id_parent'];
+ unset($params['id_parent']);
+ }
+
+ if (!empty($params['search_name'])) {
+ $params['tables'] .= sprintf(' INNER JOIN users_search AS us ON us.id = u.id AND %s LIKE :search_name ESCAPE \'\\\' COLLATE NOCASE',
+ DynamicFields::getNameFieldsSearchableSQL('us'));
+ $params[':search_name'] = '%' . Utils::unicodeTransliterate($params['search_name']) . '%';
+ unset($params['search_name']);
+ }
+
+ if (empty($params['order'])) {
+ $params['order'] = 'u.id';
+ }
+
+ $files_fields = array_keys(DynamicFields::getInstance()->fieldsByType('file'));
+
+ foreach (self::sql($params, $tpl, $line) as $row) {
+ foreach ($row as $key => &$value) {
+ if (in_array($key, $files_fields)) {
+ $value = array_map(fn($a) => $a->export(), array_values(Files::list(File::CONTEXT_USER . '/' . $row['id'] . '/' . $key)));
+ }
+ }
+
+ unset($value);
+
+ yield $row;
+ }
+ }
+
+ static public function subscriptions(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+
+ $number_field = DynamicFields::getNumberField();
+
+ $params['select'] = sprintf('su.expiry_date, su.date, s.label, su.paid, su.expected_amount');
+ $params['tables'] = 'services_users su INNER JOIN services s ON s.id = su.id_service';
+
+ if (isset($params['user'])) {
+ $params['where'] .= ' AND su.id_user = :id_user';
+ $params[':id_user'] = (int) $params['user'];
+ unset($params['user']);
+ }
+
+ if (isset($params['id_service'])) {
+ $params['where'] .= ' AND su.id_service = :id_service';
+ $params[':id_service'] = (int) $params['id_service'];
+ unset($params['id_service']);
+ }
+
+ if (!empty($params['active'])) {
+ $params['having'] = 'MAX(su.expiry_date) >= date()';
+ unset($params['active']);
+ }
+
+ if (isset($params['active']) && empty($params['active'])) {
+ $params['having'] = 'MAX(su.expiry_date) < date()';
+ unset($params['active']);
+ }
+
+ if (empty($params['order'])) {
+ $params['order'] = 'su.id';
+ }
+
+ $params['group'] = 'su.id_user, su.id_service';
+
+ return self::sql($params, $tpl, $line);
+ }
+
+ static public function transactions(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $db = DB::getInstance();
+ $params['where'] ??= '';
+
+ $id_field = DynamicFields::getNameFieldsSQL();
+
+ $params['select'] = sprintf('t.*, SUM(l.credit) AS credit, SUM(l.debit) AS debit,
+ GROUP_CONCAT(DISTINCT a.code) AS accounts_codes,
+ (SELECT GROUP_CONCAT(DISTINCT %s) FROM users WHERE id IN (SELECT id_user FROM acc_transactions_users WHERE id_transaction = t.id)) AS users_names', $id_field);
+ $params['tables'] = 'acc_transactions AS t
+ INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id
+ INNER JOIN acc_accounts AS a ON l.id_account = a.id';
+ $params['group'] = 't.id';
+
+ if (isset($params['id']) && is_array($params['id'])) {
+ $params['where'] .= ' AND t.' . $db->where('id', array_map('intval', $params['id']));
+ unset($params['id']);
+ }
+ elseif (isset($params['id'])) {
+ $params['where'] .= ' AND t.id = :id';
+ $params[':id'] = (int) $params['id'];
+ unset($params['id']);
+ }
+ elseif (isset($params['user'])) {
+ $params['where'] .= ' AND t.id IN (SELECT id_transaction FROM acc_transactions_users WHERE id_user = :id_user)';
+ $params[':id_user'] = (int) $params['user'];
+ unset($params['user']);
+ }
+
+ if (isset($params['debit_codes'])) {
+ $params['where'] .= sprintf(' AND l.credit = 0 AND (%s)', self::getAccountCodeCondition($params['debit_codes'], 'a.code'));
+ }
+ elseif (isset($params['credit_codes'])) {
+ $params['where'] .= sprintf(' AND l.debit = 0 AND (%s)', self::getAccountCodeCondition($params['credit_codes'], 'a.code'));
+ }
+
+ unset($params['debit_codes'], $params['credit_codes']);
+
+ if (isset($params['order']) && ctype_alpha(substr((string) $params['order'], 0, 1))) {
+ $params['order'] = 't.' . $params['order'];
+ }
+
+ return self::sql($params, $tpl, $line);
+ }
+
+ static public function transaction_lines(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+
+ if (isset($params['transaction'])) {
+ $params['where'] .= ' AND l.id_transaction = :transaction';
+ $params[':transaction'] = (int) $params['transaction'];
+ unset($params['transaction']);
+ }
+
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+
+ $params['select'] = sprintf('l.*, a.code AS account_code, a.label AS account_label');
+ $params['tables'] = 'acc_transactions_lines AS l
+ INNER JOIN acc_accounts AS a ON l.id_account = a.id';
+
+ return self::sql($params, $tpl, $line);
+ }
+
+ static public function transaction_users(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+
+ if (isset($params['id_transaction'])) {
+ $params['where'] = ' AND tu.id_transaction = :id_transaction';
+ $params[':id_transaction'] = (int) $params['id_transaction'];
+ unset($params['id_transaction']);
+ }
+
+ $id_field = DynamicFields::getNameFieldsSQL('u');
+ $email_field = DB::getInstance()->quoteIdentifier(DynamicFields::getFirstEmailField());
+
+ $params['select'] = sprintf('tu.*, %s AS name, %1$s AS _name, u.%s AS _email, u.*', $id_field, $email_field);
+ $params['tables'] = 'acc_transactions_users tu
+ INNER JOIN users u ON u.id = tu.id_user';
+
+ return self::sql($params, $tpl, $line);
+ }
+
+ static public function restrict(array $params, UserTemplate $tpl, int $line): ?\Generator
+ {
+ $session = Session::getInstance();
+
+ if (!$session->isLogged()) {
+ if (!empty($params['block'])) {
+ if (!headers_sent()) {
+ // FIXME: implement redirect to correct URL after login
+ Utils::redirect('!login.php');
+ }
+
+ throw new UserException('Vous n\'avez pas accès à cette page.');
+ }
+
+ return null;
+ }
+
+ if (empty($params['level']) && empty($params['section'])) {
+ yield [];
+ return null;
+ }
+
+ $convert = [
+ 'none' => $session::ACCESS_NONE,
+ 'read' => $session::ACCESS_READ,
+ 'write' => $session::ACCESS_WRITE,
+ 'admin' => $session::ACCESS_ADMIN,
+ ];
+
+ if (empty($params['level']) || !array_key_exists($params['level'], $convert)) {
+ throw new Brindille_Exception(sprintf("Ligne %d: 'restrict' niveau d'accès inconnu : %s", $line, $params['level'] ?? ''));
+ }
+
+ if (empty($params['section']) || !in_array($params['section'], $session::SECTIONS)) {
+ throw new Brindille_Exception(sprintf("Ligne %d: 'restrict' section d'accès inconnu : %s", $line, $params['section'] ?? ''));
+ }
+
+ $ok = $session->canAccess($params['section'], $convert[$params['level']]);
+
+ if ($ok) {
+ yield [];
+ return null;
+ }
+
+ if (!empty($params['block'])) {
+ throw new UserException('Vous n\'avez pas accès à cette page.');
+ }
+
+ return null;
+ }
+
+ static public function breadcrumbs(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ if (isset($params['id_page'])) {
+ $id = (int) $params['id_page'];
+ }
+ elseif (isset($params['path']) || isset($params['uri'])) {
+ $id = self::_getPageIdFromPath($params['path'] ?? $params['uri']);
+ }
+ else {
+ throw new Brindille_Exception('"id_page", "uri" or "path" parameter is mandatory and is missing');
+ }
+
+ if (!$id) {
+ return;
+ }
+
+ foreach (Web::getBreadcrumbs($id) as $row) {
+ $row->url = '/' . $row->uri;
+ yield (array) $row;
+ }
+ }
+
+ static public function categories(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+ $params['where'] .= ' AND w.type = ' . Page::TYPE_CATEGORY;
+ return self::pages($params, $tpl, $line);
+ }
+
+ static public function articles(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+ $params['where'] .= ' AND w.type = ' . Page::TYPE_PAGE;
+ return self::pages($params, $tpl, $line);
+ }
+
+ static public function pages(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+ $params['select'] = 'w.*';
+ $params['tables'] = 'web_pages w';
+ $params['where'] .= ' AND status = :status';
+ $params[':status'] = Page::STATUS_ONLINE;
+
+ $allowed_tables = self::SQL_TABLES;
+
+ if (array_key_exists('search', $params)) {
+ if (trim((string) $params['search']) === '') {
+ return;
+ }
+
+ $params[':search'] = substr(trim($params['search']), 0, 100);
+ unset($params['search']);
+
+ $params['tables'] .= ' INNER JOIN files_search ON files_search.path = \'web/\' || w.uri';
+ $params['select'] .= ', rank(matchinfo(files_search), 0, 1.0, 1.0) AS points, snippet(files_search, \'\', \' \', \'…\', 2) AS snippet';
+ $params['where'] .= ' AND files_search MATCH :search';
+
+ $params['order'] = 'points DESC';
+ $params['limit'] = '30';
+
+ // There is a bug in SQLite3 < 3.41.0
+ // where virtual tables (eg. FTS4) will trigger UPDATEs in the authorizer,
+ // making the request fail.
+ // So we will disable the authorizer here.
+ // From a security POV, this is a compromise, but in PHP < 8 there was no authorizer
+ // at all.
+ // @see https://sqlite.org/forum/forumpost/e11b51ca555f82147a1cbb58dc640b441e5f126cf6d7400753f62e82ca11ba88
+ if (\SQLite3::version()['versionNumber'] < 3041000) {
+ $allowed_tables = null;
+ }
+ }
+
+ if (isset($params['path'])) {
+ $params['uri'] = Utils::basename($params['path']);
+ unset($params['path']);
+ }
+
+ if (isset($params['uri'])) {
+ $params['where'] .= ' AND w.uri = :uri';
+ $params['limit'] = 1;
+ $params[':uri'] = $params['uri'];
+ unset($params['uri']);
+ }
+
+ if (array_key_exists('parent', $params)) {
+ if (null === $params['parent']) {
+ $params['where'] .= ' AND w.id_parent IS NULL';
+ }
+ else {
+ if (substr($params['parent'], 0, 1) === '!') {
+ $params['parent'] = substr($params['parent'], 1);
+ $params['where'] .= ' AND w.id_parent != (SELECT id FROM web_pages WHERE uri = :parent)';
+ }
+ else {
+ $params['where'] .= ' AND w.id_parent = (SELECT id FROM web_pages WHERE uri = :parent)';
+ }
+
+ $params[':parent'] = Utils::basename(trim((string) $params['parent']));
+ }
+
+ unset($params['parent']);
+ }
+
+ if (array_key_exists('id_parent', $params)) {
+ if (null === $params['id_parent']) {
+ $params['where'] .= ' AND w.id_parent IS NULL';
+ }
+ else {
+ $params['where'] .= ' AND w.id_parent = :id_parent';
+ $params[':id_parent'] = (int) $params['id_parent'];
+ }
+
+ unset($params['id_parent']);
+ }
+
+ if (isset($params['future'])) {
+ $params['where'] .= sprintf(' AND w.published %s datetime(\'now\', \'localtime\')', $params['future'] ? '>' : '<=');
+ unset($params['future']);
+ }
+
+ foreach (self::sql($params, $tpl, $line, $allowed_tables) as $row) {
+ if (empty($params['count'])) {
+ $data = $row;
+ unset($data['points'], $data['snippet']);
+
+ $page = new Page;
+ $page->exists(true);
+ $page->load($data);
+
+ if (isset($row['snippet'])) {
+ $row['snippet'] = preg_replace('!(\s*)!', '$1', $row['snippet']);
+ if (preg_match('!(.*?) !', $row['snippet'], $match)) {
+ $row['url_highlight'] = $page->url() . '#:~:text=' . rawurlencode($match[1]);
+ }
+ else {
+ $row['url_highlight'] = $page->url();
+ }
+ }
+
+ $row = array_merge($row, $page->asTemplateArray());
+ }
+
+ yield $row;
+ }
+ }
+
+ static public function images(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+ $params['where'] .= ' AND image = 1';
+ return self::attachments($params, $tpl, $line);
+ }
+
+ static public function documents(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $params['where'] ??= '';
+ $params['where'] .= ' AND image = 0';
+ return self::attachments($params, $tpl, $line);
+ }
+
+ static protected function _getPageIdFromPath(string $path): ?int
+ {
+ return self::cache('page_id_' . md5($path), function () use ($path) {
+ $db = DB::getInstance();
+ return $db->firstColumn('SELECT id FROM web_pages WHERE uri = ?;', Utils::basename($path)) ?: null;
+ });
+ }
+
+ static public function attachments(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ $id = null;
+
+ if (!empty($params['id_page'])) {
+ $id = (int)$params['id_page'];
+ }
+ elseif (!empty($params['parent'])) {
+ $id = self::_getPageIdFromPath($params['parent']);
+ }
+ else {
+ throw new Brindille_Exception('La section "attachments" doit obligatoirement comporter un paramètre "id_page" ou "parent"');
+ }
+
+ if (!$id) {
+ return;
+ }
+
+ $db = DB::getInstance();
+ $params['where'] ??= '';
+
+ // Fetch page
+ $page = self::cache('page_' . $id, function () use ($id, $db) {
+ $page = Web::get($id);
+
+ if (!$page) {
+ return null;
+ }
+
+ // Store attachments in temp table
+ $db->begin();
+ $db->exec('CREATE TEMP TABLE IF NOT EXISTS web_pages_attachments (page_id, uri, path, name, modified, image, data);');
+
+ foreach ($page->listAttachments() as $file) {
+ if ($file->type != File::TYPE_FILE) {
+ continue;
+ }
+
+ $row = $file->asArray();
+ $row['title'] = str_replace(['_', '-'], ' ', $file->name);
+ $row['title'] = preg_replace('!\.[^\.]{3,5}$!', '', $row['title']);
+ $row['extension'] = strtoupper(preg_replace('!^.*\.([^\.]{3,5})$!', '$1', $file->name));
+ $row['format'] = $file->getFormatDescription();
+ $row['url'] = $file->url();
+ $row['download_url'] = $file->url(true);
+ $row['thumb_url'] = $file->thumb_url();
+ $row['small_url'] = $file->thumb_url(File::THUMB_SIZE_SMALL);
+ $row['large_url'] = $file->thumb_url(File::THUMB_SIZE_LARGE);
+
+ $db->preparedQuery('INSERT OR REPLACE INTO web_pages_attachments VALUES (?, ?, ?, ?, ?, ?, ?);',
+ $page->id(), $file->uri(), $file->path, $file->name, $file->modified, $file->isImage(), json_encode($row));
+ }
+
+ $db->commit();
+
+ return $page;
+ });
+
+ // Page not found
+ if (!$page) {
+ return;
+ }
+
+ $params['select'] = 'data';
+ $params['tables'] = 'web_pages_attachments';
+ $params['where'] .= ' AND page_id = :page';
+ $params[':page'] = $page->id();
+ unset($params['page']);
+
+ // Generate a temporary table containing the list of files included in the text
+ if (!empty($params['except_in_text'])) {
+ // Don't regenerate that table for each section called in the page,
+ // we assume the content and list of files will not change between sections
+ self::cache('page_files_text_' . $id, function () use ($page, $db) {
+ $db->begin();
+
+ // Put files mentioned in the text in a temporary table
+ $db->exec('CREATE TEMP TABLE IF NOT EXISTS files_tmp_in_text (page_id, uri);');
+
+ foreach ($page->listTaggedAttachments() as $uri) {
+ $db->insert('files_tmp_in_text', ['page_id' => $page->id(), 'uri' => $uri]);
+ }
+
+
+ $db->commit();
+ });
+
+ $params['where'] .= sprintf(' AND uri NOT IN (SELECT uri FROM files_tmp_in_text WHERE page_id = %d)', $page->id());
+ }
+
+ if (empty($params['order'])) {
+ $params['order'] = 'name';
+ }
+
+ if ($params['order'] == 'name') {
+ $params['order'] .= ' COLLATE U_NOCASE';
+ }
+
+ foreach (self::sql($params, $tpl, $line) as $row) {
+ yield json_decode($row['data'], true);
+ }
+ }
+
+ static public function module(array $params, UserTemplate $tpl, int $line): \Generator
+ {
+ if (empty($params['name'])) {
+ throw new Brindille_Exception('Missing parameter "name"');
+ }
+
+ $module = Modules::get($params['name']);
+
+ if (!$module || !$module->enabled) {
+ return null;
+ }
+
+ $out = $module->asArray();
+ $out['path'] = 'modules/' . $module->name;
+ $out['url'] = $module->url();
+ $out['public_url'] = $module->public_url();
+
+ yield $out;
+ }
+
+ static public function sql(array $params, UserTemplate $tpl, int $line, ?array $allowed_tables = self::SQL_TABLES): \Generator
+ {
+ static $defaults = [
+ 'select' => '*',
+ 'order' => '1',
+ 'begin' => 0,
+ 'limit' => 10000,
+ 'where' => '',
+ ];
+
+ if (isset($params['sql'])) {
+ $sql = $params['sql'];
+
+ // Replace raw SQL parameters (this is for #select section)
+ foreach ($params as $k => $v) {
+ if (substr($k, 0, 1) == '!') {
+ $r = '/' . preg_quote($k, '/') . '\b/';
+ $sql = preg_replace($r, (string)$v, $sql);
+ }
+ }
+ }
+ else {
+ if (empty($params['tables'])) {
+ throw new Brindille_Exception(sprintf('"sql" section: missing parameter "tables" on line %d', $line));
+ }
+
+ foreach ($defaults as $key => $default_value) {
+ if (!isset($params[$key])) {
+ $params[$key] = $default_value;
+ }
+ }
+
+ // Allow for count=true, count=1 and also count="DISTINCT user_id" count="id"
+ if (!empty($params['count'])) {
+ $params['select'] = sprintf('COUNT(%s) AS count', $params['count'] == 1 ? '*' : $params['count']);
+ $params['order'] = '1';
+ }
+
+ if (!empty($params['where']) && !preg_match('/^\s*AND\s+/i', $params['where'])) {
+ $params['where'] = ' AND ' . $params['where'];
+ }
+
+ $sql = sprintf('SELECT %s FROM %s WHERE 1 %s %s %s ORDER BY %s LIMIT %d,%d;',
+ $params['select'],
+ $params['tables'],
+ $params['where'] ?? '',
+ isset($params['group']) ? 'GROUP BY ' . $params['group'] : '',
+ isset($params['having']) ? 'HAVING ' . $params['having'] : '',
+ $params['order'],
+ $params['begin'],
+ $params['limit']
+ );
+ }
+
+ $db = DB::getInstance();
+
+ try {
+ // Lock database against changes
+ $db->setReadOnly(true);
+
+ $statement = $db->protectSelect($allowed_tables, $sql);
+
+ $args = [];
+
+ foreach ($params as $key => $value) {
+ if (substr($key, 0, 1) == ':') {
+ if (is_object($value) || is_array($value)) {
+ throw new Brindille_Exception(sprintf("à la ligne %d : Section 'sql': le paramètre '%s' est un tableau.", $line, $key));
+ }
+
+ $args[substr($key, 1)] = $value;
+ }
+ }
+
+ $result = $db->execute($statement, $args);
+
+ if (!empty($params['debug'])) {
+ self::_debug($statement->getSQL(true));
+ }
+
+ if (!empty($params['explain'])) {
+ self::_debugExplain($statement->getSQL(true));
+ }
+
+ $db->setReadOnly(false);
+ }
+ catch (DB_Exception $e) {
+ throw new Brindille_Exception(sprintf("à la ligne %d erreur SQL :\n%s\n\nRequête exécutée :\n%s", $line, $db->lastErrorMsg(), $sql));
+ }
+
+ while ($row = $result->fetchArray(\SQLITE3_ASSOC))
+ {
+ if (isset($params['assign'])) {
+ $tpl::__assign(['var' => $params['assign'], 'value' => $row], $tpl, $line);
+ }
+
+ yield $row;
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/UserTemplate/UserTemplate.php b/src/include/lib/Paheko/UserTemplate/UserTemplate.php
new file mode 100644
index 0000000..02607fc
--- /dev/null
+++ b/src/include/lib/Paheko/UserTemplate/UserTemplate.php
@@ -0,0 +1,649 @@
+setCode($content);
+ $tpl->toggleSafeMode(true);
+ $tpl->setEscapeDefault(null);
+ $templates[$hash] = $tpl;
+
+ return $tpl;
+ }
+
+ static public function getRootVariables()
+ {
+ if (null !== self::$root_variables) {
+ return self::$root_variables;
+ }
+
+ static $keys = ['color1', 'color2', 'site_disabled', 'org_name', 'org_address', 'org_email', 'org_phone', 'org_web', 'org_infos', 'currency', 'country', 'files'];
+
+ $config = Config::getInstance();
+
+ $files = $config::FILES;
+
+ // Put URL in files array
+ array_walk($files, function (&$v, $k) use ($config) {
+ $v = $config->fileURL($k);
+ });
+
+ $config = array_intersect_key($config->asArray(), array_flip($keys));
+ $config['files'] = $files;
+
+ // @deprecated
+ // FIXME: remove in a future version
+ $config['nom_asso'] = $config['org_name'];
+ $config['adresse_asso'] = $config['org_address'];
+ $config['email_asso'] = $config['org_email'];
+ $config['telephone_asso'] = $config['org_phone'];
+ $config['site_asso'] = $config['org_web'];
+ $config['user_fields'] = [
+ 'number' => DynamicFields::getNumberField(),
+ 'login' => DynamicFields::getLoginField(),
+ 'email' => DynamicFields::getEmailFields(),
+ 'name' => DynamicFields::getNameFields(),
+ 'name_sql' => DynamicFields::getNameFieldsSQL(),
+ ];
+
+ $session = Session::getInstance();
+ $is_logged = $session->isLogged();
+
+ self::$root_variables = [
+ 'version_hash' => Utils::getVersionHash(),
+ 'root_url' => WWW_URL,
+ 'root_uri' => WWW_URI,
+ 'request_url' => Utils::getRequestURI(),
+ 'admin_url' => ADMIN_URL,
+ 'base_url' => BASE_URL,
+ 'site_url' => $config['site_disabled'] && $config['org_web'] ? $config['org_web'] : WWW_URL,
+ '_GET' => &$_GET,
+ '_POST' => &$_POST,
+ 'visitor_lang' => Translate::getHttpLang(),
+ 'config' => $config,
+ 'now' => time(),
+ 'is_logged' => $is_logged,
+ 'logged_user' => $is_logged ? $session->getUser()->asModuleArray() : null,
+ 'dialog' => isset($_GET['_dialog']) ? ($_GET['_dialog'] ?: true) : false,
+ 'pdf_enabled' => PDF_COMMAND !== null,
+ ];
+
+ return self::$root_variables;
+ }
+
+ public function __construct(?string $path = null)
+ {
+ if ($path) {
+ $path = trim($path, '/');
+ }
+
+ $this->_tpl_path = $path ?? '';
+
+ if ($path && $file = Files::get(File::CONTEXT_MODULES . '/' . $path)) {
+ $this->setLocalSource($file);
+ }
+ elseif ($path) {
+ $this->setSource(self::DIST_ROOT . $path);
+ }
+
+ $this->assignArray(self::getRootVariables());
+
+ $this->registerAll();
+
+ Plugins::fire('usertemplate.init', false, ['template' => $this]);
+ }
+
+ /**
+ * Toggle safe mode
+ *
+ * If set to TRUE, then all functions and sections are removed, except foreach.
+ * Only modifiers can be used.
+ * Useful for templates where you don't want the user to be able to do SQL queries etc.
+ *
+ * @param bool $enable
+ * @return void
+ */
+ public function toggleSafeMode(bool $safe_mode): void
+ {
+ if ($safe_mode) {
+ $this->_functions = [];
+ $this->_sections = [];
+ $this->_blocks = [];
+
+ // Register default Brindille modifiers
+ $this->registerDefaults();
+ }
+ else {
+ $this->registerAll();
+ }
+ }
+
+ public function setEscapeDefault(?string $default): void
+ {
+ $this->escape_default = $default;
+
+ if (null === $default) {
+ $this->registerModifier('escape', fn($str) => $str);
+ }
+ else {
+ $this->registerModifier('escape', fn ($str) => htmlspecialchars((string)$str) );
+ }
+ }
+
+ public function registerAll()
+ {
+ // Register default Brindille modifiers
+ $this->registerDefaults();
+
+ // Common modifiers
+ foreach (CommonModifiers::MODIFIERS_LIST as $key => $name) {
+ $this->registerModifier(is_int($key) ? $name : $key, is_int($key) ? [CommonModifiers::class, $name] : $name);
+ }
+
+ foreach (CommonFunctions::FUNCTIONS_LIST as $key => $name) {
+ $this->registerFunction(is_int($key) ? $name : $key, is_int($key) ? [CommonFunctions::class, $name] : $name);
+ }
+
+ // PHP modifiers
+ foreach (CommonModifiers::PHP_MODIFIERS_LIST as $name => $params) {
+ $this->registerModifier($name, [CommonModifiers::class, $name]);
+ }
+
+ // Local modifiers
+ foreach (Modifiers::MODIFIERS_LIST as $key => $name) {
+ $this->registerModifier(is_int($key) ? $name : $key, is_int($key) ? [Modifiers::class, $name] : $name);
+ }
+
+ foreach (Modifiers::MODIFIERS_WITH_INSTANCE_LIST as $key => $name) {
+ $this->registerModifier(is_int($key) ? $name : $key, is_int($key) ? [Modifiers::class, $name] : $name, true);
+ }
+
+ // Local functions
+ foreach (Functions::FUNCTIONS_LIST as $name) {
+ $this->registerFunction($name, [Functions::class, $name]);
+ }
+
+ foreach (Functions::COMPILE_FUNCTIONS_LIST as $name => $callback) {
+ $this->registerCompileBlock($name, $callback);
+ }
+
+ // Local sections
+ foreach (Sections::SECTIONS_LIST as $name) {
+ $this->registerSection($name, [Sections::class, $name]);
+ }
+
+ foreach (Sections::COMPILE_SECTIONS_LIST as $name => $callback) {
+ $this->registerCompileBlock($name, $callback);
+ }
+ }
+
+ /**
+ * Load template code from a user-stored file
+ */
+ public function setLocalSource(File $file)
+ {
+ if ($file->type != $file::TYPE_FILE) {
+ throw new \LogicException('Cannot construct a UserTemplate with a directory');
+ }
+
+ $this->file = $file;
+ $this->modified = $file->modified->getTimestamp();
+
+ $this->cache_path = USER_TEMPLATES_CACHE_ROOT;
+ }
+
+ /**
+ * Load template code from a filesystem file
+ */
+ public function setSource(string $path)
+ {
+ if (!($this->modified = @filemtime($path))) {
+ throw new \InvalidArgumentException('File not found: ' . $path);
+ }
+
+ $this->file = null;
+ $this->path = $path;
+
+ // Use shared cache for default templates
+ $this->cache_path = SHARED_USER_TEMPLATES_CACHE_ROOT;
+ }
+
+ public function isTrusted(): bool
+ {
+ return isset($this->path) && !isset($this->code) && !isset($this->file);
+ }
+
+ /**
+ * Load template code from a string
+ */
+ public function setCode(string $code)
+ {
+ $this->code = $code;
+ $this->file = null;
+ $this->path = null;
+ $this->modified = time();
+ // Use custom cache for user templates
+ $this->cache_path = USER_TEMPLATES_CACHE_ROOT;
+ }
+
+ protected function _getCachePath()
+ {
+ $hash = sha1($this->file ? $this->file->path : ($this->code ?: $this->path));
+ return sprintf('%s/%s.php', $this->cache_path, $hash);
+ }
+
+ public function display(): void
+ {
+ $compiled_path = $this->_getCachePath();
+
+ if (!is_dir(dirname($compiled_path))) {
+ // Force cache directory mkdir
+ Utils::safe_mkdir(dirname($compiled_path), 0777, true);
+ }
+
+ try {
+ if (file_exists($compiled_path) && filemtime($compiled_path) >= $this->modified) {
+ require $compiled_path;
+ return;
+ }
+
+ $tmp_path = $compiled_path . '.tmp';
+
+ if ($this->code) {
+ $source = $this->code;
+ }
+ elseif ($this->file) {
+ $source = $this->file->fetch();
+ }
+ else {
+ $source = file_get_contents($this->path);
+ }
+
+ $code = $this->compile($source);
+ file_put_contents($tmp_path, $code);
+
+ require $tmp_path;
+
+ @rename($tmp_path, $compiled_path);
+ }
+ catch (Brindille_Exception $e) {
+ $path = $this->file ? $this->file->path : ($this->code ? 'code' : str_replace(ROOT, '…', $this->path));
+ $is_user_code = $this->file || $this->code || ($this->module && $this->module->hasLocal());
+
+ $message = sprintf("Erreur dans '%s' :\n%s", $path, $e->getMessage());
+
+ if (!$is_user_code) {
+ // We want errors in shipped code to be reported, it is not normal
+ throw new \RuntimeException($message, 0, $e);
+ }
+ elseif (Session::getInstance()->isAdmin()) {
+ // Report error to admin with the highlighted line
+ $this->error($e, $message);
+ return;
+ }
+ else {
+ // Only report error
+ throw new UserException($message, 0, $e);
+ }
+ }
+ catch (\Throwable $e) {
+ // Don't delete temporary file as it can be used to debug
+ throw $e;
+ }
+ finally {
+ @unlink($tmp_path);
+ }
+ }
+
+ public function fetch(): string
+ {
+ ob_start();
+
+ try {
+ $this->display();
+ }
+ catch (\Throwable $e) {
+ ob_end_clean();
+ throw $e;
+ }
+
+ return ob_get_clean();
+ }
+
+ public function fetchAsAttachment(?string $type = null): File
+ {
+ $content = $this->fetch();
+ $type ??= $this->getContentType();
+ $name = $this->getHeader('filename') ?? 'document';
+
+ // Sanitize file name
+ $name = Utils::transliterateToAscii($name);
+ $name = preg_replace('/[^\w\d\.]+/U', ' ', $name);
+ $name = substr($name, -128);
+
+ if ($type == 'application/pdf' && substr($name, -4) !== '.pdf') {
+ $name .= '.pdf';
+ }
+
+ File::validateFileName($name);
+
+ $target = File::CONTEXT_ATTACHMENTS . '/' . md5($content) . '/' . $name;
+
+ if ($type == 'application/pdf') {
+ $tmp = Utils::filePDF($content);
+ $file = Files::createFromPath($target, $tmp);
+ Utils::safe_unlink($tmp);
+ }
+ else {
+ $file = Files::createFromString($target, $content);
+ }
+
+ return $file;
+ }
+
+ public function fetchToAttachment(string $uri): File
+ {
+ $parts = explode('?', $uri, 2);
+ $path = $parts[0] ?? '';
+ $query = $parts[1] ?? '';
+ parse_str($query, $qs);
+
+ $ut = new UserTemplate($path);
+ $ut->setModule($this->module);
+ $ut->assignArray(['_POST' => [], '_GET' => $qs]);
+ return $ut->fetchAsAttachment();
+ }
+
+ static public function isTemplate(string $filename): bool
+ {
+ $dot = strrpos($filename, '.');
+
+ // Templates with no extension are returned as HTML by default
+ // unless {{:http type=...}} is used
+ if ($dot === false) {
+ return true;
+ }
+
+ $ext = substr($filename, $dot+1);
+
+ switch ($ext) {
+ case 'html':
+ case 'htm':
+ case 'tpl':
+ case 'btpl':
+ case 'b':
+ case 'skel':
+ case 'xml':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public function getStatusCode(): int
+ {
+ return (int) ($this->headers['code'] ?? 200);
+ }
+
+ public function getContentType(): string
+ {
+ return $this->headers['type'] ?? 'text/html';
+ }
+
+ public function serve(): void
+ {
+ $path = $this->path ?? $this->file->path;
+
+ if (!self::isTemplate($path)) {
+ throw new \InvalidArgumentException('Not a valid template file extension: ' . $this->path);
+ }
+
+ $content = $this->fetch();
+
+ $this->dumpHeaders();
+
+ if ($this->getContentType() == 'application/pdf') {
+ Utils::streamPDF($content);
+ }
+ else {
+ echo $content;
+ }
+ }
+
+ public function error(\Exception $e, string $message)
+ {
+ $header = ini_get('error_prepend_string');
+ $header = preg_replace('!(.*?) !is', '', $header);
+ echo $header;
+
+ $name = strtok($this->_tpl_path, '/');
+ strtok('');
+
+ $path = $this->file->name ?? $this->path;
+ $location = sprintf('Dans le code du module "%s"', $name);
+
+ printf('',
+ $location, nl2br(htmlspecialchars($message)));
+
+ if ($this->code || !preg_match('/Line (\d+)\s*:/i', $message, $match)) {
+ return;
+ }
+
+ $line = $match[1] - 1;
+
+ if ($this->file) {
+ $file = explode("\n", $this->file->fetch());
+ }
+ else {
+ $file = file($path);
+ }
+
+ $start = max(0, $line - 5);
+ $max = min(count($file), $line + 6);
+
+ echo '';
+
+ for ($i = $start; $i < $max; $i++) {
+ $code = sprintf('%d %s', $i + 1, htmlspecialchars($file[$i]));
+
+ if ($i == $line) {
+ $code = sprintf('%s ', $code);
+ }
+
+ echo rtrim($code) . "\n";
+ }
+
+ echo '
';
+ exit;
+ }
+
+ public function setHeader(string $name, string $value): void
+ {
+ if ($this->parent) {
+ // Setting headers on included template does not make sense,
+ // instead pass this to parent template
+ $this->parent->setHeader($name, $value);
+ }
+ else {
+ $this->headers[$name] = $value;
+ }
+ }
+
+ public function getHeader(string $name): ?string
+ {
+ return $this->headers[$name] ?? null;
+ }
+
+ public function dumpHeaders(): void
+ {
+ if (isset($this->headers['code'])) {
+ $code = $this->headers['code'];
+
+ static $codes = [
+ 100 => 'Continue',
+ 101 => 'Switching Protocols',
+ 102 => 'Processing',
+ 200 => 'OK',
+ 201 => 'Created',
+ 202 => 'Accepted',
+ 203 => 'Non-Authoritative Information',
+ 204 => 'No Content',
+ 205 => 'Reset Content',
+ 206 => 'Partial Content',
+ 207 => 'Multi-Status',
+ 300 => 'Multiple Choices',
+ 301 => 'Moved Permanently',
+ 302 => 'Found',
+ 303 => 'See Other',
+ 304 => 'Not Modified',
+ 305 => 'Use Proxy',
+ 306 => 'Switch Proxy',
+ 307 => 'Temporary Redirect',
+ 400 => 'Bad Request',
+ 401 => 'Unauthorized',
+ 402 => 'Payment Required',
+ 403 => 'Forbidden',
+ 404 => 'Not Found',
+ 405 => 'Method Not Allowed',
+ 406 => 'Not Acceptable',
+ 407 => 'Proxy Authentication Required',
+ 408 => 'Request Timeout',
+ 409 => 'Conflict',
+ 410 => 'Gone',
+ 411 => 'Length Required',
+ 412 => 'Precondition Failed',
+ 413 => 'Request Entity Too Large',
+ 414 => 'Request-URI Too Long',
+ 415 => 'Unsupported Media Type',
+ 416 => 'Requested Range Not Satisfiable',
+ 417 => 'Expectation Failed',
+ 418 => 'I\'m a teapot',
+ 422 => 'Unprocessable Entity',
+ 423 => 'Locked',
+ 424 => 'Failed Dependency',
+ 425 => 'Unordered Collection',
+ 426 => 'Upgrade Required',
+ 449 => 'Retry With',
+ 450 => 'Blocked by Windows Parental Controls',
+ 500 => 'Internal Server Error',
+ 501 => 'Not Implemented',
+ 502 => 'Bad Gateway',
+ 503 => 'Service Unavailable',
+ 504 => 'Gateway Timeout',
+ 505 => 'HTTP Version Not Supported',
+ 506 => 'Variant Also Negotiates',
+ 507 => 'Insufficient Storage',
+ 509 => 'Bandwidth Limit Exceeded',
+ 510 => 'Not Extended',
+ ];
+
+ if (!isset($codes[$code])) {
+ throw new Brindille_Exception('Code HTTP inconnu: ' . $code);
+ }
+
+ header(sprintf('HTTP/1.1 %d %s', $code, $codes[$code]), true);
+ }
+
+ if (isset($this->headers['type'])) {
+ header(sprintf('Content-Type: %s; charset=utf-8', $this->headers['type']), true);
+ }
+
+ if (isset($this->headers['disposition'])) {
+ header(sprintf('Content-Disposition: %s; filename="%s"',
+ $this->headers['disposition'],
+ Utils::safeFileName($this->headers['filename'])
+ ), true);
+ }
+ }
+
+ public function _callFunction(string $name, array $params, int $line) {
+ try {
+ return call_user_func($this->_functions[$name], $params, $this, $line);
+ }
+ catch (UserException $e) {
+ throw $e;
+ }
+ catch (\Exception $e) {
+ throw new Brindille_Exception(sprintf("line %d: function '%s' has returned an error: %s\nParameters: %s", $line, $name, $e->getMessage(), substr(var_export($params, true), 6)), 0, $e);
+ }
+ }
+
+ public function setParent(UserTemplate $tpl)
+ {
+ $this->parent = $tpl;
+ $this->setModule($tpl->module);
+ }
+
+ public function setModule(?Module $module): void
+ {
+ if (!$module) {
+ return;
+ }
+
+ $this->module = $module;
+ $this->assign('module', array_merge($module->asArray(false), [
+ 'config' => json_decode(json_encode($module->config), true),
+ 'url' => $module->url(),
+ 'public_url' => $module->public_url(),
+ 'storage_root' => $module->storage_root(),
+ ]));
+ }
+}
diff --git a/src/include/lib/Paheko/Users/AdvancedSearch.php b/src/include/lib/Paheko/Users/AdvancedSearch.php
new file mode 100644
index 0000000..7a431e7
--- /dev/null
+++ b/src/include/lib/Paheko/Users/AdvancedSearch.php
@@ -0,0 +1,305 @@
+ $fields::getNameLabel(),
+ 'type' => 'text',
+ 'null' => true,
+ 'select' => $fields::getNameFieldsSQL('u'),
+ 'where' => $fields::getNameFieldsSearchableSQL('us') . ' %s',
+ 'order' => $order,
+ ];
+
+ $columns['is_parent'] = [
+ 'label' => 'Est responsable',
+ 'type' => 'boolean',
+ 'null' => false,
+ 'select' => 'CASE WHEN u.is_parent = 1 THEN \'Oui\' ELSE \'Non\' END',
+ 'where' => 'u.is_parent %s',
+ ];
+
+ $columns['is_child'] = [
+ 'label' => 'Est rattaché',
+ 'type' => 'boolean',
+ 'null' => false,
+ 'select' => 'CASE WHEN u.id_parent IS NOT NULL THEN \'Oui\' ELSE \'Non\' END',
+ 'where' => 'u.id_parent IS NOT NULL',
+ ];
+
+ foreach ($fields->all() as $name => $field)
+ {
+ /*
+ // already included in identity
+ if ($field->system & $field::NAME) {
+ continue;
+ }
+ */
+
+ // nope
+ if ($field->system & $field::PASSWORD) {
+ continue;
+ }
+
+ $identifier = $db->quoteIdentifier($name);
+
+ $column = [
+ 'label' => $field->label,
+ 'type' => 'text',
+ 'null' => true,
+ 'select' => sprintf('u.%s', $identifier),
+ 'where' => sprintf('%s.%s %%s', $field->hasSearchCache() ? 'us' : 'u', $identifier),
+ ];
+
+ if ($fields->isText($name)) {
+ $column['order'] = sprintf('%s COLLATE U_NOCASE %%s', $name);
+ }
+
+ if ($field->type == 'checkbox')
+ {
+ $column['type'] = 'boolean';
+ $column['null'] = false;
+ }
+ elseif ($field->type == 'select')
+ {
+ $column['type'] = 'enum';
+ $column['values'] = array_combine($field->options, $field->options);
+ }
+ elseif ($field->type == 'multiple')
+ {
+ $column['type'] = 'bitwise';
+ $column['values'] = $field->options;
+ }
+ elseif ($field->type == 'date' || $field->type == 'datetime')
+ {
+ $column['type'] = $field->type;
+ }
+ elseif ($field->type == 'number')
+ {
+ $column['type'] = 'integer';
+ }
+ elseif ($field->type === 'file') {
+ $column['type'] = 'integer';
+ $column['null'] = false;
+ $column['label'] .= ' (nombres de fichiers)';
+ $column['select'] = sprintf('(SELECT GROUP_CONCAT(f.path, \';\') FROM users_files AS uf INNER JOIN files AS f ON f.id = uf.id_file WHERE uf.id_user = u.id AND uf.field = %s)', $db->quote($field->name));
+ $column['where'] = sprintf('(SELECT COUNT(*) FROM users_files AS uf WHERE uf.id_user = u.id AND uf.field = %s) %%s', $db->quote($field->name));
+ }
+ elseif ($field->type === 'virtual') {
+ $type = $field->getRealType();
+
+ if ($type === 'integer' || $type === 'real') {
+ $type = 'integer';
+ }
+ else {
+ $type = 'text';
+ }
+
+ $column['type'] = $type;
+ $column['null'] = $field->hasNullValues();
+ }
+
+ if ($field->type == 'tel') {
+ $column['normalize'] = 'tel';
+ }
+
+ $columns[$name] = $column;
+ }
+
+ $names = $fields::getNameFields();
+
+ if (count($names) == 1) {
+ unset($columns[$names[0]]);
+ }
+
+ $columns['id_category'] = [
+ 'label' => 'Catégorie',
+ 'type' => 'enum',
+ 'null' => false,
+ 'values' => $db->getAssoc('SELECT id, name FROM users_categories ORDER BY name COLLATE U_NOCASE;'),
+ 'select' => '(SELECT name FROM users_categories WHERE id = id_category)',
+ 'where' => 'id_category %s',
+ ];
+
+ $columns['service'] = [
+ 'label' => 'Est inscrit à l\'activité',
+ 'type' => 'enum',
+ 'null' => false,
+ 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'),
+ 'select' => '\'Inscrit\'',
+ 'where' => 'id IN (SELECT id_user FROM services_users WHERE id_service %s)',
+ ];
+
+ $columns['service_not'] = [
+ 'label' => 'N\'est pas inscrit à l\'activité',
+ 'type' => 'enum',
+ 'null' => false,
+ 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'),
+ 'select' => '\'Inscrit\'',
+ 'where' => 'id NOT IN (SELECT id_user FROM services_users WHERE id_service %s)',
+ ];
+
+ $columns['service_active'] = [
+ 'label' => 'Est à jour de l\'activité',
+ 'type' => 'enum',
+ 'null' => false,
+ 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'),
+ 'select' => '\'À jour\'',
+ 'where' => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_users WHERE id_service %s GROUP BY id_user) WHERE edate >= date())',
+ ];
+
+ $columns['service_expired'] = [
+ 'label' => 'N\'est pas à jour de l\'activité',
+ 'type' => 'enum',
+ 'null' => false,
+ 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'),
+ 'select' => '\'Expiré\'',
+ 'where' => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_users WHERE id_service %s GROUP BY id_user) WHERE edate < date())',
+ ];
+
+ return $columns;
+ }
+
+ public function schemaTables(): array
+ {
+ return [
+ 'users' => 'Membres',
+ 'users_categories' => 'Catégories de membres',
+ 'services' => 'Activités',
+ 'services_fees' => 'Tarifs des activités',
+ 'services_users' => 'Inscriptions aux activités',
+ ];
+ }
+
+ public function tables(): array
+ {
+ return array_merge(array_keys($this->schemaTables()), [
+ 'users_search',
+ 'user_files',
+ 'user_view',
+ ]);
+ }
+
+ public function simple(string $query, bool $allow_redirect = false): \stdClass
+ {
+ $operator = 'LIKE %?%';
+ $db = DB::getInstance();
+
+ if (is_numeric(trim($query)))
+ {
+ $column = DynamicFields::getNumberField();
+ $operator = '= ?';
+ }
+ elseif (strpos($query, '@') !== false)
+ {
+ $column = DynamicFields::getFirstEmailField();
+ }
+ else
+ {
+ $column = 'identity';
+ }
+
+ if ($allow_redirect) {
+ $c = $column;
+ $table = 'users';
+
+ if ($column == 'identity') {
+ $c = DynamicFields::getNameFieldsSearchableSQL();
+
+ if (!$c) {
+ throw new UserException('Aucun champ texte n\'est indiqué comme identité des membres, il n\'est pas possible de faire une recherche.');
+ }
+
+ $table = 'users_search';
+ }
+
+ // Try to redirect to user if there is only one user
+ if ($operator == '= ?') {
+ $sql = sprintf('SELECT id, COUNT(*) AS count FROM %s WHERE %s = ?;', $table, $c);
+ $single_query = (int) $query;
+ }
+ else {
+ $sql = sprintf('SELECT id, COUNT(*) AS count FROM %s WHERE %s LIKE ?;', $table, $c);
+ $single_query = '%' . trim($query) . '%';
+ }
+
+ if (($row = $db->first($sql, $single_query)) && $row->count == 1) {
+ Utils::redirect('!users/details.php?id=' . $row->id);
+ }
+ }
+
+ $query = [[
+ 'operator' => 'AND',
+ 'conditions' => [
+ [
+ 'column' => $column,
+ 'operator' => $operator,
+ 'values' => [$query],
+ ],
+ ],
+ ]];
+
+ return (object) [
+ 'groups' => $query,
+ 'order' => $column,
+ 'desc' => false,
+ ];
+ }
+
+ public function make(string $query): DynamicList
+ {
+ $tables = 'users_view AS u INNER JOIN users_search AS us USING (id)';
+ $list = $this->makeList($query, $tables, 'identity', false, ['id', 'identity']);
+
+ $list->setExportCallback([Users::class, 'exportRowCallback']);
+ return $list;
+ }
+
+ public function defaults(): \stdClass
+ {
+ return (object) ['groups' => [[
+ 'operator' => 'AND',
+ 'join_operator' => null,
+ 'conditions' => [
+ [
+ 'column' => 'identity',
+ 'operator' => 'LIKE %?%',
+ 'values' => [''],
+ ],
+ ],
+ ]]];
+ }
+}
diff --git a/src/include/lib/Paheko/Users/Categories.php b/src/include/lib/Paheko/Users/Categories.php
new file mode 100644
index 0000000..1a6c94c
--- /dev/null
+++ b/src/include/lib/Paheko/Users/Categories.php
@@ -0,0 +1,76 @@
+getAssoc(sprintf('SELECT id, name FROM %s WHERE 1 %s ORDER BY name COLLATE U_NOCASE;',
+ Category::TABLE, self::getHiddenClause($hidden)
+ ));
+ }
+
+ static public function listAssocWithStats(?int $hidden = null): array
+ {
+ $db = DB::getInstance();
+
+ $categories = [0 => (object) [
+ 'label' => 'Toutes, sauf cachées',
+ 'count' => $db->count(User::TABLE, 'id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'),
+ ]];
+
+ if ($hidden !== self::WITHOUT_HIDDEN) {
+ $categories[-1] = (object) [
+ 'label' => 'Toutes, même cachées',
+ 'count' => $db->count(User::TABLE),
+ ];
+ }
+
+ $format = '%s (%d membres)';
+
+ return $categories + $db->getGrouped(sprintf(
+ 'SELECT id, name AS label, (SELECT COUNT(*) FROM %s WHERE %1$s.id_category = %s.id) AS count
+ FROM %2$s
+ WHERE 1 %s
+ ORDER BY name COLLATE U_NOCASE;',
+ User::TABLE,
+ Category::TABLE,
+ self::getHiddenClause($hidden)
+ ));
+ }
+
+ static public function listWithStats(?int $hidden = null): array
+ {
+ return DB::getInstance()->getGrouped(sprintf('SELECT c.id, c.*,
+ (SELECT COUNT(*) FROM users WHERE id_category = c.id) AS count
+ FROM %s c WHERE 1 %s ORDER BY c.name COLLATE U_NOCASE;',
+ Category::TABLE, self::getHiddenClause($hidden)
+ ));
+ }
+}
diff --git a/src/include/lib/Paheko/Users/DynamicFields.php b/src/include/lib/Paheko/Users/DynamicFields.php
new file mode 100644
index 0000000..d682f4e
--- /dev/null
+++ b/src/include/lib/Paheko/Users/DynamicFields.php
@@ -0,0 +1,1135 @@
+ [],
+ 'password' => [],
+ 'name' => [],
+ 'number' => [],
+ ];
+
+ protected array $_presets;
+
+ protected array $_deleted = [];
+
+ static protected $_instance;
+
+ static public function getInstance(): self
+ {
+ if (null === self::$_instance) {
+ self::$_instance = new self;
+ }
+
+ return self::$_instance;
+ }
+
+ static public function get(string $key)
+ {
+ return self::getInstance()->fieldByKey($key);
+ }
+
+ /**
+ * Returns the list of columns containing an email address (there might be more than one)
+ * @return array
+ */
+ static public function getEmailFields(): array
+ {
+ return array_keys(self::getInstance()->fieldsByType('email'));
+ }
+
+ static public function getFirstEmailField(): string
+ {
+ return key(self::getInstance()->fieldsByType('email'));
+ }
+
+ static public function getNumberField(): string
+ {
+ return key(self::getInstance()->fieldsBySystemUse('number'));
+ }
+
+ static public function getNumberFieldSQL(): string
+ {
+ return DB::getInstance()->quoteIdentifier(self::getNumberField());
+ }
+
+ static public function getLoginField(): string
+ {
+ return key(self::getInstance()->fieldsBySystemUse('login'));
+ }
+
+ static public function getNameFields(): array
+ {
+ return array_keys(self::getInstance()->fieldsBySystemUse('name'));
+ }
+
+ static public function getVirtualFields(): array
+ {
+ return self::getInstance()->fieldsByType('virtual');
+ }
+
+ static public function getNameFromArray($in): ?string
+ {
+ $out = [];
+
+ foreach (array_keys(self::getInstance()->fieldsBySystemUse('name')) as $f) {
+ if (is_array($in) && array_key_exists($f, $in)) {
+ $out[] = $in[$f];
+ }
+ elseif (is_object($in) && property_exists($in, $f)) {
+ $out[] = $in->$f;
+ }
+ }
+
+ return implode(' ', $out) ?: null;
+ }
+
+ static public function getNameLabel(): string
+ {
+ $list = self::getInstance()->fieldsBySystemUse('name');
+ $labels = [];
+
+ foreach ($list as $field) {
+ $labels[] = $field->label;
+ }
+
+ return implode(', ', $labels);
+ }
+
+ static public function getFirstNameField(): string
+ {
+ return key(self::getInstance()->fieldsBySystemUse('name'));
+ }
+
+ static public function getFirstSearchableNameField(): ?string
+ {
+ foreach (self::getInstance()->fieldsBySystemUse('name') as $field) {
+ if ($field->hasSearchCache()) {
+ return $field->name;
+ }
+ }
+
+ return null;
+ }
+
+ static public function getNameFieldsSQL(?string $prefix = null): string
+ {
+ $fields = self::getNameFields();
+ $db = DB::getInstance();
+
+ if ($prefix) {
+ $fields = array_map(fn($v) => $prefix . '.' . $db->quoteIdentifier($v), $fields);
+ }
+
+ if (count($fields) == 1) {
+ return $fields[0];
+ }
+
+ foreach ($fields as &$field) {
+ $field = sprintf('IFNULL(%s, \'\')', $field);
+ }
+
+ unset($field);
+
+ $fields = implode(' || \' \' || ', $fields);
+ $fields = sprintf('TRIM(%s)', $fields);
+ return $fields;
+ }
+
+ static public function getNameFieldsSearchableSQL(?string $prefix = null): ?string
+ {
+ $fields = [];
+
+ foreach (self::getInstance()->fieldsBySystemUse('name') as $field) {
+ if (!$field->hasSearchCache()) {
+ continue;
+ }
+
+ $fields[] = $field->name;
+ }
+
+ // There are no indexed fields in the name, eg. only the user number, then discard the index
+ if (!count($fields)) {
+ return null;
+ }
+
+ $db = DB::getInstance();
+
+ if ($prefix) {
+ $fields = array_map(fn($v) => $prefix . '.' . $db->quoteIdentifier($v), $fields);
+ }
+
+ foreach ($fields as &$field) {
+ $field = sprintf('IFNULL(%s, \'\')', $field);
+ }
+
+ unset($field);
+
+ $fields = implode(' || \' \' || ', $fields);
+ $fields = sprintf('TRIM(%s)', $fields);
+ return $fields;
+ }
+
+ protected function __construct(bool $load = true)
+ {
+ if ($load) {
+ $this->reload();
+ }
+ }
+
+ protected function reload()
+ {
+ $db = DB::getInstance();
+ $i = EM::getInstance(DynamicField::class)->iterate('SELECT * FROM @TABLE ORDER BY sort_order;');
+
+ foreach ($i as $field) {
+ $this->_fields[$field->name] = $field;
+ }
+
+ $this->reloadCache();
+ }
+
+ public function install(): void
+ {
+ $presets = $this->getDefaultPresets();
+
+ foreach ($presets as $name => $preset) {
+ $field = $this->addFieldFromPreset($name);
+
+ if ($name == 'password') {
+ $name = 'password';
+ $field->system |= $field::PASSWORD;
+ }
+
+ if ($name == 'email') {
+ $field->system |= $field::LOGIN;
+ }
+
+ if ($name == 'nom') {
+ $field->system |= $field::NAMES;
+ }
+
+ if ($name == 'numero') {
+ $field->system |= $field::NUMBER;
+ }
+ }
+
+ $this->save();
+ }
+
+ public function addFieldFromPreset(string $name): DynamicField
+ {
+ $data = $this->getPresets()[$name];
+
+ foreach ($data->depends ?? [] as $depends) {
+ if (!$this->fieldByKey($depends)) {
+ throw new \LogicException(sprintf('Cannot add "%s" preset if "%s" preset is not installed.', $name, $depends));
+ }
+ }
+
+ $data->user_access_level ??= Session::ACCESS_READ;
+ $data->management_access_level ??= Session::ACCESS_READ;
+ $data->required ??= false;
+ $data->list_table ??= false;
+
+ $field = new DynamicField;
+ $field->system |= $field::PRESET;
+ $field->set('name', $name);
+ $field->import((array)$data);
+ $field->sort_order = $this->getLastOrderIndex();
+
+ $this->add($field);
+
+ return $field;
+ }
+
+ protected function reloadCache()
+ {
+ $this->_fields_by_type = [];
+
+ foreach ($this->_fields_by_system_use as &$list) {
+ $list = [];
+ }
+
+ unset($list);
+
+ foreach ($this->_fields as $key => $field) {
+ if (!isset($this->_fields_by_type[$field->type])) {
+ $this->_fields_by_type[$field->type] = [];
+ }
+
+ $this->_fields_by_type[$field->type][$key] = $field;
+
+ if (!$field->system) {
+ continue;
+ }
+
+ if ($field->system & $field::PASSWORD) {
+ $this->_fields_by_system_use['password'][$key] = $field;
+ }
+
+ if ($field->system & $field::NAMES) {
+ $this->_fields_by_system_use['name'][$key] = $field;
+ }
+
+ if ($field->system & $field::NUMBER) {
+ $this->_fields_by_system_use['number'][$key] = $field;
+ }
+
+ if ($field->system & $field::LOGIN) {
+ $this->_fields_by_system_use['login'][$key] = $field;
+ }
+ }
+ }
+
+ public function fieldsByType(string $type): array
+ {
+ return $this->_fields_by_type[$type] ?? [];
+ }
+
+ public function fieldByKey(string $key): ?DynamicField
+ {
+ return $this->_fields[$key] ?? null;
+ }
+
+ public function fieldById(int $id): ?DynamicField
+ {
+ foreach ($this->_fields as $field) {
+ if ($field->id === $id) {
+ return $field;
+ }
+ }
+
+ return null;
+ }
+
+ public function fieldsBySystemUse(string $use): array
+ {
+ return $this->_fields_by_system_use[$use] ?? [];
+ }
+
+ public function getEntityTypes(): array
+ {
+ $types = [];
+
+ foreach ($this->_fields as $key => $field) {
+ $types[$key] = $field->type;
+ }
+
+ return $types;
+ }
+
+ public function getPresets(): array
+ {
+ if (!isset($this->_presets))
+ {
+ $this->_presets = Utils::parse_ini_file(self::PRESETS_FILE, true);
+
+ foreach ($this->_presets as &$preset) {
+ $preset = (object) $preset;
+ }
+
+ unset($preset);
+ }
+
+ return $this->_presets;
+ }
+
+ /**
+ * Return list of presets that are not installed already
+ */
+ public function getInstallablePresets(): array
+ {
+ $list = array_diff_key($this->getPresets(), $this->_fields);
+ $installed =& $this->_fields;
+ array_walk($list, function (&$p) use ($installed) {
+ $p->disabled = false;
+
+ foreach ($p->depends ?? [] as $d) {
+ if (!array_key_exists($d, $installed)) {
+ $p->disabled = true;
+ break;
+ }
+ }
+ });
+
+ uasort($list, fn($a, $b) => strnatcasecmp($a->label, $b->label));
+
+ return $list;
+ }
+
+ public function getDefaultPresets(): array
+ {
+ return array_filter($this->getPresets(), fn ($row) => $row->default ?? false);
+ }
+
+ public function installPreset(string $name): DynamicField
+ {
+ $preset = $this->getInstallablePresets()[$name] ?? null;
+
+ if (!$preset) {
+ throw new \InvalidArgumentException('This field cannot be installed.');
+ }
+
+ return $this->addFieldFromPreset($name);
+ }
+
+ /**
+ * Import from old INI config
+ * @deprecated Only use when migrating from an old version
+ */
+ static public function fromOldINI(string $config, string $login_field, string $name_field, string $number_field)
+ {
+ $db = DB::getInstance();
+ $config = Utils::parse_ini_string($config, true);
+
+ $presets = Utils::parse_ini_file(self::PRESETS_FILE, true);
+
+ $i = 0;
+
+ $self = new self(false);
+ $fields = [
+ 'date_connexion' => 'date_login',
+ 'date_inscription' => 'date_inscription',
+ 'clef_pgp' => 'pgp_key',
+ 'secret_otp' => 'otp_secret',
+ 'id_category' => 'id_category',
+ ];
+
+ $defaults = [
+ 'help' => null,
+ 'private' => false,
+ 'editable' => true,
+ 'mandatory' => false,
+ 'list_row' => null,
+ ];
+
+ foreach ($config as $name => $data) {
+ $field = new DynamicField;
+
+ $fields[$name] = $name;
+
+ if ($data['type'] == 'checkbox' || $data['type'] == 'multiple') {
+ // A checkbox/multiple checkbox can either be 0 or 1, not NULL
+ $db->exec(sprintf('UPDATE membres SET %s = 0 WHERE %1$s IS NULL OR %1$s = \'\';', $db->quoteIdentifier($name)));
+ }
+ else {
+ // Make sure data is NULL if empty
+ $db->exec(sprintf('UPDATE membres SET %s = NULL WHERE %1$s = \'\';', $db->quoteIdentifier($name)));
+ }
+
+ if ($name == 'passe') {
+ $name = 'password';
+ $data['title'] = 'Mot de passe';
+ $field->system |= $field::PASSWORD;
+ $fields['passe'] = 'password';
+ }
+
+ if ($name == $login_field) {
+ $field->system |= $field::LOGIN;
+ }
+
+ if ($name == $name_field) {
+ $field->system |= $field::NAMES;
+ }
+
+ if ($name == $number_field) {
+ $field->system |= $field::NUMBER;
+ $data['help'] = null;
+ $data['mandatory'] = true;
+ $data['editable'] = false;
+ }
+
+ $data = array_merge($defaults, $data);
+
+ if (array_key_exists($name, $presets)) {
+ $field->system = $field->system | $field::PRESET;
+ }
+
+ $field->set('name', $name);
+ $field->set('label', (string)$data['title']);
+ $field->set('type', (string)$data['type']);
+ $field->set('help', empty($data['help']) ? null : (string)$data['help']);
+ $field->set('user_access_level', $data['editable'] ? Session::ACCESS_WRITE : ($data['private'] ? Session::ACCESS_NONE : Session::ACCESS_READ));
+ $field->set('management_access_level', Session::ACCESS_READ);
+ $field->set('required', (bool) $data['mandatory']);
+ $field->set('list_table', (bool) $data['list_row']);
+ $field->set('sort_order', $i++);
+ $field->set('options', $data['options'] ?? null);
+ $self->add($field);
+ }
+
+ // Create date_inscription
+ $field = $self->addFieldFromPreset('date_inscription');
+ $self->add($field);
+
+ self::$_instance = $self;
+
+ $self->createTable();
+ $self->createIndexes();
+ $self->createTriggers();
+ $self->copy('membres', User::TABLE, $fields);
+
+ $self->rebuildSearchTable(true);
+
+ return $self;
+ }
+
+ public function isText(string $field)
+ {
+ $type = $this->_fields[$field]->type;
+ return (DynamicField::SQL_TYPES[$type] ?? null) === 'TEXT';
+ }
+
+ public function getKeys()
+ {
+ return array_keys($this->_fields);
+ }
+
+ public function all()
+ {
+ return $this->_fields;
+ }
+
+ public function allExceptPassword()
+ {
+ return array_filter($this->_fields, function ($a) {
+ return !($a->system & DynamicField::PASSWORD);
+ });
+ }
+
+ public function listAssocNames()
+ {
+ $out = [];
+
+ foreach ($this->_fields as $key => $field) {
+ if ($field->system & $field::PASSWORD) {
+ continue;
+ }
+
+ $out[$key] = $field->label;
+ }
+
+ return $out;
+ }
+
+ public function listImportAssocNames()
+ {
+ $out = [];
+
+ foreach ($this->_fields as $key => $field) {
+ if ($field->system & $field::PASSWORD) {
+ continue;
+ }
+
+ // Skip fields where the value cannot be imported
+ if ($field->type == 'file' || $field->type == 'virtual') {
+ continue;
+ }
+
+ $out[$key] = $field->label;
+ }
+
+ return $out;
+ }
+
+ public function listImportRequiredAssocNames(bool $require_number = true)
+ {
+ $out = [];
+
+ foreach ($this->_fields as $key => $field) {
+ if ($field->system & $field::PASSWORD) {
+ continue;
+ }
+
+ // Skip fields where the value cannot be imported
+ if ($field->type == 'file' || $field->type == 'virtual') {
+ continue;
+ }
+
+ if (!$field->required) {
+ continue;
+ }
+
+ if (!$require_number && $field->system & $field::NUMBER) {
+ continue;
+ }
+
+ $out[$key] = $field->label;
+ }
+
+ return $out;
+ }
+
+ public function getListedFields(): array
+ {
+ $fields = array_filter(
+ $this->_fields,
+ fn ($a, $b) => empty($a->list_table) ? false : true,
+ ARRAY_FILTER_USE_BOTH
+ );
+
+ uasort($fields, function ($a, $b) {
+ if ($a->sort_order == $b->sort_order)
+ return 0;
+
+ return ($a->sort_order > $b->sort_order) ? 1 : -1;
+ });
+
+ return $fields;
+ }
+
+ public function getSQLSchema(string $table_name = User::TABLE): string
+ {
+ $db = DB::getInstance();
+
+ $create = DynamicField::SYSTEM_FIELDS_SQL;
+
+ end($this->_fields);
+
+ // Find out which field is the last one to be a real column
+ do {
+ $field = !isset($field) ? current($this->_fields) : prev($this->_fields);
+ $type = DynamicField::SQL_TYPES[$field->type] ?? null;
+ }
+ while ($type === null);
+
+ $last_one = $field->name;
+
+ foreach ($this->_fields as $key => $cfg)
+ {
+ $type = DynamicField::SQL_TYPES[$cfg->type] ?? null;
+
+ // Skip fields that don't have a type (= virtual fields)
+ if (!$type) {
+ continue;
+ }
+
+ $line = sprintf('%s %s', $db->quoteIdentifier($key), $type);
+
+ if ($type == 'TEXT' && $cfg->type != 'password') {
+ $line .= ' COLLATE NOCASE';
+ }
+
+ if ($last_one != $key) {
+ $line .= ',';
+ }
+
+ if (!empty($cfg->label))
+ {
+ $line .= ' -- ' . str_replace(["\n", "\r"], '', $cfg->label);
+ }
+
+ $create[] = $line;
+ }
+
+ $sql = sprintf("CREATE TABLE %s\n(\n\t%s\n);", $table_name, implode("\n\t", $create));
+ return $sql;
+ }
+
+ public function getSearchColumns(): array
+ {
+ $c = array_keys(array_filter($this->_fields, fn ($f) => $f->hasSearchCache()));
+ return $c = array_combine($c, $c);
+ }
+
+ public function getSQLCopy(string $old_table_name, string $new_table_name = User::TABLE, array $fields = null, string $function = null): string
+ {
+ $db = DB::getInstance();
+ unset($fields['id']);
+
+ $source = [];
+
+ foreach ($fields as $src_key => $dst_key) {
+ /* Don't cast currently as this can create duplicate records when data was wrong :(
+ $field = $this->get($dst_key);
+
+ if ($field) {
+ $source[] = sprintf('CAST(%s AS %s)', $db->quoteIdentifier($src_key), $field->sql_type());
+ }
+ */
+ $source[] = $db->quoteIdentifier($src_key);
+ }
+
+ if ($function) {
+ $source = array_map(fn($a) => $function . '(' . $a . ')', $source);
+ }
+
+ return sprintf('INSERT INTO %s (id, %s) SELECT id, %s FROM %s;',
+ $new_table_name,
+ implode(', ', array_map([$db, 'quoteIdentifier'], $fields)),
+ implode(', ', $source),
+ $old_table_name
+ );
+ }
+
+ public function copy(string $old_table_name, string $new_table_name = User::TABLE, ?array $fields = null): void
+ {
+ $sql = $this->getSQLCopy($old_table_name, $new_table_name, $fields);
+ DB::getInstance()->exec($sql);
+ }
+
+ public function createTable(string $table_name = User::TABLE): void
+ {
+ $db = DB::getInstance();
+ $schema = $this->getSQLSchema($table_name);
+ $db->exec($schema);
+ }
+
+ public function createIndexes(string $table_name = User::TABLE): void
+ {
+ $id_field = null;
+ $db = DB::getInstance();
+
+ if ($id_field = $this->getLoginField()) {
+ // Mettre les champs identifiant vides à NULL pour pouvoir créer un index unique
+ $db->exec(sprintf('UPDATE %s SET %s = NULL WHERE %2$s = \'\';',
+ $table_name, $id_field));
+
+ $collation = '';
+
+ if ($this->isText($id_field)) {
+ $collation = ' COLLATE NOCASE';
+ }
+
+ // Création de l'index unique
+ $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_id_field ON %s (%s%s);', $table_name, $id_field, $collation));
+ }
+
+ $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_number ON %s (%s);', $table_name, $this->getNumberField()));
+ $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_category ON %s (id_category);', $table_name));
+ $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_parent ON %s (id_parent);', $table_name));
+ $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_is_parent ON %s (is_parent);', $table_name));
+ }
+
+ public function createTriggers(string $table_name = User::TABLE): void
+ {
+ // These triggers are to set id_parent to the ID of the user on parent user, when the user has children
+ $db = DB::getInstance();
+ $db->exec(sprintf('
+ CREATE TRIGGER %1$s_parent_trigger_update_new AFTER UPDATE OF id_parent ON %2$s BEGIN
+ UPDATE users SET is_parent = 1 WHERE id = NEW.id_parent;
+ END;
+ CREATE TRIGGER %1$s_parent_trigger_update_old AFTER UPDATE OF id_parent ON %2$s BEGIN
+ -- Set is_parent to 0 if user has no longer any children
+ UPDATE %1$s SET is_parent = 0 WHERE id = OLD.id_parent
+ AND 0 = (SELECT COUNT(*) FROM %2$s WHERE id_parent = OLD.id_parent);
+ END;
+ CREATE TRIGGER %1$s_parent_trigger_insert AFTER INSERT ON %2$s BEGIN
+ SELECT CASE WHEN NEW.id_parent IS NULL THEN RAISE(IGNORE) ELSE 0 END;
+ UPDATE users SET is_parent = 1 WHERE id = NEW.id_parent;
+ END;
+ CREATE TRIGGER %1$s_parent_trigger_delete AFTER DELETE ON %2$s BEGIN
+ SELECT CASE WHEN OLD.id_parent IS NULL THEN RAISE(IGNORE) ELSE 0 END;
+ -- Set is_parent to 0 if user has no longer any children
+ UPDATE %2$s SET is_parent = 0 WHERE id = OLD.id_parent
+ AND 0 = (SELECT COUNT(*) FROM %2$s WHERE id_parent = OLD.id_parent);
+ END;
+ -- Keep logs for create/delete/edit actions, just make them anonymous
+ CREATE TRIGGER %1$s_delete_logs BEFORE DELETE ON %2$s BEGIN
+ UPDATE logs SET id_user = NULL WHERE id_user = OLD.id AND type >= 10;
+ END;', $table_name, $table_name));
+ }
+
+ public function createView(string $table_name = User::TABLE): void
+ {
+ $db = DB::getInstance();
+ $virtual_fields = [];
+
+ foreach ($this->fieldsByType('virtual') as $field) {
+ $virtual_fields[] = sprintf('(%s) AS %s', $field->sql, $field->name);
+ }
+
+ $virtual_fields = implode(', ', $virtual_fields);
+
+ if (strlen($virtual_fields)) {
+ $virtual_fields = ', ' . $virtual_fields;
+ }
+
+ $sql = sprintf('
+ DROP VIEW IF EXISTS %s_view;
+ CREATE VIEW IF NOT EXISTS %1$s_view
+ AS
+ SELECT * %s
+ FROM %1$s;
+ ', $table_name, $virtual_fields);
+ $db->exec($sql);
+ }
+
+ /**
+ * Enregistre les changements de champs en base de données
+ */
+ public function rebuildUsersTable(array $fields): void
+ {
+ $db = DB::getInstance();
+
+ $fields = array_combine($fields, $fields);
+
+ // Virtual fields cannot be copied
+ foreach ($this->_fields as $f) {
+ $sql_type = DynamicField::SQL_TYPES[$f->type] ?? null;
+
+ if (!$sql_type) {
+ unset($fields[$f->name]);
+ }
+ }
+
+ // Always copy system fields
+ $system_fields = array_keys(DynamicField::SYSTEM_FIELDS);
+ $fields = array_merge(array_combine($system_fields, $system_fields), $fields);
+
+ $in_transaction = $db->inTransaction();
+
+ if (!$in_transaction) {
+ $db->beginSchemaUpdate();
+ }
+
+ $this->createTable(User::TABLE . '_tmp');
+
+ // No need to copy if the table does not exist (that's the case during first setup)
+ if ($db->firstColumn('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = ?;', User::TABLE)) {
+ $this->copy(User::TABLE, User::TABLE . '_tmp', $fields);
+ }
+
+ $db->exec(sprintf('DROP TABLE IF EXISTS %s;', User::TABLE));
+ $db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', User::TABLE));
+
+ $this->createIndexes(User::TABLE);
+ $this->createTriggers(User::TABLE);
+ $this->createView(User::TABLE);
+
+ $this->rebuildSearchTable(false);
+
+ if (!$in_transaction) {
+ $db->commitSchemaUpdate();
+ }
+ }
+
+ public function rebuildSearchTable(bool $from_users_table = true): void
+ {
+ $db = DB::getInstance();
+ $db->begin();
+
+ $search_table = User::TABLE . '_search';
+ $columns = $this->getSearchColumns();
+ $columns_sql = array_map([$db, 'quoteIdentifier'], $columns);
+ $columns_sql = implode(",\n\t", $columns_sql);
+
+ $sql = sprintf("CREATE TABLE IF NOT EXISTS %s\n(\n\tid INTEGER PRIMARY KEY NOT NULL REFERENCES %s (id) ON DELETE CASCADE,\n\t%s\n);", $search_table . '_tmp', User::TABLE, $columns_sql);
+
+ $db->exec($sql);
+
+ if ($from_users_table && $db->firstColumn('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = ?;', User::TABLE)) {
+ // This is slower but is necessary sometimes
+ $sql = $this->getSQLCopy(User::TABLE, $search_table . '_tmp', $columns, 'transliterate_to_ascii');
+ }
+ elseif ($db->firstColumn('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = ?;', $search_table)) {
+ $sql = $this->getSQLCopy($search_table, $search_table . '_tmp', $columns);
+ }
+ else {
+ $sql = null;
+ }
+
+ if ($sql) {
+ $db->exec($sql);
+ }
+
+ $db->exec(sprintf('DROP TABLE IF EXISTS %s;', $search_table));
+ $db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', $search_table));
+
+ foreach ($columns as $column) {
+ $sql = sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s);\n",
+ $db->quoteIdentifier($search_table . '_' . $column),
+ $search_table,
+ $db->quoteIdentifier($column)
+ );
+
+ $db->exec($sql);
+ }
+
+ $db->commit();
+ }
+
+ public function rebuildUserSearchCache(int $id): void
+ {
+ $db = DB::getInstance();
+ $columns = $this->getSearchColumns();
+ $keys = array_map([$db, 'quoteIdentifier'], $columns);
+ $copy = array_map(fn($c) => sprintf('transliterate_to_ascii(%s)', $c), $keys);
+
+ $sql = sprintf('INSERT OR REPLACE INTO %s_search (id, %s) SELECT id, %s FROM %1$s WHERE id = %d;',
+ User::TABLE,
+ implode(', ', $keys),
+ implode(', ', $copy),
+ $id
+ );
+
+ $db->exec($sql);
+ }
+
+ public function add(DynamicField $df)
+ {
+ $this->_fields[$df->name] = $df;
+ $this->reloadCache();
+ }
+
+ public function delete(string $name)
+ {
+ $this->_deleted[] = $this->_fields[$name];
+ unset($this->_fields[$name]);
+
+ $this->reloadCache();
+ }
+
+ public function save(bool $allow_rebuild = true)
+ {
+ if (empty($this->_fields_by_system_use['number'])) {
+ throw new ValidationException('Aucun champ de numéro de membre n\'existe');
+ }
+
+ if (count($this->_fields_by_system_use['number']) != 1) {
+ throw new ValidationException('Un seul champ peut être défini comme numéro');
+ }
+
+ if (empty($this->_fields_by_system_use['name'])) {
+ throw new ValidationException('Aucun champ de nom de membre n\'existe');
+ }
+
+ if (empty($this->_fields_by_system_use['login'])) {
+ throw new ValidationException('Aucun champ d\'identifiant de connexion n\'existe');
+ }
+
+ if (count($this->_fields_by_system_use['login']) != 1) {
+ throw new ValidationException('Un seul champ peut être défini comme identifiant');
+ }
+
+ if (empty($this->_fields_by_system_use['password'])) {
+ throw new ValidationException('Aucun champ de mot de passe n\'existe');
+ }
+
+ if (count($this->_fields_by_system_use['password']) != 1) {
+ throw new ValidationException('Un seul champ peut être défini comme mot de passe');
+ }
+
+ $rebuild = false;
+
+ $db = DB::getInstance();
+
+ // We need to disable foreign keys BEFORE we start the transaction
+ // this means that the config_users_fields table CANNOT have any foreign keys
+ $db->beginSchemaUpdate();
+
+ $copy = [];
+
+ foreach ($this->_fields as $field) {
+ if (!$field->exists()) {
+ $rebuild = true;
+ }
+ else {
+ $copy[] = $field->name;
+ }
+
+ if ($field->isModified()) {
+ $field->save();
+ }
+ }
+
+ foreach ($this->_deleted as $f) {
+ $f->delete();
+ $rebuild = true;
+ }
+
+ $this->_deleted = [];
+
+ if ($rebuild && $allow_rebuild) {
+ // FIXME/TODO: use ALTER TABLE ... DROP COLUMN for SQLite 3.35.0+
+ // some conditions apply
+ // https://www.sqlite.org/lang_altertable.html#altertabdropcol
+ $this->rebuildUsersTable($copy);
+ }
+
+ $db->commitSchemaUpdate();
+
+ $this->reload();
+ }
+
+ public function setOrderAll(array $order)
+ {
+ foreach (array_values($order) as $sort => $key) {
+ if (!array_key_exists($key, $this->_fields)) {
+ throw new \InvalidArgumentException('Unknown field name: ' . $key);
+ }
+
+ $this->_fields[$key]->set('sort_order', $sort);
+ }
+ }
+
+ public function getLastOrderIndex()
+ {
+ return count($this->_fields);
+ }
+
+ public function listEligibleLoginFields(): array
+ {
+ $out = [];
+
+ foreach ($this->_fields as $field) {
+ if (!in_array($field->type, $field::LOGIN_FIELD_TYPES)) {
+ continue;
+ }
+
+ $out[$field->name] = $field->label;
+ }
+
+ return $out;
+ }
+
+ protected function isUnique(string $field): bool
+ {
+ $db = DB::getInstance();
+
+ // First check that the field can be used as login
+ $sql = sprintf('SELECT (COUNT(DISTINCT transliterate_to_ascii(%s)) = COUNT(*)) FROM users WHERE %1$s IS NOT NULL AND %1$s != \'\';', $field);
+
+ return (bool) $db->firstColumn($sql);
+ }
+
+ public function changeLoginField(string $new_field, ?Session $session = null): void
+ {
+ $old_field = self::getLoginField();
+
+ if ($old_field === $new_field) {
+ return;
+ }
+
+ if (empty($this->_fields[$new_field])) {
+ throw new \InvalidArgumentException('This field does not exist.');
+ }
+
+ $type = $this->_fields[$new_field]->type;
+
+ if (!in_array($type, DynamicField::LOGIN_FIELD_TYPES)) {
+ throw new \InvalidArgumentException('This field cannot be used as a login field.');
+ }
+
+ if ($session) {
+ $user = $session->getUser();
+
+ if (empty($user->$new_field)) {
+ throw new UserException(sprintf('Le champ "%s" ne peut être utilisé comme champ de connexion car il est vide dans votre fiche de membre. Sinon vous ne pourriez plus vous connecter.', $this->_fields[$new_field]->label));
+ }
+ }
+
+ $db = DB::getInstance();
+
+ // First check that the field can be used as login
+ if (!$this->isUnique($new_field)) {
+ throw new UserException(sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->_fields[$new_field]->label));
+ }
+
+ // Change login field in fields config table
+ $sql = sprintf('UPDATE %s SET system = system & ~%d WHERE system & %2$d;
+ UPDATE %1$s SET system = system | %2$d WHERE name = %s;',
+ self::TABLE,
+ DynamicField::LOGIN,
+ $db->quote($new_field)
+ );
+
+ $db->exec($sql);
+
+ // Reload dynamic fields cache
+ $this->reload();
+
+ // Regenerate login index
+ $db->exec('DROP INDEX IF EXISTS users_id_field;');
+ $this->createIndexes();
+ }
+
+ public function listEligibleNameFields(): array
+ {
+ $out = [];
+
+ foreach ($this->_fields as $field) {
+ if (!in_array($field->type, $field::NAME_FIELD_TYPES)) {
+ continue;
+ }
+
+ $out[$field->name] = $field->label;
+ }
+
+ return $out;
+ }
+
+ public function changeNameFields(array $fields): void
+ {
+ if ($fields === self::getNameFields()) {
+ return;
+ }
+
+ $fields = array_unique($fields);
+
+ if (count($fields) < 1) {
+ throw new UserException('Aucun champ n\'a été sélectionné pour l\'identité des membres.');
+ }
+
+ $has_text = false;
+
+ foreach ($fields as $field) {
+ if (empty($this->_fields[$field])) {
+ throw new \InvalidArgumentException('This field does not exist: ' . $field);
+ }
+
+ $type = $this->_fields[$field]->type;
+
+ if (!in_array($type, DynamicField::NAME_FIELD_TYPES)) {
+ throw new \InvalidArgumentException('This field cannot be used as a name field: ' . $field);
+ }
+
+ if ($type !== 'number') {
+ $has_text = true;
+ }
+ }
+
+ if (!$has_text) {
+ throw new UserException('Aucun champ texte n\'a été sélectionné pour l\'identité des membres. Au moins un champ texte doit être sélectionné.');
+ }
+
+ $db = DB::getInstance();
+
+ $sql = sprintf('UPDATE %s SET system = system & ~%d WHERE system & %2$d;
+ UPDATE %1$s SET system = system | %2$d WHERE %s;',
+ self::TABLE,
+ DynamicField::NAMES,
+ $db->where('name', $fields)
+ );
+
+ $db->begin();
+ $db->exec($sql);
+ $db->commit();
+
+ $this->reload();
+ }
+}
diff --git a/src/include/lib/Paheko/Users/Session.php b/src/include/lib/Paheko/Users/Session.php
new file mode 100644
index 0000000..377b076
--- /dev/null
+++ b/src/include/lib/Paheko/Users/Session.php
@@ -0,0 +1,735 @@
+ self::ACCESS_NONE,
+ 'read' => self::ACCESS_READ,
+ 'write' => self::ACCESS_WRITE,
+ 'admin' => self::ACCESS_ADMIN,
+ ];
+
+ // Personalisation de la config de UserSession
+ protected bool $non_locking = true;
+ protected $cookie_name = 'pko';
+ protected $remember_me_cookie_name = 'pkop';
+ protected $remember_me_expiry = '+3 months';
+
+ protected ?User $_user;
+ protected ?array $_permissions;
+ protected ?array $_files_permissions;
+
+ static protected $_instance = null;
+
+ static public function getInstance()
+ {
+ // Use of static is important for Files\WebDAV\Session
+ return static::$_instance ?: static::$_instance = new static;
+ }
+
+ public function __clone()
+ {
+ throw new \LogicException('Cannot clone');
+ }
+
+ public function __construct()
+ {
+ if (static::$_instance !== null) {
+ throw new \LogicException('Wrong call, use getInstance');
+ }
+
+ $url = parse_url(ADMIN_URL);
+
+ parent::__construct(DB::getInstance(), [
+ 'cookie_domain' => $url['host'],
+ 'cookie_path' => preg_replace('!/admin/$!', '/', $url['path']),
+ 'cookie_secure' => HTTP::getScheme() == 'https' ? true : false,
+ ]);
+
+ $this->sid_in_url_secret = '&spko=' . sha1(SECRET_KEY);
+ }
+
+ public function isPasswordCompromised($password)
+ {
+ if (!isset($this->http)) {
+ $this->http = new \KD2\HTTP;
+ }
+
+ // Vérifier s'il n'y a pas un plugin qui gère déjà cet aspect
+ // notamment en installation mutualisée c'est plus efficace
+ $signal = Plugins::fire('password.check', true, ['password' => $password]);
+
+ if ($signal && $signal->isStopped()) {
+ return (bool) $signal->getOut('is_compromised');
+ }
+
+ unset($signal);
+
+ return parent::isPasswordCompromised($password);
+ }
+
+ protected function getUserForLogin($login)
+ {
+ $id_field = DynamicFields::getLoginField();
+
+ // Ne renvoie un membre que si celui-ci a le droit de se connecter
+ $query = 'SELECT u.id, u.%1$s AS login, u.password, u.otp_secret
+ FROM users AS u
+ INNER JOIN users_categories AS c ON c.id = u.id_category
+ WHERE u.%1$s = ? COLLATE NOCASE AND c.perm_connect >= %2$d
+ LIMIT 1;';
+
+ $query = sprintf($query, $id_field, self::ACCESS_READ);
+
+ return $this->db->first($query, $login);
+ }
+
+ protected function getUserDataForSession($id)
+ {
+ $user = Users::get($id);
+
+ if (!$user) {
+ return null;
+ }
+
+ return $id;
+ }
+
+ protected function storeRememberMeSelector($selector, $hash, $expiry, $user_id)
+ {
+ $selector = $this->cookie_name . '_' . $selector;
+ return $this->db->insert('users_sessions', [
+ 'selector' => $selector,
+ 'hash' => $hash,
+ 'expiry' => $expiry,
+ 'id_user' => $user_id,
+ ]);
+ }
+
+ protected function expireRememberMeSelectors()
+ {
+ return $this->db->delete('users_sessions', $this->db->where('expiry', '<', time()));
+ }
+
+ protected function getRememberMeSelector($selector)
+ {
+ $selector = $this->cookie_name . '_' . $selector;
+ return $this->db->first('SELECT REPLACE(selector, ?, \'\') AS selector, hash,
+ s.id_user AS user_id, u.password AS user_password, expiry
+ FROM users_sessions AS s
+ LEFT JOIN users AS u ON u.id = s.id_user
+ WHERE s.selector = ? LIMIT 1;', $this->cookie_name . '_', $selector);
+ }
+
+ protected function deleteRememberMeSelector($selector)
+ {
+ $selector = $this->cookie_name . '_' . $selector;
+ return $this->db->delete('users_sessions', $this->db->where('selector', $selector));
+ }
+
+ protected function deleteAllRememberMeSelectors($user_id)
+ {
+ return $this->db->delete('users_sessions', $this->db->where('id_user', $user_id));
+ }
+
+ public function isLogged(bool $disable_local_login = false)
+ {
+ $logged = parent::isLogged();
+
+ if ($logged && !$disable_local_login && LOCAL_LOGIN && LOCAL_LOGIN !== -1 && LOCAL_LOGIN !== $this->user) {
+ $logged = false;
+ }
+
+ // Ajout de la gestion de LOCAL_LOGIN
+ if (!$logged && !$disable_local_login && LOCAL_LOGIN) {
+ $logged = $this->forceLogin(LOCAL_LOGIN);
+ }
+
+ return $logged;
+ }
+
+ public function forceLogin($login)
+ {
+ // Force login with a static user, that is not in the local database
+ // this is useful for using a SSO like LDAP for example
+ if (is_array($login)) {
+ $this->_user = (new User)->import($login['user'] ?? []);
+
+ if (isset($login['user']['_name'])) {
+ $name = DynamicFields::getFirstNameField();
+ $this->_user->$name = $login['user']['_name'];
+ }
+
+ $this->_permissions = [];
+
+ foreach (Category::PERMISSIONS as $perm => $data) {
+ $this->_permissions[$perm] = $login['permissions'][$perm] ?? self::ACCESS_NONE;
+ }
+
+ return true;
+ }
+
+ // Look for the first user with the permission to manage the configuration
+ if (-1 === $login) {
+ $login = $this->db->firstColumn('SELECT id FROM users
+ WHERE id_category IN (SELECT id FROM users_categories WHERE perm_config = ?)
+ LIMIT 1', self::ACCESS_ADMIN);
+ }
+
+ // Only login if required
+ if ($login > 0 && ($this->user ?? null) != $login) {
+ return $this->create($login);
+ }
+
+ return isset($this->user) ? true : false;
+ }
+
+ public function login($login, $password, $remember_me = false)
+ {
+ $success = parent::login($login, $password, $remember_me);
+ $user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 150) ?: null;
+
+ if (true === $success) {
+ Log::add(Log::LOGIN_SUCCESS, compact('user_agent'));
+
+ // Mettre à jour la date de connexion
+ $this->db->preparedQuery('UPDATE users SET date_login = datetime() WHERE id = ?;', [$this->getUser()->id]);
+ }
+ // $success can be 'OTP' as well
+ elseif (!$success) {
+ if ($user = $this->getUserForLogin($login)) {
+ Log::add(Log::LOGIN_FAIL, compact('user_agent'), $user->id);
+ }
+ else {
+ Log::add(Log::LOGIN_FAIL, compact('user_agent'));
+ }
+ }
+
+ Plugins::fire('user.login.after', false, compact('login', 'password', 'remember_me', 'success'));
+
+ // Clean up logs
+ Log::clean();
+
+ return $success;
+ }
+
+ public function loginOTP(string $code): bool
+ {
+ $this->start();
+ $user_id = $_SESSION['userSessionRequireOTP']->user->id ?? null;
+ $user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 150) ?: null;
+ $details = compact('user_agent') + ['otp' => true];
+
+ $success = parent::loginOTP($code);
+
+ if ($success) {
+ Log::add(Log::LOGIN_SUCCESS, $details, $user_id);
+
+ // Mettre à jour la date de connexion
+ $this->db->preparedQuery('UPDATE users SET date_login = datetime() WHERE id = ?;', [$user_id]);
+ }
+ else {
+ Log::add(Log::LOGIN_FAIL, $details, $user_id);
+ }
+
+ Plugins::fire('user.login.otp', false, compact('success', 'user_id'));
+
+ return $success;
+ }
+
+ public function logout(bool $all = false)
+ {
+ $this->_user = null;
+ $this->_permissions = null;
+ $this->_files_permissions = null;
+
+ return parent::logout();
+ }
+
+ public function recoverPasswordSend(string $id): void
+ {
+ $user = $this->fetchUserForPasswordRecovery($id);
+
+ if (!$user) {
+ throw new UserException('Aucun membre trouvé avec cette adresse e-mail, ou le membre trouvé n\'a pas le droit de se connecter.');
+ }
+
+ if ($user->perm_connect == self::ACCESS_NONE) {
+ throw new UserException('Ce membre n\'a pas le droit de se connecter.');
+ }
+
+ $email = DynamicFields::getFirstEmailField();
+
+ if (!trim($user->$email)) {
+ throw new UserException('Ce membre n\'a pas d\'adresse e-mail renseignée dans son profil.');
+ }
+
+ $user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 150) ?: null;
+ Log::add(Log::LOGIN_RECOVER, compact('user_agent'), $user->id);
+
+ $query = $this->makePasswordRecoveryQuery($user);
+
+ $url = ADMIN_URL . 'password.php?c=' . $query;
+
+ EmailsTemplates::passwordRecovery($user->$email, $url, $user->pgp_key);
+ }
+
+ protected function fetchUserForPasswordRecovery(string $identifier, ?string $identifier_field = null): ?\stdClass
+ {
+ $db = DB::getInstance();
+
+ $identifier_field ??= DynamicFields::getLoginField();
+ $email_field = DynamicFields::getFirstEmailField();
+
+ if ($identifier_field === 'id') {
+ $identifier = (int) $identifier;
+ }
+ else {
+ $identifier = trim($identifier);
+ }
+
+ // Fetch user, must have an email
+ $sql = sprintf('SELECT u.id, u.%s AS email, u.password, u.pgp_key, c.perm_connect
+ FROM users u
+ INNER JOIN users_categories c ON c.id = u.id_category
+ WHERE u.%s = ? COLLATE U_NOCASE
+ AND u.%1$s IS NOT NULL
+ LIMIT 1;',
+ $db->quoteIdentifier($email_field),
+ $db->quoteIdentifier($identifier_field));
+
+ return $db->first($sql, $identifier) ?: null;
+ }
+
+ protected function makePasswordRecoveryHash(\stdClass $user, ?int $expire = null): string
+ {
+ // valide pour 1 heure minimum
+ $expire = $expire ?? ceil((time() - strtotime('2017-01-01')) / 3600) + 1;
+
+ $hash = hash_hmac('sha256', $user->email . $user->id . $user->password . $expire, SECRET_KEY, true);
+ $hash = substr(Security::base64_encode_url_safe($hash), 0, 16);
+ return $hash;
+ }
+
+ protected function makePasswordRecoveryQuery(\stdClass $user): string
+ {
+ $expire = ceil((time() - strtotime('2017-01-01')) / 3600) + 1;
+ $hash = $this->makePasswordRecoveryHash($user, $expire);
+ $id = base_convert($user->id, 10, 36);
+ $expire = base_convert($expire, 10, 36);
+ return sprintf('%s.%s.%s', $id, $expire, $hash);
+ }
+
+ /**
+ * Check that the supplied query is valid, if so, return the user information
+ * @param string $query User-supplied query
+ */
+ public function checkRecoveryPasswordQuery(string $query): ?\stdClass
+ {
+ if (substr_count($query, '.') !== 2) {
+ return null;
+ }
+
+ list($id, $expire, $email_hash) = explode('.', $query);
+
+ $id = base_convert($id, 36, 10);
+ $expire = base_convert($expire, 36, 10);
+
+ $expire_timestamp = ($expire * 3600) + strtotime('2017-01-01');
+
+ // Check that the query has not expired yet
+ if (time() / 3600 > $expire_timestamp) {
+ return null;
+ }
+
+ // Fetch user info
+ $user = $this->fetchUserForPasswordRecovery($id, 'id');
+
+ if (!$user) {
+ return null;
+ }
+
+ // Check hash using secret data from the user
+ $hash = $this->makePasswordRecoveryHash($user, $expire);
+
+ if (!hash_equals($hash, $email_hash)) {
+ return null;
+ }
+
+ return $user;
+ }
+
+ public function recoverPasswordChange(string $query, string $password, string $password_confirmed)
+ {
+ $user = $this->checkRecoveryPasswordQuery($query);
+
+ if (null === $user) {
+ throw new UserException('Le code permettant de changer le mot de passe a expiré. Merci de bien vouloir recommencer la procédure.');
+ }
+
+ $ue = Users::get($user->id);
+ $ue->importSecurityForm(false, compact('password', 'password_confirmed'));
+ $ue->save(false);
+ EmailsTemplates::passwordChanged($ue);
+ }
+
+ public function user(): ?User
+ {
+ return $this->getUser();
+ }
+
+ static public function getPreference(string $key)
+ {
+ $user = self::getLoggedUser();
+ return $user ? $user->getPreference($key) : null;
+ }
+
+ static public function getLoggedUser(): ?User
+ {
+ $s = self::getInstance();
+
+ if (!$s->isLogged()) {
+ return null;
+ }
+
+ return $s->getUser();
+ }
+
+ /**
+ * Returns cookie string for PDF printing
+ */
+ static public function getCookie(): ?string
+ {
+ $i = self::getInstance();
+
+ if (!$i->isLogged()) {
+ return null;
+ }
+
+ return sprintf('%s=%s', $i->cookie_name, $i->id());
+ }
+
+ static public function getCookieSecret(): string
+ {
+ return self::getInstance()->sid_in_url_secret;
+ }
+
+ public function getUser()
+ {
+ if (isset($this->_user)) {
+ return $this->_user;
+ }
+
+ if (!$this->isLogged())
+ {
+ throw new \LogicException('User is not logged in.');
+ }
+
+ $this->_user = Users::get($this->user);
+
+ if (!$this->_user) {
+ $this->logout();
+ // User does not exist anymore
+ }
+
+ $this->_permissions = null;
+ $this->_files_permissions = null;
+ return $this->_user;
+ }
+
+ static public function getUserId(): ?int
+ {
+ $i = self::getInstance();
+
+ if (!$i->isLogged()) {
+ return null;
+ }
+
+ return $i->getUser()->id;
+ }
+
+ public function canAccess(string $section, int $permission): bool
+ {
+ if (!$this->isLogged()) {
+ return false;
+ }
+
+ if (!isset($this->_permissions)) {
+ $this->_permissions = $this->user()->category()->getPermissions();
+ }
+
+ $perm = $this->_permissions[$section] ?? null;
+
+ if (null === $perm) {
+ throw new \InvalidArgumentException('Unknown section: ' . $section);
+ }
+
+ return ($perm >= $permission);
+ }
+
+ public function requireAccess(string $section, int $permission): void
+ {
+ if (!$this->canAccess($section, $permission)) {
+ throw new UserException('Vous n\'avez pas le droit d\'accéder à cette page.');
+ }
+ }
+
+ /**
+ * Check permissions for extensions files (plugins, modules)
+ */
+ public function checkExtensionFilePermissions(string $path, string $permission): bool
+ {
+ $context = strtok($path, '/');
+ $type = strtok('/');
+ $name = strtok('/');
+ $file_path = strtok('');
+
+ if (empty($name) || empty($type) || ($type !== 'm' && $type !== 'p') || empty($file_path)) {
+ return false;
+ }
+
+ $public = substr($file_path, 0, 7) === 'public/';
+
+ $base = $context . '/' . $type . '/' . $name . '/';
+
+ if ($public) {
+ $base .= 'public/';
+ }
+
+ // Build cache
+ if (!isset($this->_files_permissions[$base])) {
+ $read = $write = false;
+ // Public files
+ if (!$this->isLogged() && $public) {
+ $read = true;
+ }
+ // Other files are private
+ elseif ($this->isLogged()) {
+ if ('p' === $type) {
+ $ext = Plugins::get($name);
+ }
+ else {
+ $ext = Modules::get($name);
+ }
+
+ $read = $write = $ext->restrict_section ? $this->canAccess($ext->restrict_section, $ext->restrict_level) : false;
+ }
+
+ $this->_files_permissions[$base] = [
+ 'mkdir' => $write,
+ 'move' => $write,
+ 'write' => $write,
+ 'create' => $write,
+ 'delete' => $write,
+ 'read' => $read,
+ 'share' => $write,
+ ];
+ }
+
+ return $this->_files_permissions[$base][$permission];
+ }
+
+ public function checkFilePermission(string $path, string $permission): bool
+ {
+ $path = ltrim($path, '/');
+
+ if (!isset($this->_files_permissions)) {
+ $this->_files_permissions = Files::buildUserPermissions($this);
+ }
+
+ $context = strtok($path, '/');
+ $b = strtok('/');
+ $c = strtok('/');
+ strtok('');
+
+ // Check permissions for plugins and modules files
+ if ($context === File::CONTEXT_EXTENSIONS) {
+ return $this->checkExtensionFilePermissions($path, $permission);
+ }
+
+ static $default = [
+ 'mkdir' => false,
+ 'move' => false,
+ 'create' => false,
+ 'read' => false,
+ 'write' => false,
+ 'delete' => false,
+ 'share' => false,
+ ];
+
+ $file_permissions = $default;
+
+ // Access to user files is quite specific as it is defined per field
+ if ($context === File::CONTEXT_USER) {
+ if (!$c) {
+ if ($this->canAccess(self::SECTION_USERS, self::ACCESS_READ)) {
+ $file_permissions['read'] = true;
+ }
+
+ return $file_permissions[$permission];
+ }
+
+ // Always match by field name
+ $path_level = $context . '//' . $c;
+
+ $field = DynamicFields::get($c);
+
+ if (!$field) {
+ return $default[$permission];
+ }
+
+ if ($this->isLogged() && (int)$b === $this::getUserId()) {
+ $read = $field->user_access_level >= self::ACCESS_READ;
+ $write = $field->user_access_level >= self::ACCESS_WRITE;
+ }
+ else {
+ $read = $this->canAccess(self::SECTION_USERS, $field->management_access_level);
+ $write = $this->canAccess(self::SECTION_USERS, self::ACCESS_WRITE) && $this->canAccess(self::SECTION_USERS, $field->management_access_level);
+ }
+
+ $file_permissions['read'] = $read;
+ $file_permissions['write'] =
+ $file_permissions['delete'] =
+ $file_permissions['create'] = $write;
+ return $file_permissions[$permission];
+ }
+ else {
+ // Remove components
+ $path_level = preg_replace('!/[^/]+!', '/', $path);
+ }
+
+ foreach ($this->_files_permissions as $context => $permissions) {
+ if (!array_key_exists($permission, $permissions)) {
+ throw new \InvalidArgumentException(sprintf('Unknown permission "%s" in context "%s"', $permission, $context));
+ }
+
+ if ('' === $context
+ || $context == $path
+ || 0 === strpos($path, $context)
+ || 0 === strpos($path_level, $context)) {
+ return $permissions[$permission];
+ }
+ }
+
+ throw new \InvalidArgumentException(sprintf('Unknown context: %s', $path));
+ }
+
+ public function getFilePermissions(string $path): ?array
+ {
+ if (!isset($this->_files_permissions)) {
+ $this->_files_permissions = Files::buildUserPermissions($this);
+ }
+
+ return $this->_files_permissions[$path] ?? null;
+ }
+
+ public function requireFilePermission(string $context, string $permission)
+ {
+ if (!$this->checkFilePermission($context, $permission)) {
+ throw new UserException('Vous n\'avez pas le droit d\'effectuer cette action.');
+ }
+ }
+
+ // Ici checkOTP utilise NTP en second recours
+ public function checkOTP($secret, $code)
+ {
+ if (Security_OTP::TOTP($secret, $code))
+ {
+ return true;
+ }
+
+ // Vérifier encore, mais avec le temps NTP
+ // au cas où l'horloge du serveur n'est pas à l'heure
+ if (\Paheko\NTP_SERVER
+ && ($time = Security_OTP::getTimeFromNTP(\Paheko\NTP_SERVER))
+ && Security_OTP::TOTP($secret, $code, $time))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function getNewOTPSecret()
+ {
+ $config = Config::getInstance();
+ $out = [];
+ $out['secret'] = Security_OTP::getRandomSecret();
+ $out['secret_display'] = implode(' ', str_split($out['secret'], 4));
+
+ $icon = $config->fileURL('icon');
+ $out['url'] = Security_OTP::getOTPAuthURL($config->org_name, $out['secret'], 'totp', $icon);
+
+ $qrcode = new QRCode($out['url']);
+ $out['qrcode'] = 'data:image/svg+xml;base64,' . base64_encode($qrcode->toSVG());
+
+ return $out;
+ }
+
+ public function countActiveSessions(): int
+ {
+ $selector = $this->getRememberMeCookie()->selector ?? null;
+ $user = $this->getUser();
+ return DB::getInstance()->count('users_sessions', 'id_user = ? AND selector != ?', $user->id(), $this->cookie_name . '_' . $selector) + 1;
+ }
+
+ public function isAdmin(): bool
+ {
+ return $this->canAccess(self::SECTION_CONNECT, self::ACCESS_READ)
+ && $this->canAccess(self::SECTION_CONFIG, self::ACCESS_ADMIN);
+ }
+}
diff --git a/src/include/lib/Paheko/Users/Users.php b/src/include/lib/Paheko/Users/Users.php
new file mode 100644
index 0000000..cb6162c
--- /dev/null
+++ b/src/include/lib/Paheko/Users/Users.php
@@ -0,0 +1,592 @@
+default_category;
+ $user = new User;
+ $user->set('id_category', $default_category);
+ return $user;
+ }
+
+ static public function iterateAssocByCategory(?int $id_category = null): iterable
+ {
+ $where = $id_category ? sprintf('id_category = %d', $id_category) : 'id_category IN (SELECT id FROM users_categories WHERE hidden = 0)';
+
+ $sql = sprintf('SELECT id, %s AS name FROM users WHERE %s ORDER BY name COLLATE U_NOCASE;',
+ DynamicFields::getNameFieldsSQL(),
+ $where);
+
+ foreach (DB::getInstance()->iterate($sql) as $row) {
+ yield $row->id => $row->name;
+ }
+ }
+
+ static protected function iterateEmails(array $sql, string $email_column = '_email'): \Generator
+ {
+ foreach (DB::getInstance()->iterate(implode(' UNION ALL ', $sql)) as $row) {
+ yield $row->$email_column => $row;
+ }
+ }
+
+ /**
+ * Return a list for all emails for a specific mailing checkbox
+ */
+ static public function iterateEmailsByField(string $field_name, $field_value): iterable
+ {
+ $db = DB::getInstance();
+ $field = DynamicFields::get($field_name);
+
+ if (!$field) {
+ throw new \InvalidArgumentException('Unknown field: ' . $field_name);
+ }
+
+ if (is_bool($field_value)) {
+ $field_value = (int)$field_value;
+ }
+ else {
+ $field_value = $db->quote($field_value);
+ }
+
+ $sql = [];
+ $where = sprintf('%s = %d', $db->quoteIdentifier($field->name), $field_value);
+ $where .= ' AND id_category IN (SELECT id FROM users_categories WHERE hidden = 0)';
+
+ $fields = DynamicFields::getEmailFields();
+
+ foreach ($fields as $field) {
+ $sql[] = sprintf('SELECT *, %s AS _email, NULL AS preferences FROM users WHERE %s AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $where);
+ }
+
+ return self::iterateEmails($sql);
+ }
+
+ /**
+ * Return a list for all emails by category
+ * @param int|null $id_category If NULL, then all categories except hidden ones will be returned
+ */
+ static public function iterateEmailsByCategory(?int $id_category = null): iterable
+ {
+ $db = DB::getInstance();
+ $fields = DynamicFields::getEmailFields();
+ $sql = [];
+ $where = $id_category ? sprintf('id_category = %d', $id_category) : 'id_category IN (SELECT id FROM users_categories WHERE hidden = 0)';
+
+ foreach ($fields as $field) {
+ $sql[] = sprintf('SELECT *, %s AS _email, NULL AS preferences FROM users WHERE %s AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $where);
+ }
+
+ return self::iterateEmails($sql);
+ }
+
+ /**
+ * Return a list of all emails by service (user must be active)
+ */
+ static public function iterateEmailsByActiveService(int $id_service): iterable
+ {
+ $db = DB::getInstance();
+
+ // Create a temporary table
+ if (!$db->test('sqlite_temp_master', 'type = \'table\' AND name=\'users_active_services\'')) {
+ $db->exec('DROP TABLE IF EXISTS users_active_services;
+ CREATE TEMPORARY TABLE IF NOT EXISTS users_active_services (id, service);
+ INSERT INTO users_active_services SELECT id_user, id_service FROM (
+ SELECT id_user, id_service, MAX(expiry_date) FROM services_users
+ WHERE expiry_date IS NULL OR expiry_date >= date()
+ GROUP BY id_user, id_service
+ );
+ DELETE FROM users_active_services WHERE id IN (SELECT id FROM users WHERE id_category IN (SELECT id FROM users_categories WHERE hidden =1));');
+ }
+
+ $fields = DynamicFields::getEmailFields();
+ $sql = [];
+
+ foreach ($fields as $field) {
+ $sql[] = sprintf('SELECT u.*, u.%s AS _email, NULL AS preferences FROM users u INNER JOIN users_active_services s ON s.id = u.id
+ WHERE s.service = %d AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $id_service);
+ }
+
+ return self::iterateEmails($sql);
+ }
+
+ static public function iterateEmailsBySearch(int $id_search): iterable
+ {
+ $db = DB::getInstance();
+
+ $s = Search::get($id_search);
+ // Make sure the query is protected and safe, by doing a protectSelect
+ $s->query(['limit' => 1]);
+
+ $header = $s->getHeader();
+ $id_column = null;
+
+ if (in_array('id', $header)) {
+ $id_column = 'id';
+ }
+ elseif (in_array('_user_id', $header)) {
+ $id_column = '_user_id';
+ }
+ else {
+ throw new UserException('La recherche ne comporte pas de colonne "id" ou "_user_id", et donc ne permet pas l\'envoi d\'email.');
+ }
+
+ $columns = array_map([$db, 'quoteIdentifier'], $header);
+ $columns = implode(', ', $columns);
+
+ // We only need the user id, store it in a temporary table for now
+ $db->exec(sprintf('DROP TABLE IF EXISTS users_tmp_search; CREATE TEMPORARY TABLE IF NOT EXISTS users_tmp_search (%s);', $columns));
+ $db->exec(sprintf('INSERT INTO users_tmp_search SELECT * FROM (%s)', $s->SQL(['no_limit' => true])));
+
+ $fields = DynamicFields::getEmailFields();
+
+ $sql = [];
+
+ foreach ($fields as $field) {
+ $sql[] = sprintf('SELECT s.*, u.*, u.%s AS _email, NULL AS preferences
+ FROM users u
+ INNER JOIN users_tmp_search AS s ON s.%s = u.id
+ WHERE u.%1$s IS NOT NULL',
+ $db->quoteIdentifier($field),
+ $db->quoteIdentifier($id_column)
+ );
+ }
+
+ return self::iterateEmails($sql);
+ }
+
+ static public function listByCategory(?int $id_category = null, ?Session $session = null): DynamicList
+ {
+ $db = DB::getInstance();
+ $df = DynamicFields::getInstance();
+ $number_field = $df->getNumberField();
+ $name_fields = $df->getNameFields();
+
+ $columns = [
+ '_user_id' => [
+ 'select' => 'u.id',
+ ],
+ '_user_name_index' => [
+ 'select' => $df::getNameFieldsSearchableSQL('s'),
+ ],
+ ];
+
+ $number_column = [
+ 'label' => 'Num.',
+ 'select' => 'u.' . $db->quoteIdentifier($number_field),
+ ];
+
+ $identity_column = [
+ 'label' => $df->getNameLabel(),
+ 'select' => $df->getNameFieldsSQL('u'),
+ 'order' => '_user_name_index %s',
+ ];
+
+ $fields = $df->getListedFields();
+
+ foreach ($fields as $key => $config) {
+ // Skip number field
+ if ($key === $number_field) {
+ if (null !== $number_column) {
+ $columns['number'] = $number_column;
+ $number_column = null;
+ }
+
+ continue;
+ }
+ // Skip name fields
+ elseif (in_array($key, $name_fields)) {
+ if (null !== $identity_column) {
+ $columns['identity'] = $identity_column;
+ $identity_column = null;
+ }
+
+ continue;
+ }
+
+ if ($session && !$session->canAccess(Session::SECTION_USERS, $config->management_access_level)) {
+ continue;
+ }
+
+ $columns[$key] = [
+ 'label' => $config->label,
+ 'select' => 'u.' . $db->quoteIdentifier($key),
+ ];
+
+ if ($config->hasSearchCache($key)) {
+ $columns[$key]['order'] = sprintf('s.%s %%s', $db->quoteIdentifier($key));
+ }
+
+ if ($config->type == 'file') {
+ $columns[$key]['select'] = sprintf('(SELECT GROUP_CONCAT(f.path, \';\')
+ FROM users_files uf
+ INNER JOIN files f ON f.id = uf.id_file AND f.trash IS NULL
+ WHERE uf.id_user = u.id AND uf.field = %s)',
+ $db->quote($key)
+ );
+ }
+ }
+
+ if (null !== $identity_column) {
+ $columns['identity'] = $identity_column;
+ }
+
+ $tables = 'users_view u';
+ $tables .= ' INNER JOIN users_search s ON s.id = u.id';
+
+ if ($db->test('users', 'is_parent = 1')) {
+ $tables .= ' LEFT JOIN users b ON b.id = u.id_parent';
+
+ $columns['id_parent'] = [
+ 'label' => 'Rattaché à',
+ 'select' => 'u.id_parent',
+ 'order' => 'u.id_parent IS NULL, _parent_name COLLATE U_NOCASE %s, _user_name_index %1$s',
+ ];
+
+ $columns['_parent_name'] = [
+ 'select' => sprintf('CASE WHEN u.id_parent IS NOT NULL THEN %s ELSE NULL END', $df->getNameFieldsSQL('b')),
+ ];
+
+ $columns['is_parent'] = [
+ 'label' => 'Responsable',
+ 'select' => 'u.is_parent',
+ 'order' => 'u.is_parent DESC, _user_name_index %1$s',
+ ];
+ }
+
+ if (!$id_category) {
+ $conditions = sprintf('u.id_category IN (SELECT id FROM users_categories WHERE hidden = 0)');
+ }
+ elseif ($id_category > 0) {
+ $conditions = sprintf('u.id_category = %d', $id_category);
+ }
+ else {
+ $conditions = '1';
+ }
+
+ $order = 'identity';
+
+ if (!isset($columns[$order])) {
+ $order = key($fields) ?? 'number';
+ }
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->orderBy($order, false);
+
+ return $list;
+ }
+
+ static public function get(int $id): ?User
+ {
+ return EM::findOneById(User::class, $id, 'users_view');
+ }
+
+ static public function getName(int $id): ?string
+ {
+ $name = DynamicFields::getNameFieldsSQL();
+ $found = EM::getInstance(User::class)->col(sprintf('SELECT %s FROM @TABLE WHERE id = ?;', $name), $id);
+ $found = (string) $found;
+ return $found ?: null;
+ }
+
+ static public function getNames(array $ids): array
+ {
+ $name = DynamicFields::getNameFieldsSQL();
+ $db = EM::getInstance(User::class)->DB();
+ return $db->getAssoc(sprintf('SELECT id, %s FROM users WHERE %s;', $name, $db->where('id', $ids)));
+ }
+
+ static public function getFromNumber(string $number): ?User
+ {
+ $field = DynamicFields::getNumberFieldSQL();
+ return EM::findOne(User::class, 'SELECT * FROM @TABLE_view WHERE ' . $field . ' = ?', $number);
+ }
+
+ static public function getIdFromNumber(string $number): ?int
+ {
+ $field = DynamicFields::getNumberFieldSQL();
+ return EM::getInstance(User::class)->col('SELECT id FROM @TABLE WHERE ' . $field . ' = ?', $number) ?: null;
+ }
+
+ static public function getNameFromNumber(string $number): ?string
+ {
+ $name = DynamicFields::getNameFieldsSQL();
+ $field = DynamicFields::getNumberFieldSQL();
+ $found = EM::getInstance(User::class)->col(sprintf('SELECT %s FROM @TABLE WHERE %s = ?;', $name, $field), $number);
+ $found = (string) $found;
+ return $found ?: null;
+ }
+
+ static public function deleteSelected(array $ids): void
+ {
+ $ids = array_map('intval', $ids);
+
+ if ($logged_user_id = Session::getUserId()) {
+ if (in_array($logged_user_id, $ids)) {
+ throw new UserException('Il n\'est pas possible de supprimer son propre compte.');
+ }
+ }
+
+ foreach ($ids as $id) {
+ Files::delete(File::CONTEXT_USER . '/' . $id);
+ }
+
+ $db = DB::getInstance();
+
+ // Suppression du membre
+ $db->delete(User::TABLE, $db->where('id', $ids));
+ }
+
+ static public function deleteFilesSelected(array $ids): void
+ {
+ $ids = array_map('intval', $ids);
+
+ foreach ($ids as $id) {
+ Files::delete(File::CONTEXT_USER . '/' . $id);
+ }
+ }
+
+ static public function changeCategorySelected(int $category_id, array $ids): void
+ {
+ $db = DB::getInstance();
+
+ if (!$db->test(Category::TABLE, 'id = ?', $category_id)) {
+ throw new \InvalidArgumentException('Invalid category ID: ' . $category_id);
+ }
+
+ $ids = array_map('intval', $ids);
+
+ // Don't allow current user ID to change his/her category
+ $logged_user_id = Session::getUserId();
+ $ids = array_filter($ids, fn($a) => $a != $logged_user_id);
+
+ $db->update(User::TABLE,
+ ['id_category' => $category_id],
+ $db->where('id', $ids)
+ );
+ }
+
+ static public function exportSelected(string $format, array $ids): void
+ {
+ $db = DB::getInstance();
+
+ $ids = array_map('intval', $ids);
+ $where = $db->where('id', $ids);
+ $name = sprintf('Liste de %d membres', count($ids));
+ self::exportWhere($format, $name, $where);
+ }
+
+ static public function exportCategory(string $format, int $id_category): void
+ {
+ if ($id_category == -1) {
+ $name = 'Tous les membres';
+ $where = '1';
+ }
+ elseif (!$id_category) {
+ $name = 'Membres sauf catégories cachées';
+ $where = 'id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
+ }
+ else {
+ $cat = Categories::get($id_category);
+ $name = sprintf('Membres - %s', $cat->name);
+ $where = sprintf('id_category = %d', $id_category);
+ }
+
+ self::exportWhere($format, $name, $where);
+ }
+
+ static public function export(string $format): void
+ {
+ self::exportWhere($format, 'Tous les membres', '1');
+ }
+
+ static protected function exportWhere(string $format, string $name, string $where): void
+ {
+ $df = DynamicFields::getInstance();
+ $db = DB::getInstance();
+
+ $header = $df->listAssocNames();
+ $columns = array_keys($header);
+ $columns = array_map([$db, 'quoteIdentifier'], $columns);
+ $columns = implode(', ', $columns);
+ $header['category'] = 'Catégorie';
+
+ $i = $db->iterate(sprintf('SELECT %s, (SELECT name FROM users_categories WHERE id = u.id_category) AS category FROM users_view u WHERE %s;', $columns, $where));
+
+ CSV::export($format, $name, $i, $header, [self::class, 'exportRowCallback']);
+ }
+
+ static public function exportRowCallback(&$row) {
+ $df = DynamicFields::getInstance();
+
+ foreach ($row as $key => &$value) {
+ $field = $df->get($key);
+
+ if (!$field || null === $value) {
+ continue;
+ }
+
+ if ($field->type === 'date' && is_string($value)) {
+ $value = \DateTime::createFromFormat('!Y-m-d', $value);
+ }
+ elseif ($field->type === 'datetime' && is_string($value)) {
+ $value = \DateTime::createFromFormat('!Y-m-d', $value);
+ }
+ else {
+ $value = $field->getStringValue($value);
+ }
+ }
+
+ unset($value);
+ }
+
+ static public function importReport(CSV_Custom $csv, string $mode, ?int $logged_user_id = null): array
+ {
+ if (!in_array($mode, self::IMPORT_MODES)) {
+ throw new \InvalidArgumentException('Invalid import mode: ' . $mode);
+ }
+
+ $report = ['created' => [], 'modified' => [], 'unchanged' => [], 'errors' => []];
+
+ if ($logged_user_id) {
+ $report['has_logged_user'] = false;
+ }
+
+ foreach (self::iterateImport($csv, $mode, $report['errors']) as $line => $user) {
+ if ($logged_user_id && $user->id == $logged_user_id) {
+ $report['has_logged_user'] = true;
+ continue;
+ }
+
+ try {
+ $user->selfCheck();
+ }
+ catch (UserException $e) {
+ $report['errors'][] = sprintf('Ligne %d (%s) : %s', $line, $user->name(), $e->getMessage());
+ continue;
+ }
+
+ if (!$user->exists()) {
+ $report['created'][] = $user;
+ }
+ elseif ($user->isModified()) {
+ $report['modified'][] = $user;
+ }
+ else {
+ $report['unchanged'][] = $user;
+ }
+ }
+
+ return $report;
+ }
+
+ static public function import(CSV_Custom $csv, string $mode, ?int $logged_user_id = null): void
+ {
+ if (!in_array($mode, self::IMPORT_MODES)) {
+ throw new \InvalidArgumentException('Invalid import mode: ' . $mode);
+ }
+
+ $db = DB::getInstance();
+ $db->begin();
+
+ foreach (self::iterateImport($csv, $mode) as $i => $user) {
+ // Skip logged user, to avoid changing own login field
+ if ($logged_user_id && $user->id == $logged_user_id) {
+ continue;
+ }
+
+ try {
+ $user->save();
+ }
+ catch (UserException $e) {
+ throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()), 0, $e);
+ }
+ }
+
+ $db->commit();
+ }
+
+ static public function iterateImport(CSV_Custom $csv, string $mode, ?array &$errors = null): \Generator
+ {
+ if (!in_array($mode, self::IMPORT_MODES)) {
+ throw new \InvalidArgumentException('Invalid import mode: ' . $mode);
+ }
+
+ $number_field = DynamicFields::getNumberField();
+
+ foreach ($csv->iterate() as $i => $row) {
+ $user = null;
+
+ try {
+ if ($mode === 'update') {
+ if (empty($row->$number_field)) {
+ throw new UserException('Aucun numéro de membre n\'a été indiqué');
+ }
+
+ $user = self::getFromNumber($row->$number_field);
+
+ if (!$user) {
+ $msg = sprintf('Le membre avec le numéro "%s" n\'existe pas.', $row->$number_field);
+ throw new UserException($msg);
+ }
+ }
+ elseif ($mode === 'auto' && !empty($row->$number_field)) {
+ $user = self::getFromNumber($row->$number_field);
+ }
+
+ if (!$user) {
+ $user = self::create();
+
+ if ($mode === 'create' || empty($row->$number_field)) {
+ $user->$number_field = null;
+ $user->setNumberIfEmpty();
+ unset($row->$number_field);
+ }
+ }
+
+ $user->importForm((array)$row);
+ yield $i => $user;
+ }
+ catch (UserException $e) {
+ if (null !== $errors) {
+ $errors[] = sprintf('Ligne %d : %s', $i, $e->getMessage());
+ continue;
+ }
+
+ throw $e;
+ }
+ }
+ }
+}
diff --git a/src/include/lib/Paheko/Utils.php b/src/include/lib/Paheko/Utils.php
new file mode 100644
index 0000000..f6c5121
--- /dev/null
+++ b/src/include/lib/Paheko/Utils.php
@@ -0,0 +1,1613 @@
+ '↑',
+ 'down' => '↓',
+ 'export' => '↷',
+ 'import' => '↶',
+ 'reset' => '↺',
+ 'upload' => '⇑',
+ 'download' => '⇓',
+ 'home' => '⌂',
+ 'print' => '⎙',
+ 'star' => '★',
+ 'check' => '☑',
+ 'settings' => '☸',
+ 'alert' => '⚠',
+ 'mail' => '✉',
+ 'edit' => '✎',
+ 'delete' => '✘',
+ 'help' => '❓',
+ 'plus' => '➕',
+ 'minus' => '➖',
+ 'login' => '⇥',
+ 'logout' => '⤝',
+ 'eye-off' => '⤫',
+ 'menu' => '𝍢',
+ 'eye' => '👁',
+ 'user' => '👤',
+ 'users' => '👪',
+ 'calendar' => '📅',
+ 'attach' => '📎',
+ 'search' => '🔍',
+ 'lock' => '🔒',
+ 'unlock' => '🔓',
+ 'folder' => '🗀',
+ 'document' => '🗅',
+ 'bold' => 'B',
+ 'italic' => 'I',
+ 'header' => 'H',
+ 'text' => 'T',
+ 'paragraph' => '§',
+ 'list-ol' => '1',
+ 'list-ul' => '•',
+ 'table' => '◫',
+ 'radio-unchecked' => '◯',
+ 'uncheck' => '☐',
+ 'radio-checked' => '⬤',
+ 'image' => '🖻',
+ 'left' => '←',
+ 'right' => '→',
+ 'column' => '▚',
+ 'del-column' => '🮔',
+ 'reload' => '🗘',
+ 'gallery' => '🖼',
+ 'code' => '<',
+ 'markdown' => 'M',
+ 'globe' => '🌍',
+ 'video' => '▶',
+ 'quote' => '«',
+ 'money' => '€',
+ 'pdf' => 'P',
+ 'trash' => '🗑',
+ 'history' => '⌚',
+ ];
+
+ const FRENCH_DATE_NAMES = [
+ 'January' => 'janvier',
+ 'February' => 'février',
+ 'March' => 'mars',
+ 'April' => 'avril',
+ 'May' => 'mai',
+ 'June' => 'juin',
+ 'July' => 'juillet',
+ 'August' => 'août',
+ 'September' => 'septembre',
+ 'October' => 'octobre',
+ 'November' => 'novembre',
+ 'December' => 'décembre',
+ 'Monday' => 'lundi',
+ 'Tuesday' => 'mardi',
+ 'Wednesday' => 'mercredi',
+ 'Thursday' => 'jeudi',
+ 'Friday' => 'vendredi',
+ 'Saturday' => 'samedi',
+ 'Sunday' => 'dimanche',
+ 'Jan' => 'jan',
+ 'Feb' => 'fév',
+ 'Mar' => 'mar',
+ 'Apr' => 'avr',
+ 'Jun' => 'juin',
+ 'Jul' => 'juil',
+ 'Aug' => 'août',
+ 'Sep' => 'sep',
+ 'Oct' => 'oct',
+ 'Nov' => 'nov',
+ 'Dec' => 'déc',
+ 'Mon' => 'lun',
+ 'Tue' => 'mar',
+ 'Wed' => 'mer',
+ 'Thu' => 'jeu',
+ 'Fri' => 'ven',
+ 'Sat' => 'sam',
+ 'Sun' => 'dim',
+ ];
+
+ static public function get_datetime($ts)
+ {
+ if (null === $ts) {
+ return null;
+ }
+
+ if (is_object($ts) && $ts instanceof \DateTimeInterface) {
+ return $ts;
+ }
+ elseif (is_numeric($ts)) {
+ $ts = new \DateTime('@' . $ts);
+ $ts->setTimezone(new \DateTimeZone(date_default_timezone_get()));
+ return $ts;
+ }
+ elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $ts)) {
+ return \DateTime::createFromFormat('!d/m/Y', $ts);
+ }
+ elseif (strlen($ts) == 10) {
+ return \DateTime::createFromFormat('!Y-m-d', $ts);
+ }
+ elseif (strlen($ts) == 19) {
+ return \DateTime::createFromFormat('Y-m-d H:i:s', $ts);
+ }
+ elseif (strlen($ts) == 16) {
+ return \DateTime::createFromFormat('!Y-m-d H:i', $ts);
+ }
+ else {
+ return null;
+ }
+ }
+
+ static public function strftime_fr($ts, $format)
+ {
+ $ts = self::get_datetime($ts);
+
+ if (null === $ts) {
+ return $ts;
+ }
+
+ $date = Translate::strftime($format, $ts, 'fr_FR');
+ return $date;
+ }
+
+ static public function date_fr($ts, $format = null)
+ {
+ $ts = self::get_datetime($ts);
+
+ if (null === $ts) {
+ return $ts;
+ }
+
+ if (is_null($format))
+ {
+ $format = 'd/m/Y à H:i';
+ }
+
+ $date = $ts->format($format);
+
+ $date = strtr($date, self::FRENCH_DATE_NAMES);
+ return $date;
+ }
+
+ static public function shortDate($ts, bool $with_hour = false): ?string
+ {
+ return self::date_fr($ts, 'd/m/Y' . ($with_hour ? ' à H\hi' : ''));
+ }
+
+ /**
+ * @deprecated
+ */
+ static public function checkDate($str)
+ {
+ if (!preg_match('!^(\d{4})-(\d{2})-(\d{2})$!', $str, $match))
+ return false;
+
+ if (!checkdate($match[2], $match[3], $match[1]))
+ return false;
+
+ return true;
+ }
+
+ /**
+ * @deprecated
+ */
+ static public function checkDateTime($str)
+ {
+ if (!preg_match('!^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})!', $str, $match))
+ return false;
+
+ if (!self::checkDate($match[1]))
+ return false;
+
+ if ((int) $match[2] < 0 || (int) $match[2] > 23)
+ return false;
+
+ if ((int) $match[3] < 0 || (int) $match[3] > 59)
+ return false;
+
+ if (isset($match[4]) && ((int) $match[4] < 0 || (int) $match[4] > 59))
+ return false;
+
+ return true;
+ }
+
+ static public function moneyToInteger($value)
+ {
+ if (null === $value || trim($value) === '') {
+ return 0;
+ }
+
+ if (!preg_match('/^(-?)(\d+)(?:[,.](\d{1,3}))?/', $value, $match)) {
+ throw new UserException(sprintf('Le montant est invalide : %s. Exemple de format accepté : 142,02', $value));
+ }
+
+ $cents = $match[3] ?? '0';
+
+ if (strlen($cents) === 1) {
+ $cents .= '0';
+ }
+
+ $more = (int) substr($cents, 2, 1);
+ $cents = (int) substr($cents, 0, 2);
+
+ if ($more >= 5) {
+ $cents++;
+ }
+
+ $value = $match[1] . $match[2] . str_pad((string)$cents, 2, '0', STR_PAD_LEFT);
+ $value = (int) $value;
+ return $value;
+ }
+
+ static public function weightToInteger($number): ?int
+ {
+ if (null === $number || '' === $number) {
+ return null;
+ }
+
+ if (-1 == $number) {
+ return -1;
+ }
+
+ $w = explode('.', str_replace(',', '.', $number));
+ $a = $w[0] ?: '0';
+ $b = substr(($w[1] ?? '0') . '000', 0, 3);
+ return intval($a . $b);
+ }
+
+ static public function format_weight($number, bool $empty_is_zero = false, bool $append_unit = false): string
+ {
+ if (empty($number) || $number < 0) {
+ return $empty_is_zero ? '0' : '';
+ }
+
+ $decimals = substr($number, -3);
+
+ if ((int)$decimals > 0) {
+ $decimals = ',' . substr('000' . $decimals, -3);
+ }
+ else {
+ $decimals = '';
+ }
+
+ $out = (int)substr($number, 0, -3) . $decimals;
+
+ if ($append_unit) {
+ $out .= ' kg';
+ }
+
+ return $out;
+ }
+
+ static public function money_format($number, ?string $dec_point = ',', string $thousands_sep = ' ', $zero_if_empty = true): string {
+ if ($number == 0) {
+ return $zero_if_empty ? '0' : '0,00';
+ }
+
+ $sign = $number < 0 ? '-' : '';
+
+ // Convert floats to string, and THEN to integer
+ // to avoid truncating numbers
+ // see https://fossil.kd2.org/paheko/tktview/a29df35328fdf783b98edb60f038f248c3af9b38
+ if (!is_int($number)) {
+ $number = (int)(string)$number;
+ }
+
+ $number = abs((int)(string) $number);
+
+ $decimals = substr('0' . $number, -2);
+ $number = (int) substr($number, 0, -2);
+
+ if ($dec_point === null) {
+ $decimals = null;
+ }
+
+ return sprintf('%s%s%s%s', $sign, number_format($number, 0, $dec_point, $thousands_sep), $dec_point, $decimals);
+ }
+
+ static public function getLocalURL(string $url = '', ?string $default_prefix = null): string
+ {
+ if (substr($url, 0, 1) == '!') {
+ return ADMIN_URL . substr($url, 1);
+ }
+ elseif (substr($url, 0, 7) == '/admin/') {
+ return ADMIN_URL . substr($url, 7);
+ }
+ elseif (substr($url, 0, 2) == './') {
+ $base = self::getSelfURI();
+ $base = preg_replace('!/[^/]*$!', '/', $base);
+ $base = trim($base, '/');
+ return '/' . $base . '/' . substr($url, 2);
+ }
+ elseif (substr($url, 0, 1) == '/' && ($pos = strpos($url, WWW_URI)) === 0) {
+ return WWW_URL . substr($url, strlen(WWW_URI));
+ }
+ elseif (substr($url, 0, 1) == '/') {
+ return WWW_URL . substr($url, 1);
+ }
+ elseif (substr($url, 0, 5) == 'http:' || substr($url, 0, 6) == 'https:') {
+ return $url;
+ }
+ elseif ($url == '') {
+ return ADMIN_URL;
+ }
+ else {
+ if (null !== $default_prefix) {
+ $default_prefix = self::getLocalURL($default_prefix);
+ }
+
+ return $default_prefix . $url;
+ }
+ }
+
+ static public function getRequestURI()
+ {
+ if (!empty($_SERVER['REQUEST_URI']))
+ return $_SERVER['REQUEST_URI'];
+ else
+ return false;
+ }
+
+ static public function getSelfURL($qs = true)
+ {
+ $uri = self::getSelfURI($qs);
+
+ // Make absolute URI relative to parent URI
+ if (0 === strpos($uri, WWW_URI . 'admin/')) {
+ $uri = substr($uri, strlen(WWW_URI . 'admin/'));
+ }
+
+ return ADMIN_URL . ltrim($uri, '/');
+ }
+
+ static public function getSelfURI($qs = true)
+ {
+ $uri = self::getRequestURI();
+
+ if ($qs !== true && (strpos($uri, '?') !== false))
+ {
+ $uri = substr($uri, 0, strpos($uri, '?'));
+ }
+
+ if (is_array($qs))
+ {
+ $uri .= '?' . http_build_query($qs);
+ }
+
+ return $uri;
+ }
+
+ static public function getModifiedURL(string $new)
+ {
+ return HTTP::mergeURLs(self::getSelfURI(), $new);
+ }
+
+ static public function redirectDialog(?string $destination = null, bool $exit = true): void
+ {
+ if (isset($_GET['_dialog'])) {
+ self::reloadParentFrame($destination, $exit);
+ }
+ else {
+ self::redirect($destination, $exit);
+ }
+ }
+
+ static public function reloadParentFrameIfDialog(?string $destination = null): void
+ {
+ if (!isset($_GET['_dialog'])) {
+ return;
+ }
+
+ self::reloadParentFrame($destination);
+ }
+
+ static public function reloadParentFrame(?string $destination = null, bool $exit = true): void
+ {
+ $url = self::getLocalURL($destination ?? '!');
+
+ echo '
+
+
+
+
+
+
+
+ Cliquer ici pour continuer
+
+ ';
+
+ if ($exit) {
+ exit;
+ }
+ }
+
+ public static function redirect(?string $destination = null, bool $exit = true)
+ {
+ $destination ??= '';
+ $destination = self::getLocalURL($destination);
+
+ if (isset($_GET['_dialog'])) {
+ $destination .= (strpos($destination, '?') === false ? '?' : '&') . '_dialog';
+
+ if (!empty($_GET['_dialog'])) {
+ $destination .= '=' . rawurlencode($_GET['_dialog']);
+ }
+ }
+
+ if (PHP_SAPI == 'cli') {
+ echo 'Please visit ' . $destination . PHP_EOL;
+ exit;
+ }
+
+ if (headers_sent())
+ {
+ echo
+ ''.
+ '
' .
+ ' ' .
+ ' '.
+ ' '.
+ ' '.
+ ' '.
+ '';
+
+ if ($exit)
+ exit();
+
+ return true;
+ }
+
+ header("Location: " . $destination);
+
+ if ($exit) {
+ exit;
+ }
+ }
+
+ static public function getIP()
+ {
+ if (!empty($_SERVER['REMOTE_ADDR']))
+ return $_SERVER['REMOTE_ADDR'];
+ return '';
+ }
+
+ static public function getCountryList()
+ {
+ return Translate::getCountriesList('fr');
+ }
+
+ static public function getCountryName(string $code): ?string
+ {
+ $code = strtoupper($code);
+ $list = self::getCountryList();
+ return $list[$code] ?? null;
+ }
+
+ static public function getCountryCode(string $find): ?string
+ {
+ if (!strlen($find)) {
+ return null;
+ }
+
+ $list = self::getCountryList();
+
+ foreach ($list as $code => $name) {
+ if (strnatcasecmp($find, $name) === 0) {
+ return $code;
+ }
+ }
+
+ return null;
+ }
+
+ static public function transliterateToAscii($str, $charset='UTF-8')
+ {
+ // Don't process empty strings
+ if (!trim($str))
+ return $str;
+
+ // We only process non-ascii strings
+ if (preg_match('!^[[:ascii:]]+$!', $str))
+ return $str;
+
+ $str = htmlentities($str, ENT_NOQUOTES, $charset);
+
+ $str = preg_replace('#&([A-za-z])(?:acute|cedil|circ|grave|orn|ring|slash|th|tilde|uml);#', '\1', $str);
+ $str = preg_replace('#&([A-za-z]{2})(?:lig);#', '\1', $str); // pour les ligatures e.g. 'œ'
+
+ $str = preg_replace('#&[^;]+;#', '', $str); // supprime les autres caractères
+ $str = preg_replace('![^[:ascii:]]+!', '', $str);
+
+ return $str;
+ }
+
+ /**
+ * Transforme les tags HTML basiques en tags SkrivML
+ * @param string $str Texte d'entrée
+ * @return string Texte transformé
+ */
+ static public function HTMLToSkriv($str)
+ {
+ $str = preg_replace('/(\V*?)<\/h3>/', '=== $1 ===', $str);
+ $str = preg_replace('/(\V*)<\/b>/', '**$1**', $str);
+ $str = preg_replace('/(\V*?)<\/strong>/', '**$1**', $str);
+ $str = preg_replace('/(\V*?)<\/i>/', '\'\'$1\'\'', $str);
+ $str = preg_replace('/(\V*?)<\/em>/', '\'\'$1\'\'', $str);
+ $str = preg_replace('/(\V*?)<\/li>/', '* $1', $str);
+ $str = preg_replace('/|<\/ul>/', '', $str);
+ $str = preg_replace('/(\V*?)<\/a>/', '[[$2 | $1]]', $str);
+ return $str;
+ }
+
+ static public function safe_unlink(string $path): bool
+ {
+ if (!@unlink($path))
+ {
+ return true;
+ }
+
+ if (!file_exists($path))
+ {
+ return true;
+ }
+
+ throw new \RuntimeException(sprintf('Impossible de supprimer le fichier %s: %s', $path, error_get_last()));
+
+ return true;
+ }
+
+ static public function safe_mkdir($path, $mode = null, $recursive = false)
+ {
+ if (null === $mode && file_exists(DATA_ROOT)) {
+ $mode = fileperms(DATA_ROOT);
+ }
+ elseif (null === $mode && file_exists(ROOT)) {
+ $mode = fileperms(ROOT);
+ }
+
+ return @mkdir($path, $mode, $recursive) || is_dir($path);
+ }
+
+ /**
+ * Does a recursive list using glob(), this is faster than using Recursive iterators
+ * @param string $path Target path
+ * @param string $pattern Pattern
+ * @param int $flags glob() Flags
+ * @return array
+ */
+ static public function recursiveGlob(string $path, string $pattern = '*', int $flags = 0): array
+ {
+ $target = $path . DIRECTORY_SEPARATOR . $pattern;
+ $list = [];
+
+ // glob is the fastest way to recursely list directories and files apparently
+ // after comparing with opendir(), dir() and filesystem recursive iterators
+ foreach(glob($target, $flags) as $file) {
+ $file = basename($file);
+
+ if ($file[0] == '.') {
+ continue;
+ }
+
+ $list[] = $file;
+
+ if (is_dir($path . DIRECTORY_SEPARATOR . $file)) {
+ foreach (self::recursiveGlob($path . DIRECTORY_SEPARATOR . $file, $pattern, $flags) as $subfile) {
+ $list[] = $file . DIRECTORY_SEPARATOR . $subfile;
+ }
+ }
+ }
+
+ return $list;
+ }
+
+ static public function suggestPassword()
+ {
+ return Security::getRandomPassphrase(ROOT . '/include/data/dictionary.fr');
+ }
+
+ static public function normalizePhoneNumber($n)
+ {
+ return preg_replace('![^\d\+\(\)p#,;-]!', '', trim($n));
+ }
+
+ static public function write_ini_string($in)
+ {
+ $out = '';
+ $get_ini_line = function ($key, $value) use (&$get_ini_line)
+ {
+ if (is_bool($value))
+ {
+ return $key . ' = ' . ($value ? 'true' : 'false');
+ }
+ elseif (is_numeric($value))
+ {
+ return $key . ' = ' . $value;
+ }
+ elseif (is_array($value) || is_object($value))
+ {
+ $out = '';
+ $value = (array) $value;
+ foreach ($value as $row)
+ {
+ $out .= $get_ini_line($key . '[]', $row) . "\n";
+ }
+
+ return substr($out, 0, -1);
+ }
+ else
+ {
+ return $key . ' = "' . str_replace('"', '\\"', $value) . '"';
+ }
+ };
+
+ foreach ($in as $key=>$value)
+ {
+ if ((is_array($value) || is_object($value)) && is_string($key))
+ {
+ $out .= '[' . $key . "]\n";
+
+ foreach ($value as $row_key=>$row_value)
+ {
+ $out .= $get_ini_line($row_key, $row_value) . "\n";
+ }
+
+ $out .= "\n";
+ }
+ else
+ {
+ $out .= $get_ini_line($key, $value) . "\n";
+ }
+ }
+
+ return $out;
+ }
+
+ static public function getMaxUploadSize()
+ {
+ $limits = [
+ self::return_bytes(ini_get('upload_max_filesize')),
+ self::return_bytes(ini_get('post_max_size'))
+ ];
+
+ return min(array_filter($limits));
+ }
+
+
+ static public function return_bytes($size_str)
+ {
+ if ($size_str == '-1')
+ {
+ return false;
+ }
+
+ if (PHP_VERSION_ID >= 80200) {
+ return ini_parse_quantity($size_str);
+ }
+
+ switch (substr($size_str, -1))
+ {
+ case 'G': case 'g': return (int)$size_str * pow(1024, 3);
+ case 'M': case 'm': return (int)$size_str * pow(1024, 2);
+ case 'K': case 'k': return (int)$size_str * 1024;
+ default: return $size_str;
+ }
+ }
+
+ static public function format_bytes($size, bool $bytes = false)
+ {
+ if ($size > (1024 * 1024 * 1024 * 1024)) {
+ $size = $size / 1024 / 1024 / 1024 / 1024;
+
+ if ($size < 10) {
+ $decimals = $size == (int) $size ? 0 : 1;
+ return number_format(round($size, 1), $decimals, ',', '') . ' To';
+ }
+ else {
+ return round($size) . ' To';
+ }
+ }
+ elseif ($size > (1024 * 1024 * 1024)) {
+ $size = $size / 1024 / 1024 / 1024;
+
+ if ($size < 10) {
+ $decimals = $size == (int) $size ? 0 : 1;
+ return number_format(round($size, 1), $decimals, ',', '') . ' Go';
+ }
+ else {
+ return ceil($size) . ' Go';
+ }
+ }
+ elseif ($size > (1024 * 1024)) {
+ $size = $size / 1024 / 1024;
+ $decimals = $size == (int) $size ? 0 : 2;
+ return ceil($size) . ' Mo';
+ }
+ elseif ($size > 1024) {
+ return ceil($size / 1024) . ' Ko';
+ }
+ elseif ($bytes) {
+ return $size . ' octets';
+ }
+ elseif (!$size) {
+ return '0 o';
+ }
+ else {
+ return '< 1 Ko';
+ }
+ }
+
+ static public function createEmptyDirectory(string $path)
+ {
+ Utils::safe_mkdir($path, 0777, true);
+
+ if (!is_dir($path))
+ {
+ throw new UserException('Le répertoire '.$path.' n\'existe pas ou n\'est pas un répertoire.');
+ }
+
+ // On en profite pour vérifier qu'on peut y lire et écrire
+ if (!is_writable($path) || !is_readable($path))
+ {
+ throw new UserException('Le répertoire '.$path.' n\'est pas accessible en lecture/écriture.');
+ }
+
+ // Some basic safety against misconfigured hosts
+ file_put_contents($path . '/index.html', '404 Not Found Not Found The requested URL was not found on this server.
');
+ }
+
+ static public function resetCache(string $path): void
+ {
+ if (!file_exists($path)) {
+ self::createEmptyDirectory($path);
+ return;
+ }
+
+ $dir = dir($path);
+
+ while ($file = $dir->read()) {
+ if (substr($file, 0, 1) == '.' || is_dir($path . DIRECTORY_SEPARATOR . $file)) {
+ continue;
+ }
+
+ self::safe_unlink($path . DIRECTORY_SEPARATOR . $file);
+ }
+
+ $dir->close();
+ }
+
+ static public function deleteRecursive(string $path, bool $delete_self = false): bool
+ {
+ if (!file_exists($path)) {
+ return false;
+ }
+
+ if (is_file($path)) {
+ return self::safe_unlink($path);
+ }
+
+ $dir = dir($path);
+ if (!$dir) return false;
+
+ while ($file = $dir->read())
+ {
+ if ($file == '.' || $file == '..')
+ continue;
+
+ if (is_dir($path . DIRECTORY_SEPARATOR . $file))
+ {
+ if (!self::deleteRecursive($path . DIRECTORY_SEPARATOR . $file, true))
+ return false;
+ }
+ else
+ {
+ self::safe_unlink($path . DIRECTORY_SEPARATOR . $file);
+ }
+ }
+
+ $dir->close();
+
+ if ($delete_self) {
+ rmdir($path);
+ }
+
+ return true;
+ }
+
+ static public function plugin_url($params = [])
+ {
+ if (isset($params['id']))
+ {
+ $url = ADMIN_URL . 'p/' . $params['id'] . '/';
+ }
+ elseif (defined('Paheko\PLUGIN_ADMIN_URL'))
+ {
+ $url = PLUGIN_ADMIN_URL;
+ }
+ else {
+ throw new \RuntimeException('Missing plugin URL');
+ }
+
+ if (!empty($params['file']))
+ $url .= $params['file'];
+
+ if (!empty($params['query']))
+ {
+ $url .= '?';
+
+ if (!(is_numeric($params['query']) && (int)$params['query'] === 1) && $params['query'] !== true)
+ $url .= $params['query'];
+ }
+
+ return $url;
+ }
+
+ static public function iconUnicode(string $shape): string
+ {
+ if (!isset(self::ICONS[$shape])) {
+ throw new \UnexpectedValueException('Unknown icon shape: ' . $shape);
+ }
+
+ return self::ICONS[$shape];
+ }
+
+ static public function array_transpose(?array $array): array
+ {
+ $out = [];
+
+ if (!$array) {
+ return $out;
+ }
+
+ $max = 0;
+
+ foreach ($array as $rows) {
+ if (!is_array($rows)) {
+ throw new \UnexpectedValueException('Invalid multi-dimensional array: not an array: ' . gettype($rows));
+ }
+
+ $max = max($max, count($rows));
+ }
+
+ foreach ($array as $column => $rows) {
+ // Match number of rows of largest sub-array, in case there is a missing row in a column
+ if ($max != count($rows)) {
+ $rows = array_merge($rows, array_fill(0, $max - count($rows), null));
+ }
+
+ foreach ($rows as $k => $v) {
+ if (!isset($out[$k])) {
+ $out[$k] = [];
+ }
+
+ $out[$k][$column] = $v;
+ }
+ }
+
+ return $out;
+ }
+
+ static public function rgbHexToDec(string $hex)
+ {
+ return sscanf($hex, '#%02x%02x%02x');
+ }
+
+ /**
+ * Converts an RGB color value to HSV. Conversion formula
+ * adapted from http://en.wikipedia.org/wiki/HSV_color_space.
+ * Assumes r, g, and b are contained in the set [0, 255] and
+ * returns h, s, and v in the set [0, 1].
+ *
+ * @param Number r The red color value
+ * @param Number g The green color value
+ * @param Number b The blue color value
+ * @return Array The HSV representation
+ */
+ static public function rgbToHsv($r, $g = null, $b = null)
+ {
+ if (is_string($r) && is_null($g) && is_null($b))
+ {
+ list($r, $g, $b) = self::rgbHexToDec($r);
+ }
+
+ $r /= 255;
+ $g /= 255;
+ $b /= 255;
+ $max = max($r, $g, $b);
+ $min = min($r, $g, $b);
+ $h = $s = $v = $max;
+
+ $d = $max - $min;
+ //$s = ($max == 0) ? 0 : $d / $max;
+ $l = ($max + $min) / 2;
+ $s = $l > 0.5 ? $d / ((2 - $max - $min) ?: 1) : $d / (($max + $min) ?: 1);
+
+ if($max == $min)
+ {
+ $h = 0; // achromatic
+ }
+ else
+ {
+ switch($max)
+ {
+ case $r: $h = ($g - $b) / $d + ($g < $b ? 6 : 0); break;
+ case $g: $h = ($b - $r) / $d + 2; break;
+ case $b: $h = ($r - $g) / $d + 4; break;
+ }
+ $h /= 6;
+ }
+
+ return array($h * 360, $s, $l);
+ }
+
+ static public function HTTPCache(?string $hash, ?int $last_change, int $max_age = 3600): bool
+ {
+ $etag = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH'], '"\' ') : null;
+ $last_modified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : null;
+
+ $etag = $etag ? str_replace('-gzip', '', $etag) : null;
+
+ header(sprintf('Cache-Control: private, max-age=%d', $max_age), true);
+ header_remove('Expires');
+
+ if ($last_change) {
+ header(sprintf('Last-Modified: %s GMT', gmdate('D, d M Y H:i:s', $last_change)), true);
+ }
+
+ if ($hash) {
+ $hash = md5(Utils::getVersionHash() . $hash);
+ header(sprintf('Etag: "%s"', $hash), true);
+ }
+
+ if (($etag && $etag === $hash) || ($last_modified && $last_modified >= $last_change)) {
+ http_response_code(304);
+ exit;
+ }
+
+ return false;
+ }
+
+ static public function transformTitleToURI($str)
+ {
+ $str = Utils::transliterateToAscii($str);
+
+ $str = preg_replace('![^\w\d_-]!i', '-', $str);
+ $str = preg_replace('!-{2,}!', '-', $str);
+ $str = trim($str, '-');
+
+ return $str;
+ }
+
+ static public function safeFileName(string $str): string
+ {
+ $str = Utils::transliterateToAscii($str);
+ $str = preg_replace('![^\w\d_ -]!i', '.', $str);
+ $str = preg_replace('!\.{2,}!', '.', $str);
+ $str = trim($str, '.');
+ return $str;
+ }
+
+ /**
+ * dirname may have undefined behaviour depending on the locale!
+ */
+ static public function dirname(string $str): string
+ {
+ $str = str_replace(DIRECTORY_SEPARATOR, '/', $str);
+ return substr($str, 0, strrpos($str, '/'));
+ }
+
+ /**
+ * basename may have undefined behaviour depending on the locale!
+ */
+ static public function basename(string $str): string
+ {
+ $str = str_replace(DIRECTORY_SEPARATOR, '/', $str);
+ $str = trim($str, '/');
+ $str = substr($str, strrpos($str, '/'));
+ $str = trim($str, '/');
+ return $str;
+ }
+
+ static public function unicodeTransliterate($str): ?string
+ {
+ if ($str === null) {
+ return null;
+ }
+
+ $str = str_replace('’', '\'', $str); // Normalize French apostrophe
+
+ return transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $str);
+ }
+
+ static public function unicodeCaseComparison($a, $b): int
+ {
+ if (!isset(self::$collator) && function_exists('collator_create')) {
+ self::$collator = \Collator::create('fr_FR');
+
+ // This is what makes the comparison case insensitive
+ // https://www.php.net/manual/en/collator.setstrength.php
+ self::$collator->setAttribute(\Collator::STRENGTH, \Collator::PRIMARY);
+
+ // Don't use \Collator::NUMERIC_COLLATION here as it goes against what would feel logic
+ // for account ordering
+ // with NUMERIC_COLLATION: 1, 2, 10, 11, 101
+ // without: 1, 10, 101, 11, 2
+ }
+
+ // Make sure we have UTF-8
+ // If we don't, we may end up with malformed database, eg. "row X missing from index" errors
+ // when doing an integrity check
+ $a = self::utf8_encode($a);
+ $b = self::utf8_encode($b);
+
+ if (isset(self::$collator)) {
+ return (int) self::$collator->compare($a, $b);
+ }
+
+ $a = strtoupper(self::transliterateToAscii($a));
+ $b = strtoupper(self::transliterateToAscii($b));
+
+ return strcmp($a, $b);
+ }
+
+ static public function utf8_encode(?string $str): ?string
+ {
+ if (null === $str) {
+ return null;
+ }
+
+ // Check if string is already UTF-8 encoded or not
+ if (preg_match('//u', $str)) {
+ return $str;
+ }
+
+ return !preg_match('//u', $str) ? self::iso8859_1_to_utf8($str) : $str;
+ }
+
+ /**
+ * Poly-fill to encode a ISO-8859-1 string to UTF-8 for PHP >= 9.0
+ * @see https://php.watch/versions/8.2/utf8_encode-utf8_decode-deprecated
+ */
+ static public function iso8859_1_to_utf8(string $s): string
+ {
+ if (PHP_VERSION_ID < 90000) {
+ return @utf8_encode($s);
+ }
+
+ $s .= $s;
+ $len = strlen($s);
+
+ for ($i = $len >> 1, $j = 0; $i < $len; ++$i, ++$j) {
+ switch (true) {
+ case $s[$i] < "\x80":
+ $s[$j] = $s[$i];
+ break;
+ case $s[$i] < "\xC0":
+ $s[$j] = "\xC2";
+ $s[++$j] = $s[$i];
+ break;
+ default:
+ $s[$j] = "\xC3";
+ $s[++$j] = chr(ord($s[$i]) - 64);
+ break;
+ }
+ }
+
+ return substr($s, 0, $j);
+ }
+
+ /**
+ * Transforms a unicode string to lowercase AND removes all diacritics
+ *
+ * @see https://www.matthecat.com/supprimer-les-accents-d-une-chaine-avec-php.html
+ */
+ static public function unicodeCaseFold(?string $str): string
+ {
+ if (null === $str || trim($str) === '') {
+ return '';
+ }
+
+ $str = str_replace('’', '\'', $str); // Normalize French apostrophe
+
+ if (!isset(self::$transliterator) && function_exists('transliterator_create')) {
+ self::$transliterator = \Transliterator::create('Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; Lower();');
+ }
+
+ if (isset(self::$transliterator)) {
+ return self::$transliterator->transliterate($str);
+ }
+
+ return strtoupper(self::transliterateToAscii($str));
+ }
+
+ static public function knatcasesort(array $array)
+ {
+ uksort($array, [self::class, 'unicodeCaseComparison']);
+ return $array;
+ }
+
+ static public function appendCookieToURLs(string $str): string
+ {
+ $cookie = Session::getCookie();
+ $secret = Session::getCookieSecret();
+
+ if (!$cookie) {
+ return $str;
+ }
+
+ // Append session cookie to URLs, so that tags and others work
+ $r = preg_quote(WWW_URL, '!');
+ $r = '!(?<=["\'])((?:/|' . $r . ').*?)(?=["\'])!';
+ $str = preg_replace_callback($r, function ($match) use ($cookie, $secret): string {
+ if (false !== strpos($match[1], '?')) {
+ $separator = '&';
+ }
+ else {
+ $separator = '?';
+ }
+
+ if (substr($match[1], 0, 1) === '/') {
+ $url = BASE_URL . ltrim($match[1], '/');
+ }
+ else {
+ $url = $match[1];
+ }
+
+ return $url . $separator . $cookie . htmlspecialchars($secret);
+ }, $str);
+
+ return $str;
+ }
+
+ /**
+ * Escape a command-line argument, because escapeshellarg is stripping UTF-8 characters (d'oh)
+ * @see https://markushedlund.com/dev/php-escapeshellarg-with-unicodeutf-8-support/
+ */
+ static public function escapeshellarg(string $arg): string
+ {
+ if (PHP_OS_FAMILY === 'Windows') {
+ return '"' . str_replace(array('"', '%'), array('', ''), $arg) . '"';
+ }
+ else {
+ return "'" . str_replace("'", "'\\''", $arg) . "'";
+ }
+ }
+
+ /**
+ * Execute a system command with a timeout
+ * @see https://blog.dubbelboer.com/2012/08/24/execute-with-timeout.html
+ */
+ static public function exec(string $cmd, int $timeout, ?callable $stdin, ?callable $stdout, ?callable $stderr = null): int
+ {
+ if (!function_exists('proc_open') || !function_exists('proc_terminate')
+ || preg_match('/proc_(?:open|terminate|get_status|close)/', ini_get('disable_functions'))) {
+ throw new \RuntimeException('Execution of system commands is disabled.');
+ }
+
+ $descriptorspec = [
+ 0 => ["pipe", "r"], // stdin is a pipe that the child will read from
+ 1 => ["pipe", "w"], // stdout is a pipe that the child will write to
+ 2 => ['pipe', 'w'], // stderr
+ ];
+
+ $process = proc_open($cmd, $descriptorspec, $pipes);
+
+ if (!is_resource($process)) {
+ throw new \RuntimeException('Cannot execute command: ' . $cmd);
+ }
+
+ // $pipes now looks like this:
+ // 0 => writeable handle connected to child stdin
+ // 1 => readable handle connected to child stdout
+
+ // Set to non-blocking
+ stream_set_blocking($pipes[0], false);
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+
+ $timeout_ms = $timeout * 1000000; // in microseconds
+
+ if (null !== $stdin) {
+ // Send STDIN
+ fwrite($pipes[0], $stdin());
+ }
+
+ fclose($pipes[0]);
+ $code = 0;
+
+ while ($timeout_ms > 0) {
+ $start = microtime(true);
+
+ // Wait until we have output or the timer expired.
+ $read = [$pipes[1]];
+ $other = [];
+
+ if (null !== $stderr) {
+ $read[] = $pipes[2];
+ }
+
+ // Wait every 0.5 seconds
+ stream_select($read, $other, $other, 0, 500000);
+
+ // Get the status of the process.
+ // Do this before we read from the stream,
+ // this way we can't lose the last bit of output if the process dies between these functions.
+ $status = proc_get_status($process);
+
+ // We must get the exit code when it is sent, or we won't be able to get it later
+ if ($status['exitcode'] > -1) {
+ $code = $status['exitcode'];
+ }
+
+ // Read the contents from the buffer.
+ // This function will always return immediately as the stream is non-blocking.
+ if (null !== $stdout) {
+ $stdout(stream_get_contents($pipes[1]));
+ }
+
+ if (null !== $stderr) {
+ $stderr(stream_get_contents($pipes[2]));
+ }
+
+ if (!$status['running']) {
+ // Break from this loop if the process exited before the timeout.
+ break;
+ }
+
+ // Subtract the number of microseconds that we waited.
+ $timeout_ms -= (microtime(true) - $start) * 1000000;
+ }
+
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+
+ if ($status['running']) {
+ proc_terminate($process, 9);
+ throw new \OverflowException(sprintf("Command killed after taking more than %d seconds: \n%s", $timeout, $cmd));
+ }
+
+ proc_close($process);
+
+ return $code;
+ }
+
+ /**
+ * Displays a PDF from a string, only works when PDF_COMMAND constant is set to "prince"
+ * @param string $str HTML string
+ * @return void
+ */
+ static public function streamPDF(string $str): void
+ {
+ if (!PDF_COMMAND) {
+ throw new \LogicException('PDF generation is disabled');
+ }
+
+ $str = self::appendCookieToURLs($str);
+
+ if (PDF_COMMAND == 'auto') {
+ // Try to see if there's a plugin
+ $in = ['string' => $str];
+
+ $signal = Plugins::fire('pdf.stream', true, $in);
+
+ if ($signal && $signal->isStopped()) {
+ return;
+ }
+
+ unset($signal, $in);
+ }
+
+ // Only Prince handles using STDIN and STDOUT
+ if (PDF_COMMAND != 'prince') {
+ $file = self::filePDF($str);
+ readfile($file);
+ unlink($file);
+ return;
+ }
+
+ // 3 seconds is plenty enough to fetch resources, right?
+ $cmd = 'prince --http-timeout=3 --pdf-profile="PDF/A-3b" -o - -';
+
+ // Prince is fast, right? Fingers crossed
+ self::exec($cmd, 10, fn () => $str, fn ($data) => print($data));
+
+ if (PDF_USAGE_LOG) {
+ file_put_contents(PDF_USAGE_LOG, date("Y-m-d H:i:s\n"), FILE_APPEND);
+ }
+ }
+
+ /**
+ * Creates a PDF file from a HTML string
+ * @param string $str HTML string
+ * @return string File path of the PDF file (temporary), you must delete or move it
+ */
+ static public function filePDF(string $str): ?string
+ {
+ $cmd = PDF_COMMAND;
+
+ if (!$cmd) {
+ throw new \LogicException('PDF generation is disabled');
+ }
+
+ $source = sprintf('%s/print-%s.html', CACHE_ROOT, md5(random_bytes(16)));
+ $target = str_replace('.html', '.pdf', $source);
+
+ $str = self::appendCookieToURLs($str);
+
+ Utils::safe_mkdir(CACHE_ROOT, null, true);
+ file_put_contents($source, $str);
+
+ if ($cmd == 'auto') {
+ // Try to see if there's a plugin
+ $in = ['source' => $source, 'target' => $target];
+
+ $signal = Plugins::fire('pdf.create', true, $in);
+
+ if ($signal && $signal->isStopped()) {
+ Utils::safe_unlink($source);
+ return $target;
+ }
+
+ unset($in, $signal);
+
+ // Try to find a local executable
+ $list = ['prince', 'chromium', 'wkhtmltopdf', 'weasyprint'];
+ $cmd = null;
+
+ foreach ($list as $program) {
+ if (shell_exec('which ' . $program)) {
+ $cmd = $program;
+ break;
+ }
+ }
+
+ // We still haven't found anything
+ if (!$cmd) {
+ throw new \LogicException('Aucun programme de création de PDF trouvé, merci d\'en installer un : https://fossil.kd2.org/paheko/wiki?name=Configuration');
+ }
+ }
+
+ $timeout = 25;
+
+ switch ($cmd) {
+ case 'prince':
+ $timeout = 10;
+ $cmd = 'prince --http-timeout=3 --pdf-profile="PDF/A-3b" -o %2$s %1$s';
+ break;
+ case 'chromium':
+ $cmd = 'chromium --headless --timeout=5000 --disable-gpu --run-all-compositor-stages-before-draw --print-to-pdf-no-header --print-to-pdf=%2$s %1$s';
+ break;
+ case 'wkhtmltopdf':
+ $cmd = 'wkhtmltopdf -q --print-media-type --enable-local-file-access --disable-smart-shrinking --encoding "UTF-8" %s %s';
+ break;
+ case 'weasyprint':
+ $timeout = 60;
+ $cmd = 'weasyprint %1$s %2$s';
+ break;
+ default:
+ break;
+ }
+
+ $cmd = sprintf($cmd, self::escapeshellarg($source), self::escapeshellarg($target));
+ $cmd .= ' 2>&1';
+
+ $output = '';
+
+ try {
+ self::exec($cmd, $timeout, null, function($data) use (&$output) { $output .= $data; });
+ }
+ finally {
+ Utils::safe_unlink($source);
+ }
+
+ if (!file_exists($target)) {
+ throw new \RuntimeException('PDF command failed: ' . $output);
+ }
+
+ if (PDF_USAGE_LOG) {
+ file_put_contents(PDF_USAGE_LOG, date("Y-m-d H:i:s\n"), FILE_APPEND);
+ }
+
+ return $target;
+ }
+
+ /**
+ * Integer to A-Z, AA-ZZ, AAA-ZZZ, etc.
+ * @see https://www.php.net/manual/fr/function.base-convert.php#94874
+ */
+ static public function num2alpha(int $n): string {
+ $r = '';
+ for ($i = 1; $n >= 0 && $i < 10; $i++) {
+ $r = chr(0x41 + intval($n % pow(26, $i) / pow(26, $i - 1))) . $r;
+ $n -= pow(26, $i);
+ }
+ return $r;
+ }
+
+ static public function uuid(): string
+ {
+ $uuid = bin2hex(random_bytes(16));
+
+ return sprintf('%08s-%04s-4%03s-%04x-%012s',
+ // 32 bits for "time_low"
+ substr($uuid, 0, 8),
+ // 16 bits for "time_mid"
+ substr($uuid, 8, 4),
+ // 16 bits for "time_hi_and_version",
+ // four most significant bits holds version number 4
+ substr($uuid, 13, 3),
+ // 16 bits:
+ // * 8 bits for "clk_seq_hi_res",
+ // * 8 bits for "clk_seq_low",
+ // two most significant bits holds zero and one for variant DCE1.1
+ hexdec(substr($uuid, 16, 4)) & 0x3fff | 0x8000,
+ // 48 bits for "node"
+ substr($uuid, 20, 12)
+ );
+ }
+
+ /**
+ * Hash de la version pour les éléments statiques (cache)
+ *
+ * On ne peut pas utiliser la version directement comme query string
+ * pour les éléments statiques (genre /admin/static/admin.css?v0.9.0)
+ * car cela dévoilerait la version de Paheko utilisée, posant un souci
+ * en cas de faille, on cache donc la version utilisée, chaque instance
+ * aura sa propre version
+ */
+ static public function getVersionHash(): string
+ {
+ return substr(sha1(paheko_version() . paheko_manifest() . ROOT . SECRET_KEY), 0, 10);
+ }
+
+ /**
+ * Génération pagination à partir de la page courante ($current),
+ * du nombre d'items total ($total), et du nombre d'items par page ($bypage).
+ * $listLength représente la longueur d'items de la pagination à génerer
+ *
+ * @param int $current
+ * @param int $total
+ * @param int $bypage
+ * @param int $listLength
+ * @param bool $showLast Toggle l'affichage du dernier élément de la pagination
+ * @return array|null
+ */
+ static public function getGenericPagination($current, $total, $bypage, $listLength = 11, $showLast = true)
+ {
+ if ($total <= $bypage)
+ return null;
+
+ $total = ceil($total / $bypage);
+
+ if ($total < $current)
+ return null;
+
+ $length = ($listLength / 2);
+
+ $begin = $current - ceil($length);
+ if ($begin < 1)
+ {
+ $begin = 1;
+ }
+
+ $end = $begin + $listLength;
+ if($end > $total)
+ {
+ $begin -= ($end - $total);
+ $end = $total;
+ }
+ if ($begin < 1)
+ {
+ $begin = 1;
+ }
+ if($end==($total-1)) {
+ $end = $total;
+ }
+ if($begin == 2) {
+ $begin = 1;
+ }
+ $out = [];
+
+ if ($current > 1) {
+ $out[] = ['id' => $current - 1, 'label' => '« ' . 'Page précédente', 'class' => 'prev', 'accesskey' => 'a'];
+ }
+
+ if ($begin > 1) {
+ $out[] = ['id' => 1, 'label' => '1 ...', 'class' => 'first'];
+ }
+
+ for ($i = $begin; $i <= $end; $i++)
+ {
+ $out[] = ['id' => $i, 'label' => $i, 'class' => ($i == $current) ? 'current' : ''];
+ }
+
+ if ($showLast && $end < $total) {
+ $out[] = ['id' => $total, 'label' => '... ' . $total, 'class' => 'last'];
+ }
+
+ if ($current < $total) {
+ $out[] = ['id' => $current + 1, 'label' => 'Page suivante' . ' »', 'class' => 'next', 'accesskey' => 'z'];
+ }
+
+ return $out;
+ }
+
+ static public function parse_ini_file(string $path, bool $sections = false)
+ {
+ return self::parse_ini_string(file_get_contents($path), $sections);
+ }
+
+ /**
+ * Safe alternative to parse_ini_string without constant/variable expansion
+ * but still type values, like INI_SCANNER_TYPED
+ */
+ static public function parse_ini_string(string $ini, bool $sections = false)
+ {
+ try {
+ $ini = parse_ini_string($ini, $sections, \INI_SCANNER_RAW);
+ }
+ catch (\Throwable $e) {
+ throw new \RuntimeException($e->getMessage(), 0, $e);
+ }
+
+ return self::_resolve_ini_types($ini);
+ }
+
+ static protected function _resolve_ini_types(array $ini)
+ {
+ foreach ($ini as $key => &$value) {
+ if (is_array($value)) {
+ $value = self::_resolve_ini_types($value);
+ }
+ elseif ($value === 'FALSE' || $value === 'false' || $value === 'off' || $value === 'no' || $value === 'none') {
+ $value = false;
+ }
+ elseif ($value === 'TRUE' || $value === 'true' || $value === 'on' || $value === 'yes') {
+ $value = true;
+ }
+ elseif ($value === 'NULL' || $value === 'null') {
+ $value = null;
+ }
+ elseif (ctype_digit($value)) {
+ $value = (int)$value;
+ }
+ else {
+ $value = str_replace('\n', "\n", $value);
+ }
+ }
+
+ unset($value);
+
+ return $ini;
+ }
+}
diff --git a/src/include/lib/Paheko/Web/Cache.php b/src/include/lib/Paheko/Web/Cache.php
new file mode 100644
index 0000000..3000bfd
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Cache.php
@@ -0,0 +1,180 @@
+ $host,
+ '%host.2%' => substr($host, 0, 2),
+ ]);
+
+ return $path;
+ }
+
+ static public function getPath(string $uri, bool $append_extension = true): string
+ {
+ $uri = rawurldecode($uri);
+ $uri = '/' . ltrim($uri, '/');
+
+ $target = self::$root . '/' . md5($uri);
+
+ if ($append_extension) {
+ $ext = self::getFileExtension($uri) ?? '.html';
+ $target .= $ext;
+ }
+
+ return $target;
+ }
+
+ static public function getFileExtension(string $name): ?string
+ {
+ if (preg_match('/\.[a-z0-9]{1,10}$/', $name, $match)) {
+ return $match[0];
+ }
+
+ return null;
+ }
+
+ static public function clear(): void
+ {
+ if (!self::init()) {
+ return;
+ }
+
+ Utils::deleteRecursive(self::$root, false);
+ }
+
+ static public function delete(string $uri): void
+ {
+ if (!self::init()) {
+ return;
+ }
+
+ $uri = rawurldecode($uri);
+ $uri = '/' . ltrim($uri, '/');
+
+ $target = self::getPath($uri, false);
+
+ foreach (glob($target . '*') as $file) {
+ Utils::safe_unlink($file);
+ }
+ }
+
+ static public function init(): bool
+ {
+ if (!WEB_CACHE_ROOT) {
+ return false;
+ }
+
+ // Symlinks on Windows… Not sure if that works
+ if (PHP_OS_FAMILY == 'Windows') {
+ return false;
+ }
+
+ // Only Apache is supported, no need to create useless cache files with other servers
+ if (false === strpos($_SERVER['SERVER_SOFTWARE'] ?? '', 'Apache')) {
+ return false;
+ }
+
+ if (isset(self::$root)) {
+ return true;
+ }
+
+ self::$root = rtrim(self::getRoot(), '/');
+
+ if (!file_exists(self::$root)) {
+ Utils::safe_mkdir(self::$root, 0777, true);
+ }
+
+ $cache_root = Utils::dirname(WEB_CACHE_ROOT);
+
+ // Create symlink for self-hosting with .htaccess
+ if (!file_exists(ROOT . '/www/.cache') && file_exists($cache_root) && is_writable(ROOT . '/www')) {
+ @symlink($cache_root, ROOT . '/www/.cache');
+ }
+
+ return true;
+ }
+
+ static public function link(string $uri, string $destination): void
+ {
+ if (!self::init()) {
+ return;
+ }
+
+ $target = self::getPath($uri);
+
+ @unlink($target);
+ @symlink($destination, $target);
+ }
+
+ static public function store(string $uri, string $html): void
+ {
+ // Do not store if the page content might be influenced by either POST, query string, or logged-in user
+ if (!isset($_GET['__reload']) && ($_SERVER['REQUEST_METHOD'] != 'GET' || !empty($_SERVER['QUERY_STRING']) || isset($_COOKIE['pko']))) {
+ return;
+ }
+
+ if (!self::init()) {
+ return;
+ }
+
+ $ext = self::getFileExtension($uri);
+ $is_html = false !== stripos($html, '
+ document.addEventListener(\'DOMContentLoaded\', () => {
+ var now = +(new Date) / 1000;
+ if (now < %d || location.hash) {
+ return;
+ }
+
+ fetch(location.href + \'?__reload\').then(r => r.text()).then(r => {
+ var x = window.pageX, y = window.pageY;
+ document.open();
+ document.write(r);
+ document.close();
+ window.scrollTo(x, y);
+ });
+ });
+
+ path = $path;
+
+ if ($path) {
+ $this->context = strtok($path, '/');
+ strtok('');
+
+ if ($this->context === File::CONTEXT_WEB) {
+ $this->parent = $path;
+ $this->uri = Utils::basename($path);
+ $this->link_prefix = $user_prefix ?? WWW_URI;
+ }
+ else {
+ $this->parent = Utils::dirname($path);
+ $this->uri = $this->parent;
+
+ if ($this->context === File::CONTEXT_DOCUMENTS) {
+ $prefix_path = $this->parent;
+ }
+ else {
+ $prefix_path = File::CONTEXT_DOCUMENTS;
+ }
+
+ $this->link_prefix = $user_prefix ?? ADMIN_URL . 'common/files/preview.php?p=' . $prefix_path . '/';
+ $this->link_suffix = '.md';
+ }
+
+ $this->uri = str_replace('%2F', '/', rawurlencode($this->uri));
+ }
+ }
+
+ abstract public function render(string $content): string;
+
+ public function hasPath(): bool
+ {
+ return isset($this->path);
+ }
+
+ public function registerAttachment(string $uri)
+ {
+ Render::registerAttachment($this->path, $uri);
+ }
+
+ public function listLinks(): array
+ {
+ return $this->links;
+ }
+
+ public function listAttachments(): array
+ {
+ if (!isset($this->parent)) {
+ return [];
+ }
+
+ $this->loadAttachments();
+
+ return $this->attachments;
+ }
+
+ protected function loadAttachments(): void
+ {
+ if (isset($this->attachments)) {
+ return;
+ }
+
+ $this->attachments = Files::list($this->parent);
+ }
+
+ public function listImagesFilenames(): array
+ {
+ $out = array_filter($this->listAttachments(), fn ($a) => $a->image);
+ array_walk($out, fn(&$a) => $a = $a->name);
+ return $out;
+ }
+
+ public function resolveAttachment(string $uri): ?File
+ {
+ $prefix = $this->uri;
+ $pos = strpos($uri, '/');
+
+ if ($pos === 0) {
+ // Absolute URL: treat it as absolute!
+ $uri = ltrim($uri, '/');
+ }
+ else {
+ // Handle relative URIs
+ $uri = $prefix . '/' . $uri;
+ }
+
+ $this->loadAttachments();
+
+ $uri = ltrim($uri, '/');
+ $path = $uri;
+
+ $uri = explode('/', $uri);
+ $uri = array_map('rawurlencode', $uri);
+ $uri = implode('/', $uri);
+
+ $context = strtok($this->path, '/');
+ strtok('');
+
+ $attachment = null;
+
+ if ($context === File::CONTEXT_WEB) {
+ foreach ($this->listAttachments() as $file) {
+ if ($file->uri() === $uri) {
+ $attachment = $file;
+ break;
+ }
+ }
+ }
+ else {
+ $attachment = $this->attachments[$path] ?? null;
+ }
+
+ $this->registerAttachment($uri);
+
+ return $attachment;
+ }
+
+ public function outputHTML(string $content): string
+ {
+ $content = trim($content);
+
+ if ($content === '') {
+ return $content;
+ }
+
+ $content = preg_replace_callback(';( ]*)(href=["\']?((?!#)[^\s\'"]+)[\'"]?)([^>]*>)(.*?) ;is', function ($match) {
+ $label = trim(html_entity_decode(strip_tags($match[5]))) ?: null;
+ $href = sprintf(' href="%s"', htmlspecialchars($this->resolveLink(htmlspecialchars_decode($match[3]), $label)));
+ return $match[1] . $href . $match[4] . $match[5] . '';
+ }, $content);
+
+ $content = '' . $content . '
';
+ return $content;
+ }
+
+ public function resolveLink(string $uri, ?string $label = null): string
+ {
+ $first = substr($uri, 0, 1);
+
+ if ($first === '/' || $first === '!') {
+ $uri = $first === '!' ? Utils::getLocalURL($uri) : $uri;
+ $this->links[] = ['type' => 'internal', 'uri' => $uri, 'label' => $label];
+ return $uri;
+ }
+
+ $pos = strpos($uri, ':');
+
+ if ($pos !== false && (substr($uri, 0, 7) === 'http://' || substr($uri, 0, 8) === 'https://')) {
+ $this->links[] = ['type' => 'external', 'uri' => $uri, 'label' => $label];
+ return $uri;
+ }
+ elseif ($pos !== false) {
+ $this->links[] = ['type' => 'other', 'uri' => $uri, 'label' => $label];
+ return $uri;
+ }
+ else {
+ $this->links[] = ['type' => 'page', 'uri' => $uri, 'label' => $label];
+ }
+
+ if (strpos(Utils::basename($uri), '.') === false) {
+ $uri .= $this->link_suffix;
+ }
+
+ return $this->link_prefix . $uri;
+ }
+}
\ No newline at end of file
diff --git a/src/include/lib/Paheko/Web/Render/Encrypted.php b/src/include/lib/Paheko/Web/Render/Encrypted.php
new file mode 100644
index 0000000..13eb7b1
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Render/Encrypted.php
@@ -0,0 +1,18 @@
+assign('admin_url', ADMIN_URL);
+ $tpl->assign(compact('content'));
+ return $tpl->fetch('common/files/_file_render_encrypted.tpl');
+ }
+}
diff --git a/src/include/lib/Paheko/Web/Render/Extensions.php b/src/include/lib/Paheko/Web/Render/Extensions.php
new file mode 100644
index 0000000..b6d719a
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Render/Extensions.php
@@ -0,0 +1,309 @@
+/!\ ' . htmlspecialchars($msg, ENT_QUOTES, 'UTF-8') . '' . $tag . '>';
+ }
+
+ static public function setRenderer(AbstractRender $renderer)
+ {
+ self::$renderer = $renderer;
+ }
+
+ static public function getList(): array
+ {
+ $list = [
+ 'file' => [self::class, 'file'],
+ 'fichier' => [self::class, 'file'],
+ 'image' => [self::class, 'image'],
+ 'gallery' => [self::class, 'gallery'],
+ 'video' => [self::class, 'video'],
+ 'paheko' => [self::class, 'paheko'],
+ ];
+
+ $signal = Plugins::fire('render.extensions.init', false, $list);
+
+ if ($signal) {
+ $list = array_merge($list, $signal->getOut());
+ }
+
+ return $list;
+ }
+
+ static public function paheko(bool $block, array $args, ?string $content): string
+ {
+ if (!empty($args['doc'])) {
+ if (!preg_match('/^[\w_-]+$/i', $args['doc'])) {
+ return '[Invalid doc]';
+ }
+
+ $path = ROOT . '/www/admin/static/doc/' . $args['doc'] . '.html';
+
+ if (!file_exists($path)) {
+ return '[Invalid doc]';
+ }
+
+ $body = file_get_contents($path);
+ $body = substr($body, strpos($body, 'class="web-content"'));
+ $body = substr($body, strpos($body, '>')+1);
+ $body = substr($body, 0, strrpos($body, '(?:(?!).)*?;s', '', $body);
+
+ // Replace images
+ $body = preg_replace(';src="(?!https?:|/);', 'src="' . ADMIN_URL . 'static/doc/', $body);
+
+ // Replace links
+ $body = preg_replace_callback('!href="([a-z_-]+)\.html!',
+ function($match) use ($args, $replace) {
+ $url = $match[1];
+ if (array_key_exists($url, $replace)) {
+ $url = $replace[$url];
+ }
+ else {
+ $url = str_replace('_', '-', $url);
+
+ if (isset($args['prefix'])) {
+ $url = $args['prefix'] . $url;
+ }
+ }
+
+ return 'href="' . $url;
+ }, $body);
+ return $body;
+ }
+ elseif (isset($args['version']) || in_array('version', $args)) {
+ return \Paheko\paheko_version();
+ }
+
+ return '';
+ }
+
+ static public function gallery(bool $block, array $args, ?string $content): string
+ {
+ $type = 'gallery';
+
+ if (isset($args['type'])) {
+ $type = $args['type'];
+ }
+ elseif (isset($args[0])) {
+ $type = $args[0];
+ }
+
+ if (!in_array($type, ['gallery', 'slideshow'])) {
+ $type = 'gallery';
+ }
+
+ $out = sprintf('', $type);
+ $index = '';
+
+ if (trim((string)$content) === '') {
+ $images = self::$renderer->listImagesFilenames();
+ }
+ else {
+ $images = explode("\n", $content);
+ }
+
+ $i = 1;
+
+ foreach ($images as $line) {
+ $line = trim($line);
+
+ if ($line === '') {
+ continue;
+ }
+
+ $img = strtok($line, '|');
+ $label = strtok('');
+ $size = $type === 'slideshow' ? File::THUMB_SIZE_LARGE : File::THUMB_SIZE_TINY;
+
+ $out .= sprintf('%s ', self::img($img, $size, $label ?: null));
+ }
+
+ $out .= '
';
+ return $out;
+ }
+
+ static public function file(bool $block, array $args): string
+ {
+ $name = $args[0] ?? null;
+ $caption = $args[1] ?? null;
+
+ if (!$name || !self::$renderer->hasPath()) {
+ return self::error('Tag file : aucun nom de fichier indiqué.');
+ }
+
+ if (empty($caption)) {
+ $caption = substr($name, 0, strrpos($name, '.'));
+ }
+
+ $file = self::$renderer->resolveAttachment($name);
+
+ if (!$file) {
+ return self::error('Tag file : nom de fichier introuvable.');
+ }
+
+ $ext = $file->extension();
+ $thumb = '';
+
+ if ($thumb_url = $file->thumb_url()) {
+ $thumb = sprintf(' ',
+ htmlspecialchars($thumb_url),
+ htmlspecialchars($file->name)
+ );
+ }
+
+ return sprintf(
+ '%s%s %s — %s ',
+ htmlspecialchars($ext),
+ htmlspecialchars($file->url()),
+ $thumb,
+ htmlspecialchars($caption),
+ htmlspecialchars($file->getFormatDescription()),
+ htmlspecialchars(Utils::format_bytes($file->size))
+ );
+ }
+
+ static public function video(bool $block, array $args): string
+ {
+ $name = $args['file'] ?? ($args[0] ?? null);
+
+ if (!$name || !self::$renderer->hasPath()) {
+ return self::error('Tag video : aucun nom de fichier indiqué.');
+ }
+
+ $poster = $args['poster'] ?? ($args[1] ?? null);
+ $subs = $args['subtitles'] ?? ($args[2] ?? null);
+ $video = self::$renderer->resolveAttachment($name);
+
+ if (!$video) {
+ return self::error('Tag video : nom de fichier introuvable.');
+ }
+
+ if ($poster) {
+ $poster = self::$renderer->resolveAttachment($poster);
+ $poster = $poster ? $poster->url() : null;
+ }
+ else {
+ $poster = $video->thumb_url('750px');
+ }
+
+ $params = '';
+
+ if ($subs) {
+ $subs = self::$renderer->resolveAttachment($subs);
+
+ // Automagically manage .srt files
+ // Inspired by https://github.com/codeit-ninja/SRT-Support-for-HTML5-videos
+ if (substr($subs->name, -4) === '.srt') {
+ $params .= ' onplay="var k = this.querySelector(\'track\'); fetch(k.src).then((r) => r.text()).then((t) => { t = \'WEBVTT\n\n\' + t.split(/\n/g).map(line => line.replace(/((\d+:){0,2}\d+),(\d+)/g, \'$1.$3\')).join(\'\n\'); k.src = window.URL.createObjectURL(new Blob([t])); } ); return true;"';
+ }
+
+ $subs = sprintf(' ', htmlspecialchars($subs->url()));
+ }
+
+ if (isset($args['width'])) {
+ $params .= sprintf(' width="%d"', $args['width']);
+ }
+
+ if (isset($args['height'])) {
+ $params .= sprintf(' height="%d"', $args['height']);
+ }
+
+ return sprintf('%s Play ',
+ $poster ? 'metadata' : 'none',
+ htmlspecialchars($poster),
+ htmlspecialchars($video->url()),
+ $params,
+ $subs
+ );
+ }
+
+ static public function image(bool $block, array $args): string
+ {
+ static $align_replace = ['gauche' => 'left', 'droite' => 'right', 'centre' => 'center'];
+
+ $name = $args['file'] ?? ($args[0] ?? null);
+ $align = $args['align'] ?? ($args[1] ?? null);
+ $caption = $args['caption'] ?? (isset($args[2]) ? implode(' ', array_slice($args, 2)) : null);
+
+ $align = strtr((string)$align, $align_replace);
+
+ if (!$name || !self::$renderer->hasPath()) {
+ return self::error('Tag image : aucun nom de fichier indiqué.');
+ }
+
+ if ($align === 'center') {
+ $size = File::THUMB_SIZE_LARGE;
+ }
+ elseif ($align === 'left' || $align === 'right') {
+ $size = File::THUMB_SIZE_TINY;
+ }
+ else {
+ $size = null;
+ }
+
+ $out = self::img($name, $align ? $size : null, $caption);
+
+ if (!empty($align)) {
+ if ($caption) {
+ $caption = sprintf('%s ', htmlspecialchars($caption));
+ }
+
+ $out = sprintf('%s%s ', $align, $out, $caption);
+ }
+
+ return $out;
+ }
+
+ static protected function img(string $name, ?string $thumb_size = File::THUMB_SIZE_TINY, ?string $caption = null): string
+ {
+ $file = self::$renderer->resolveAttachment($name);
+
+ if (!$file) {
+ return self::error('Tag image : nom de fichier inconnu : ' . $name);
+ }
+
+ $svg = substr($name, -4) == '.svg';
+ $thumb_url = null;
+ $url = $file->url();
+
+ if ($svg || !$thumb_size) {
+ $thumb_url = $url;
+ }
+ else {
+ $thumb_url = $file->thumb_url($thumb_size);
+ }
+
+ return sprintf(' ',
+ htmlspecialchars($url),
+ htmlspecialchars($thumb_url ?? $url),
+ htmlspecialchars($caption ?? '')
+ );
+ }
+}
diff --git a/src/include/lib/Paheko/Web/Render/Markdown.php b/src/include/lib/Paheko/Web/Render/Markdown.php
new file mode 100644
index 0000000..c82f1ae
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Render/Markdown.php
@@ -0,0 +1,70 @@
+ $callback) {
+ self::$md->registerExtension($name, $callback);
+ }
+
+ self::$md->registerDefaultExtensionCallback([self::class, 'defaultExtensionCallback']);
+ }
+
+ Extensions::setRenderer($this);
+
+ $content = self::$md->text($content);
+
+ return $this->outputHTML($content);
+ }
+
+ static public function defaultExtensionCallback(bool $block, array $params, ?string $content, string $name, KD2_Markdown $md): string
+ {
+ $args = array_merge($params, compact('block', 'params', 'content'));
+
+ $out = Modules::snippetsAsString(sprintf(Modules::SNIPPET_MARKDOWN_EXTENSION, $name), $args);
+
+ if (!$out) {
+ return $md::error(sprintf("L'extension <<%s>> n'existe pas.", $name), $block);
+ }
+
+ return $out;
+ }
+}
diff --git a/src/include/lib/Paheko/Web/Render/Render.php b/src/include/lib/Paheko/Web/Render/Render.php
new file mode 100644
index 0000000..8e42083
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Render/Render.php
@@ -0,0 +1,51 @@
+render($content);
+ }
+
+ static public function getRenderer(string $format, ?string $path, string $link_prefix = null)
+ {
+ if ($format == self::FORMAT_SKRIV) {
+ return new Skriv($path, $link_prefix);
+ }
+ // Keep legacy format as it is sometimes used in upgrades
+ else if ($format == self::FORMAT_ENCRYPTED) {
+ return new Encrypted($path, $link_prefix);
+ }
+ else if ($format == self::FORMAT_MARKDOWN) {
+ return new Markdown($path, $link_prefix);
+ }
+ else {
+ throw new \LogicException('Invalid format: ' . $format);
+ }
+ }
+
+ static public function registerAttachment(string $path, string $uri): void
+ {
+ $hash = md5($path);
+
+ if (!array_key_exists($hash, self::$attachments)) {
+ self::$attachments[$hash] = [];
+ }
+
+ self::$attachments[$hash][$uri] = true;
+ }
+
+ static public function listAttachments(?string $path) {
+ return array_keys(self::$attachments[md5($path)] ?? []);
+ }
+}
diff --git a/src/include/lib/Paheko/Web/Render/Skriv.php b/src/include/lib/Paheko/Web/Render/Skriv.php
new file mode 100644
index 0000000..fe49df0
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Render/Skriv.php
@@ -0,0 +1,39 @@
+resolveAttachment($match[1]);
+ return $a ? $a->url() : $match[1];
+ }, $str);
+
+ if (!isset(self::$skriv)) {
+ self::$skriv = new SkrivLite;
+
+ self::$skriv->registerExtensions(Extensions::getList());
+ }
+
+ Extensions::setRenderer($this);
+
+ $str = CommonModifiers::typo($str);
+ $str = self::$skriv->render($str);
+
+ return $this->outputHTML($str);
+ }
+}
diff --git a/src/include/lib/Paheko/Web/Router.php b/src/include/lib/Paheko/Web/Router.php
new file mode 100644
index 0000000..382910b
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Router.php
@@ -0,0 +1,253 @@
+ $k . ': ' . $v, $headers, array_keys($headers)))
+ );
+
+ if ($method != 'GET' && $method != 'OPTIONS' && $method != 'HEAD') {
+ self::log("ROUTER: <= Request body:\n%s", file_get_contents('php://input'));
+ }
+ }
+
+ // Redirect old URLs (pre-1.1)
+ if ($uri === 'feed/atom/') {
+ Utils::redirect('/atom.xml');
+ }
+ elseif ($uri === 'favicon.ico') {
+ http_response_code(301);
+ header('Location: ' . Config::getInstance()->fileURL('favicon'), true);
+ return;
+ }
+ // Default robots.txt if website is disabled
+ elseif ($uri === 'robots.txt' && Config::getInstance()->site_disabled) {
+ http_response_code(200);
+ header('Content-Type: text/plain');
+ echo "User-agent: *\nDisallow: /admin/\n";
+ echo "User-agent: GPTBot\nDisallow: /\n";
+ return;
+ }
+ // Add trailing slash to URLs if required
+ elseif (($first === 'p' || $first === 'm') && preg_match('!^(?:admin/p|p|m)/\w+$!', $uri)) {
+ Utils::redirect('/' . $uri . '/');
+ }
+ elseif ((($first === 'admin' && 0 === strpos($uri, 'admin/p/')) || $first === 'p')
+ && preg_match('!^(?:admin/p|p)/(' . Plugins::NAME_REGEXP . ')/(.*)$!', $uri, $match)
+ && Plugins::exists($match[1])) {
+ $uri = ($first === 'admin' ? 'admin/' : 'public/') . $match[2];
+
+
+ if ($match[2] === 'icon.svg' || substr($uri, -3) === '.md') {
+ $r = Plugins::routeStatic($match[1], $uri);
+
+ if ($r) {
+ return;
+ }
+ }
+ else {
+ $plugin = Plugins::get($match[1]);
+
+ if ($plugin && $plugin->enabled) {
+ $plugin->route($uri);
+ return;
+ }
+ }
+ }
+
+ // Other admin/plugin routes are not found
+ if ($first === 'admin' || $first === 'p') {
+ throw new UserException('Cette page ne semble pas exister.', 404);
+ }
+ elseif ($first === 'api') {
+ API::routeHttpRequest(substr($uri, 4));
+ return;
+ }
+ // Route WebDAV requests to WebDAV server
+ elseif ((in_array($uri, self::DAV_ROUTES) || in_array($first, self::DAV_ROUTES))
+ && WebDAV_Server::route($uri)) {
+ return;
+ }
+ // Redirect PROPFIND requests to WebDAV, required for some WebDAV clients
+ elseif ($method === 'PROPFIND') {
+ header('Location: /dav/documents/');
+ return;
+ }
+ // Don't try to route paths with no slash, files are always in a sub-directory
+ elseif ($uri && false !== strpos($uri, '/') && self::routeFile($uri)) {
+ return;
+ }
+
+ // Redirect to ADMIN_URL if website is disabled
+ // (but not for content.css)
+ if (Config::getInstance()->site_disabled && $uri !== 'content.css' && $first !== 'm') {
+ if ($uri === '') {
+ Utils::redirect(ADMIN_URL);
+ }
+ else {
+ throw new UserException('Cette page n\'existe pas.', 404);
+ }
+ }
+
+ // Let modules handle the request
+ Modules::route($uri);
+ }
+
+ static public function routeFile(string $uri): bool
+ {
+ $context = strtok($uri, '/');
+ strtok('');
+
+ $size = null;
+
+ if (false !== strpos($uri, 'px.') && preg_match('/\.([\da-z-]+px)\.(?:webp|svg)$/', $uri, $match)) {
+ $uri = substr($uri, 0, -strlen($match[0]));
+ $size = $match[1];
+ }
+
+ $file = Files::getFromURI($uri);
+
+ // We can't serve directories
+ if ($file && $file->isDir()) {
+ $file = null;
+ }
+
+ if (!$file) {
+ // URL has a context but is not a file? stop here
+ if ($context && array_key_exists($context, File::CONTEXTS_NAMES)) {
+ throw new UserException('Cette adresse n\'existe pas ou plus.', 404);
+ }
+
+ return false;
+ }
+
+ if ($file->trash) {
+ throw new UserException('Cette page n\'existe pas.', 404);
+ }
+
+ foreach ($_GET as $key => $v) {
+ if (array_key_exists($key, File::ALLOWED_THUMB_SIZES)) {
+ $size = $key;
+ break;
+ }
+ }
+
+ $session = Session::getInstance();
+
+ $signal = Plugins::fire('http.request.file.before', true, compact('file', 'uri', 'session'));
+
+ if ($signal && $signal->isStopped()) {
+ // If a plugin handled the request, let's stop here
+ return true;
+ }
+
+ $file->validateCanRead($session, $_GET['s'] ?? null, $_POST['p'] ?? null);
+
+ if ($size) {
+ $file->serveThumbnail($size);
+ }
+ else {
+ $file->serve(isset($_GET['download']));
+ }
+
+ Plugins::fire('http.request.file.after', false, compact('file', 'uri', 'session'));
+
+ return true;
+ }
+
+ static public function log(string $message, ...$params)
+ {
+ if (!HTTP_LOG_FILE) {
+ return;
+ }
+
+ static $log = '';
+
+ if (!$log) {
+ register_shutdown_function(function () use (&$log) {
+ file_put_contents(HTTP_LOG_FILE, $log, FILE_APPEND);
+ });
+ }
+
+ $log .= vsprintf($message, $params) . "\n\n";
+ }
+
+ static public function isXSendFileEnabled(): bool
+ {
+ if (!ENABLE_XSENDFILE || !isset($_SERVER['SERVER_SOFTWARE'])) {
+ return false;
+ }
+
+ if (stristr($_SERVER['SERVER_SOFTWARE'], 'apache')
+ && function_exists('apache_get_modules')
+ && in_array('mod_xsendfile', apache_get_modules())) {
+ return true;
+ }
+ else if (stristr($_SERVER['SERVER_SOFTWARE'], 'lighttpd')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ static public function xSendFile(string $path): void
+ {
+ header('X-Sendfile: ' . $path);
+ }
+}
diff --git a/src/include/lib/Paheko/Web/Sync.php b/src/include/lib/Paheko/Web/Sync.php
new file mode 100644
index 0000000..a62a640
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Sync.php
@@ -0,0 +1,247 @@
+set('title', $value);
+ }
+ elseif ($key == 'published') {
+ $page->set('published', new \DateTime($value));
+ }
+ elseif ($key == 'modified') {
+ $page->set('modified', new \DateTime($value));
+ }
+ elseif ($key == 'format') {
+ $value = strtolower($value);
+
+ if (!array_key_exists($value, Page::FORMATS_LIST)) {
+ throw new \LogicException('Unknown format: ' . $value);
+ }
+
+ $page->set('format', $value);
+ }
+ elseif ($key == 'type') {
+ $value = strtolower($value);
+
+ if ($value == strtolower(Page::TYPES[Page::TYPE_CATEGORY])) {
+ $value = Page::TYPE_CATEGORY;
+ }
+ elseif ($value == strtolower(Page::TYPES[Page::TYPE_PAGE])) {
+ $value = Page::TYPE_PAGE;
+ }
+ else {
+ throw new \LogicException('Unknown type: ' . $value);
+ }
+
+ $page->set('type', $value);
+ }
+ elseif ($key == 'status') {
+ $value = strtolower($value);
+
+ if (!array_key_exists($value, Page::STATUS_LIST)) {
+ throw new \LogicException('Unknown status: ' . $value);
+ }
+
+ $page->set('status', $value);
+ }
+ else {
+ // Ignore other metadata
+ }
+ }
+
+ $page->set('content', trim($content, "\n\r"));
+
+ return true;
+ }
+
+ static protected function loadFromFile(Page $page, File $file): bool
+ {
+ // Don't update if page was modified in DB since
+ if ($page->modified && $page->modified > $file->modified) {
+ return false;
+ }
+
+ $m = null;
+
+ if ($page->modified) {
+ $m = clone $page->modified;
+ }
+
+ if (!self::importFromRaw($page, $file->fetch())) {
+ throw new \LogicException('Invalid page content: ' . $file->parent);
+ }
+
+ if (empty($page->modified)) {
+ $page->set('modified', $file->modified);
+ }
+
+ if ($m && $m > $page->modified) {
+ return false;
+ }
+
+ if (!isset($page->type) || $page->type != $page::TYPE_CATEGORY) {
+ $type = $page::TYPE_PAGE;
+
+ foreach (Files::list($page->dir_path) as $file) {
+ if ($file->isDir()) {
+ $type = $page::TYPE_CATEGORY;
+ break;
+ }
+ }
+
+ $page->set('type', $type);
+ }
+ else {
+ $page->set('type', $page::TYPE_CATEGORY);
+ }
+
+ return true;
+ }
+
+ static public function fromFile(File $file): Page
+ {
+ $page = new Page;
+
+ $path = substr($file->parent, strlen(File::CONTEXT_WEB . '/'));
+ $parent = Utils::basename($path) ?: null;
+
+ if ($parent) {
+ $parent = Web::getByUri($parent);
+ $parent = $parent ? $parent->id() : null;
+ }
+
+ $page->importForm([
+ 'id_parent' => $parent,
+ 'uri' => Utils::basename($file->parent),
+ ]);
+
+ self::loadFromFile($page, $file);
+ return $page;
+ }
+
+ static public function flatten(?string $path = null)
+ {
+ $path ??= File::CONTEXT_WEB;
+
+ foreach (Files::list($path) as $file) {
+ if ($file->isDir()) {
+ self::flatten($file->path);
+
+ if (substr_count($file->path, '/') >= 2) {
+ $file->rename(File::CONTEXT_WEB . '/' . $file->name, false);
+ }
+ }
+ }
+ }
+
+ /**
+ * This syncs the whole website between the actual files and the web_pages table
+ */
+ static public function sync(bool $force = false): array
+ {
+ // This is only useful if web pages are stored outside of the database
+ if (FILE_STORAGE_BACKEND == 'SQLite' && !$force) {
+ return [];
+ }
+
+ $path = File::CONTEXT_WEB;
+ $errors = [];
+
+ $list = iterator_to_array(Files::listRecursive($path, null, false));
+ $exists = [];
+
+ foreach ($list as $path => $file) {
+ if ($file->isDir() || $file->name !== 'index.txt') {
+ continue;
+ }
+
+ $exists[$path] = Utils::basename(Utils::dirname($path));
+ }
+
+ $db = DB::getInstance();
+
+ $in_db = $db->getAssoc('SELECT uri, uri FROM web_pages;');
+
+ $deleted = array_diff($in_db, $exists);
+ $new = array_diff($exists, $in_db);
+ $intersection = array_intersect($in_db, $exists);
+
+ if ($deleted) {
+ $db->exec(sprintf('DELETE FROM web_pages WHERE %s;', $db->where('uri', $deleted)));
+ }
+
+ sort($new);
+
+ foreach ($new as $path => $uri) {
+ $f = Files::get($path);
+
+ if (!$f) {
+ // This is a directory without content, ignore
+ continue;
+ }
+
+ try {
+ $page = self::fromFile($f);
+ $page->save();
+ }
+ catch (ValidationException $e) {
+ // Ignore validation errors, just don't add pages to index
+ $errors[] = sprintf('Erreur à l\'import, page "%s": %s', str_replace(File::CONTEXT_WEB . '/', '', $f->parent), $e->getMessage());
+ }
+ }
+
+ if (count($new) || count($deleted)) {
+ Cache::clear();
+ }
+
+ foreach ($intersection as $path => $uri) {
+ $file = Files::get($path);
+ $page = Web::getByUri($uri);
+
+ if ($page && $file && self::loadFromFile($page, $file)) {
+ $page->save();
+ }
+ }
+
+ self::flatten();
+
+ Storage::cleanup();
+
+ return $errors;
+ }
+}
diff --git a/src/include/lib/Paheko/Web/Web.php b/src/include/lib/Paheko/Web/Web.php
new file mode 100644
index 0000000..c6d6521
--- /dev/null
+++ b/src/include/lib/Paheko/Web/Web.php
@@ -0,0 +1,178 @@
+uri = substr($result->path, strlen(File::CONTEXT_WEB) + 1);
+ }
+
+ unset($result);
+
+ return $results;
+ }
+
+ static public function getBreadcrumbs(int $id): array
+ {
+ return DB::getInstance()->getGrouped(sprintf(self::BREADCRUMBS_SQL, $id));
+ }
+
+ static protected function getParentClause(?int $id_parent): string
+ {
+ if ($id_parent) {
+ return 'id_parent = ' . $id_parent;
+ }
+ else {
+ return 'id_parent IS NULL';
+ }
+ }
+
+ static public function listCategories(?int $id_parent): array
+ {
+ $sql = sprintf('SELECT * FROM @TABLE WHERE %s AND type = %d ORDER BY title COLLATE U_NOCASE;', self::getParentClause($id_parent), Page::TYPE_CATEGORY);
+ return EM::getInstance(Page::class)->all($sql);
+ }
+
+ static public function listPages(?int $id_parent, bool $order_by_date = true): array
+ {
+ $order = $order_by_date ? 'published DESC' : 'title COLLATE U_NOCASE';
+ $sql = sprintf('SELECT * FROM @TABLE WHERE %s AND type = %d ORDER BY %s;', self::getParentClause($id_parent), Page::TYPE_PAGE, $order);
+ return EM::getInstance(Page::class)->all($sql);
+ }
+
+ static public function listAll(): array
+ {
+ $sql = 'SELECT * FROM @TABLE ORDER BY title COLLATE U_NOCASE;';
+ return EM::getInstance(Page::class)->all($sql);
+ }
+
+ static public function getDraftsList(?int $id_parent): DynamicList
+ {
+ $list = self::getPagesList($id_parent);
+ $list->setParameter('status', Page::STATUS_DRAFT);
+ $list->setPageSize(1000);
+ return $list;
+ }
+
+ static public function getPagesList(?int $id_parent): DynamicList
+ {
+ $columns = [
+ 'id' => [],
+ 'uri' => [
+ ],
+ 'title' => [
+ 'label' => 'Titre',
+ 'order' => 'title COLLATE U_NOCASE %s',
+ ],
+ 'published' => [
+ 'label' => 'Publication',
+ ],
+ 'modified' => [
+ 'label' => 'Modification',
+ ],
+ ];
+
+ $tables = Page::TABLE;
+ $conditions = self::getParentClause($id_parent) . ' AND type = :type AND status = :status';
+
+ $list = new DynamicList($columns, $tables, $conditions);
+ $list->setParameter('type', Page::TYPE_PAGE);
+ $list->setParameter('status', Page::STATUS_ONLINE);
+ $list->orderBy('title', false);
+ return $list;
+ }
+
+ static public function getAllList(): DynamicList
+ {
+ $db = DB::getInstance();
+
+ $columns = [
+ 'id' => [],
+ 'uri' => [
+ ],
+ 'title' => [
+ 'label' => 'Titre',
+ 'order' => 'title COLLATE U_NOCASE %s',
+ ],
+ 'path' => [
+ 'label' => 'Catégorie',
+ 'select' => sprintf('(SELECT GROUP_CONCAT(title, \' > \') FROM (%s))',
+ rtrim(sprintf(Web::BREADCRUMBS_SQL, 'p.id_parent'), '; ')),
+ ],
+ 'draft' => [
+ 'label' => 'Brouillon',
+ 'select' => sprintf('CASE WHEN p.status = %s THEN 1 ELSE 0 END',
+ $db->quote(Page::STATUS_DRAFT),
+ ),
+ 'order' => 'status %s, title COLLATE U_NOCASE %1$s',
+ ],
+ 'published' => [
+ 'label' => 'Publication',
+ ],
+ 'modified' => [
+ 'label' => 'Modification',
+ ],
+ ];
+
+ $tables = Page::TABLE . ' AS p';
+
+ $list = new DynamicList($columns, $tables);
+ $list->orderBy('title', false);
+ $list->setPageSize(null);
+ return $list;
+ }
+
+ static public function getByURI(string $uri): ?Page
+ {
+ return EM::findOne(Page::class, 'SELECT * FROM @TABLE WHERE uri = ?;', $uri);
+ }
+
+ static public function get(int $id): ?Page
+ {
+ return EM::findOne(Page::class, 'SELECT * FROM @TABLE WHERE id = ?;', $id);
+ }
+
+ static public function checkAllInternalPagesLinks(): array
+ {
+ $sql = 'SELECT * FROM @TABLE ORDER BY title COLLATE U_NOCASE;';
+ $list = [];
+
+ foreach (EM::getInstance(Page::class)->iterate($sql) as $page) {
+ $list[$page->uri] = $page;
+ }
+
+ $errors = [];
+
+ foreach ($list as $page) {
+ if (count($page->checkInternalPagesLinks($list)) > 0) {
+ $errors[] = $page;
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/include/lib/dependencies.list b/src/include/lib/dependencies.list
new file mode 100644
index 0000000..544fbfc
--- /dev/null
+++ b/src/include/lib/dependencies.list
@@ -0,0 +1,43 @@
+KD2/data/
+KD2/DB/AbstractEntity.php
+KD2/DB/DB.php
+KD2/DB/Date.php
+KD2/DB/EntityManager.php
+KD2/DB/SQLite3.php
+KD2/Brindille.php
+KD2/ErrorManager.php
+KD2/Form.php
+KD2/FossilInstaller.php
+KD2/Graphics/Blob.php
+KD2/Graphics/Image.php
+KD2/Graphics/BarCode.php
+KD2/Graphics/QRCode.php
+KD2/Graphics/SVG/Pie.php
+KD2/Graphics/SVG/Plot.php
+KD2/Graphics/SVG/Bar.php
+KD2/JSONSchema.php
+KD2/HTML/CSSParser.php
+KD2/HTML/TableExport.php
+KD2/HTML/TableToCSV.php
+KD2/HTML/TableToODS.php
+KD2/HTML/TableToXLSX.php
+KD2/HTML/Markdown.php
+KD2/HTML/Markdown_Extensions.php
+KD2/HTTP.php
+KD2/Mail_Message.php
+KD2/Office/Calc/Writer.php
+KD2/Office/ToText.php
+KD2/Security.php
+KD2/Security_OTP.php
+KD2/SimpleDiff.php
+KD2/SkrivLite.php
+KD2/Smartyer.php
+KD2/SMTP.php
+KD2/Translate.php
+KD2/UserSession.php
+KD2/WebDAV/AbstractStorage.php
+KD2/WebDAV/NextCloud.php
+KD2/WebDAV/Server.php
+KD2/WebDAV/WOPI.php
+KD2/ZipReader.php
+KD2/ZipWriter.php
diff --git a/src/include/migrations/1.1/30.php b/src/include/migrations/1.1/30.php
new file mode 100644
index 0000000..a6586c9
--- /dev/null
+++ b/src/include/migrations/1.1/30.php
@@ -0,0 +1,26 @@
+exec(sprintf('ATTACH \'%s\' AS old;', $old_db));
+
+$chart_id = $db->firstColumn('SELECT id FROM acc_charts WHERE code = \'PCA_2018\' AND country = \'FR\';');
+
+// We cannot use UPDATE FROM as it doesn't work with old SQLite < 3.33.0
+$db->exec(sprintf('UPDATE acc_accounts AS a
+ SET description = (SELECT b.description FROM old.acc_accounts b WHERE b.id = a.id),
+ type = (SELECT b.type FROM old.acc_accounts b WHERE b.id = a.id)
+ WHERE a.id_chart = %d AND EXISTS (SELECT b.id FROM old.acc_accounts b WHERE b.id = a.id AND b.label = a.label AND b.code = a.code);', $chart_id));
+
+$db->exec('DETACH \'old\';');
diff --git a/src/include/migrations/1.1/31.sql b/src/include/migrations/1.1/31.sql
new file mode 100644
index 0000000..85e7f05
--- /dev/null
+++ b/src/include/migrations/1.1/31.sql
@@ -0,0 +1,45 @@
+-- New config value
+INSERT OR IGNORE INTO config (key, value) VALUES ('analytical_set_all', 1);
+
+-- Fix charts positions
+
+-- Force all third party accounts to be in position 3 (active or passive)
+UPDATE acc_accounts SET position = 3
+ WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR')
+ AND position NOT IN (0, 1, 2, 3)
+ AND SUBSTR(code, 1, 1) IN ('1', '2', '3', '4', '5');
+
+-- Force all expense accounts to be in expense position
+UPDATE acc_accounts SET position = 4
+ WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR')
+ AND position NOT IN (0, 4)
+ AND code LIKE '6%';
+
+-- Force all revenue accounts to be in revenue position
+UPDATE acc_accounts SET position = 5
+ WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR')
+ AND position NOT IN (0, 5)
+ AND code LIKE '7%';
+
+-- Force all volunteering accounts
+UPDATE acc_accounts SET position = 4
+ WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR')
+ AND position NOT IN (0, 4)
+ AND code LIKE '86_%';
+
+UPDATE acc_accounts SET position = 5
+ WHERE id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR')
+ AND position NOT IN (0, 5)
+ AND code LIKE '87_%';
+
+-- Change code from accounts that are outside their class
+UPDATE acc_accounts SET code = '9' || code WHERE type = 7 AND code NOT LIKE '9%';
+
+-- Remove bank for banks that are outside their class
+UPDATE acc_accounts SET type = 0 WHERE type = 1 AND code NOT LIKE '5%';
+UPDATE acc_accounts SET type = 0 WHERE type = 2 AND code NOT LIKE '5%';
+UPDATE acc_accounts SET type = 0 WHERE type = 3 AND code NOT LIKE '5%';
+UPDATE acc_accounts SET type = 0 WHERE type = 4 AND code NOT LIKE '4%';
+UPDATE acc_accounts SET type = 0 WHERE type = 5 AND code NOT LIKE '6%';
+UPDATE acc_accounts SET type = 0 WHERE type = 6 AND code NOT LIKE '7%';
+UPDATE acc_accounts SET type = 0 WHERE type = 8 AND code NOT LIKE '8%';
diff --git a/src/include/migrations/1.2/1.2.0.sql b/src/include/migrations/1.2/1.2.0.sql
new file mode 100644
index 0000000..18d70e3
--- /dev/null
+++ b/src/include/migrations/1.2/1.2.0.sql
@@ -0,0 +1,74 @@
+ALTER TABLE acc_accounts RENAME TO acc_accounts_old;
+ALTER TABLE acc_transactions_lines RENAME TO acc_transactions_lines_old;
+ALTER TABLE services_fees RENAME TO services_fees_old;
+
+.read schema.sql
+
+INSERT OR IGNORE INTO acc_projects (code, label, description)
+ SELECT
+ a.code,
+ a.label,
+ a.description
+ FROM acc_accounts_old a
+ WHERE a.type = 7;
+
+-- Copy data to change the column name from acc_accounts to acc_projects
+INSERT INTO services_fees SELECT * FROM services_fees_old;
+INSERT INTO acc_transactions_lines SELECT * FROM acc_transactions_lines_old;
+
+-- Update references to analytical accounts
+UPDATE services_fees AS a
+ SET id_project = (SELECT b.id FROM acc_projects AS b INNER JOIN acc_accounts_old c ON c.code = b.code WHERE c.id = a.id_project)
+ WHERE id_project IS NOT NULL;
+
+UPDATE acc_transactions_lines AS a
+ SET id_project = (SELECT b.id FROM acc_projects AS b INNER JOIN acc_accounts_old c ON c.code = b.code WHERE c.id = a.id_project)
+ WHERE id_project IS NOT NULL;
+
+-- Remove first 99 and 9 from code (added in 1.1.30)
+UPDATE acc_projects AS a SET code = SUBSTR(code, 3)
+ WHERE SUBSTR(code, 1, 2) = '99' AND LENGTH(code) > 2
+ AND NOT EXISTS (SELECT id FROM acc_projects WHERE code = SUBSTR(a.code, 3));
+UPDATE acc_projects AS a SET code = SUBSTR(code, 2)
+ WHERE SUBSTR(code, 1, 1) = '9' AND LENGTH(code) > 1
+ AND NOT EXISTS (SELECT id FROM acc_projects WHERE code = SUBSTR(a.code, 2));
+
+UPDATE acc_transactions_lines SET id_project = NULL WHERE id_project NOT IN (SELECT id FROM acc_projects);
+
+INSERT INTO acc_accounts SELECT *, CASE WHEN type > 0 AND type <= 8 THEN 1 ELSE 0 END FROM acc_accounts_old;
+
+-- Delete old analytical accounts
+DELETE FROM acc_accounts AS a WHERE
+ (SELECT COUNT(*) FROM acc_transactions_lines AS b WHERE b.id_account = a.id) = 0
+ AND (type = 7
+ OR (id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR')
+ AND type != 7
+ AND code LIKE '9%'
+ AND user = 0
+ )
+ );
+
+UPDATE acc_accounts SET type = 0;
+
+UPDATE acc_accounts SET type = 1 WHERE code LIKE '512_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 2 WHERE code LIKE '53_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 3 WHERE code LIKE '511_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 4 WHERE code LIKE '4_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 5 WHERE code LIKE '6_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 6 WHERE code LIKE '7_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 7 WHERE code LIKE '86_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 8 WHERE code LIKE '87_%' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 9 WHERE code = '890' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 10 WHERE code = '891' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 11 WHERE code = '120' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 12 WHERE code = '129' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 13 WHERE code = '1068' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 14 WHERE code = '110' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+UPDATE acc_accounts SET type = 15 WHERE code = '119' AND id_chart IN (SELECT id FROM acc_charts WHERE country = 'FR');
+
+
+
+-- Cleanup
+DROP TABLE acc_accounts_old;
+DROP TABLE acc_transactions_lines_old;
+DROP TABLE services_fees_old;
\ No newline at end of file
diff --git a/src/include/migrations/1.2/1.2.1.sql b/src/include/migrations/1.2/1.2.1.sql
new file mode 100644
index 0000000..e992642
--- /dev/null
+++ b/src/include/migrations/1.2/1.2.1.sql
@@ -0,0 +1,26 @@
+ALTER TABLE acc_charts RENAME TO acc_charts_old;
+DROP VIEW acc_accounts_balances;
+
+.read schema.sql
+
+INSERT INTO acc_charts SELECT * FROM acc_charts_old;
+
+-- Reset country if code was official, changing the country should not have been possible
+UPDATE acc_charts SET country = 'FR' WHERE code IS NOT NULL AND code != 'PCMN_2019';
+UPDATE acc_charts SET country = 'BE' WHERE code IS NOT NULL AND code = 'PCMN_2019';
+
+-- Reset country to FR for countries using something similar
+UPDATE acc_charts SET country = 'FR' WHERE country IN ('GN', 'TN', 'RE', 'CN', 'PF', 'MW', 'CI', 'GP', 'GA', 'DE', 'NC');
+
+-- Set country to NULL if outside of supported countries
+UPDATE acc_charts SET country = NULL WHERE country NOT IN ('FR', 'BE', 'CH');
+
+-- Reset type to zero if not supported
+UPDATE acc_accounts SET type = 0 WHERE id_chart IN (SELECT id FROM acc_charts WHERE country IS NULL);
+
+-- Reset other charts is done in PHP code
+
+-- Fix some search
+UPDATE recherches SET contenu = REPLACE(contenu, 'a2.code', 'p.code') WHERE contenu LIKE '%a2.code%';
+
+DROP TABLE acc_charts_old;
\ No newline at end of file
diff --git a/src/include/migrations/1.2/1.2.2.php b/src/include/migrations/1.2/1.2.2.php
new file mode 100644
index 0000000..c57079d
--- /dev/null
+++ b/src/include/migrations/1.2/1.2.2.php
@@ -0,0 +1,31 @@
+ 0
+ AND c.country = \'FR\'
+ GROUP BY a.id;';
+
+$db->begin();
+
+foreach ($db->iterate($sql) as $row) {
+ $new_code = '5120' . substr($row->code, 2);
+
+ if ($db->firstColumn('SELECT 1 FROM acc_accounts WHERE code = ? AND id_chart = ?;', $new_code, $row->id_chart)) {
+ $new_code = '51200' . substr($row->code, 2);
+ }
+
+ // Change code
+ $db->preparedQuery('UPDATE acc_accounts SET code = ?, type = 1, user = ? WHERE id = ?;', [$new_code, $row->chart_code ? 1 : 0, $row->id]);
+
+ // If the account was part of the official chart, put it back in the chart
+ if ($row->chart_code && !$row->user) {
+ $db->preparedQuery('INSERT INTO acc_accounts (id_chart, code, label, position, user) VALUES (?, ?, ?, ?, ?);',
+ [$row->id_chart, $row->code, $row->label, $row->position, 0]);
+ }
+}
+
+$db->commit();
diff --git a/src/include/migrations/1.2/schema.sql b/src/include/migrations/1.2/schema.sql
new file mode 100644
index 0000000..af65bd6
--- /dev/null
+++ b/src/include/migrations/1.2/schema.sql
@@ -0,0 +1,453 @@
+CREATE TABLE IF NOT EXISTS config (
+ key TEXT PRIMARY KEY NOT NULL,
+ value TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS users_categories
+-- Users categories, mainly used to manage rights
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+
+ -- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin
+ perm_web INTEGER NOT NULL DEFAULT 1,
+ perm_documents INTEGER NOT NULL DEFAULT 1,
+ perm_users INTEGER NOT NULL DEFAULT 1,
+ perm_accounting INTEGER NOT NULL DEFAULT 1,
+
+ perm_subscribe INTEGER NOT NULL DEFAULT 0,
+ perm_connect INTEGER NOT NULL DEFAULT 1,
+ perm_config INTEGER NOT NULL DEFAULT 0,
+
+ hidden INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden);
+
+-- Membres de l'asso
+-- Table dynamique générée par l'application
+-- voir Garradin\Membres\Champs.php
+
+CREATE TABLE IF NOT EXISTS membres_sessions
+-- Sessions
+(
+ selecteur TEXT NOT NULL,
+ hash TEXT NOT NULL,
+ id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ expire INT NOT NULL,
+
+ PRIMARY KEY (selecteur, id_membre)
+);
+
+CREATE TABLE IF NOT EXISTS services
+-- Types de services (cotisations)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
+ start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
+ end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
+);
+
+CREATE TABLE IF NOT EXISTS services_fees
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ amount INTEGER NULL,
+ formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)
+
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
+ id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting
+ id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL
+);
+
+CREATE TABLE IF NOT EXISTS services_users
+-- Enregistrement des cotisations et activités
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service
+
+ paid INTEGER NOT NULL DEFAULT 0,
+ expected_amount INTEGER NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, date);
+
+CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
+CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
+CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
+CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date);
+
+CREATE TABLE IF NOT EXISTS services_reminders
+-- Rappels de devoir renouveller une cotisation
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+
+ delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel
+
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS services_reminders_sent
+-- Enregistrement des rappels envoyés à qui et quand
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,
+
+ sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
+ due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);
+
+CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
+CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);
+
+--
+-- COMPTA
+--
+
+CREATE TABLE IF NOT EXISTS acc_charts
+-- Plans comptables : il peut y en avoir plusieurs
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ country TEXT NULL,
+ code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur
+ label TEXT NOT NULL,
+ archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable
+);
+
+CREATE TABLE IF NOT EXISTS acc_accounts
+-- Comptes des plans comptables
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE,
+
+ code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc.
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ position INTEGER NOT NULL, -- position actif/passif/charge/produit
+ type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
+ user INTEGER NOT NULL DEFAULT 1, -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur
+ bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
+CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
+CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);
+CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code);
+
+-- Balance des comptes par exercice
+CREATE VIEW IF NOT EXISTS acc_accounts_balances
+AS
+ SELECT id_year, id, label, code, type, debit, credit, bookmark,
+ CASE -- 3 = dynamic asset or liability depending on balance
+ WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
+ WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
+ ELSE position
+ END AS position,
+ CASE
+ WHEN position IN (1, 4) -- 1 = asset, 4 = expense
+ OR (position = 3 AND (debit - credit) > 0)
+ THEN
+ debit - credit
+ ELSE
+ credit - debit
+ END AS balance,
+ CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt
+ FROM (
+ SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark,
+ SUM(l.credit) AS credit,
+ SUM(l.debit) AS debit
+ FROM acc_accounts a
+ INNER JOIN acc_transactions_lines l ON l.id_account = a.id
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ GROUP BY t.id_year, a.id
+ );
+
+CREATE TABLE IF NOT EXISTS acc_projects
+-- Analytical projects
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ code TEXT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ archived INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code);
+CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code);
+
+CREATE TABLE IF NOT EXISTS acc_years
+-- Exercices
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ label TEXT NOT NULL,
+
+ start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
+ end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),
+
+ closed INTEGER NOT NULL DEFAULT 0,
+
+ id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
+);
+
+CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);
+
+-- Make sure id_account is reset when a year is deleted
+CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
+ UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
+END;
+
+CREATE TABLE IF NOT EXISTS acc_transactions
+-- Opérations comptables
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
+ status INTEGER NOT NULL DEFAULT 0, -- Statut (bitmask)
+
+ label TEXT NOT NULL,
+ notes TEXT NULL,
+ reference TEXT NULL, -- N° de pièce comptable
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable
+
+ hash TEXT NULL,
+ prev_hash TEXT NULL,
+
+ id_year INTEGER NOT NULL REFERENCES acc_years(id),
+ id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL,
+ id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
+CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related);
+CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_lines
+-- Lignes d'écritures d'une opération
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_account INTEGER NOT NULL REFERENCES acc_accounts (id), -- N° du compte dans le plan comptable
+
+ credit INTEGER NOT NULL,
+ debit INTEGER NOT NULL,
+
+ reference TEXT NULL, -- Référence de paiement, eg. numéro de chèque
+ label TEXT NULL,
+
+ reconciled INTEGER NOT NULL DEFAULT 0,
+
+ id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL,
+
+ CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
+ CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_users
+-- Liaison des écritures et des membres
+(
+ id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_user, id_transaction)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ officiel INTEGER NOT NULL DEFAULT 0,
+ nom TEXT NOT NULL,
+ description TEXT NULL,
+ auteur TEXT NULL,
+ url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INTEGER NOT NULL DEFAULT 0,
+ menu_condition TEXT NULL,
+ config TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS plugins_signaux
+-- Association entre plugins et signaux (hooks)
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (id),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS api_credentials
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ label TEXT NOT NULL,
+ key TEXT NOT NULL,
+ secret TEXT NOT NULL,
+ created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_use TEXT NULL,
+ access_level INT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key);
+
+---------- FILES ----------------
+
+CREATE TABLE IF NOT EXISTS files
+-- Files metadata
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ path TEXT NOT NULL,
+ parent TEXT NOT NULL,
+ name TEXT NOT NULL, -- File name
+ type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory
+ mime TEXT NULL,
+ size INT NULL,
+ modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) = modified),
+ image INT NOT NULL DEFAULT 0,
+
+ CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL))
+);
+
+-- Unique index as this is used to make up a file path
+CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path);
+CREATE INDEX IF NOT EXISTS files_parent ON files (parent);
+CREATE INDEX IF NOT EXISTS files_name ON files (name);
+CREATE INDEX IF NOT EXISTS files_modified ON files (modified);
+
+CREATE TABLE IF NOT EXISTS files_contents
+-- Files contents (empty if using another storage backend)
+(
+ id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
+ compressed INT NOT NULL DEFAULT 0,
+ content BLOB NOT NULL
+);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4
+-- Search inside files content
+(
+ tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012)
+ path TEXT NOT NULL,
+ title TEXT NULL,
+ content TEXT NOT NULL, -- Text content
+ notindexed=path
+);
+
+CREATE TABLE IF NOT EXISTS web_pages
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ parent TEXT NOT NULL, -- Parent path, empty = web root
+ path TEXT NOT NULL, -- Full page directory name
+ uri TEXT NOT NULL, -- Page identifier
+ file_path TEXT NOT NULL, -- Full file path for contents
+ type INTEGER NOT NULL, -- 1 = Category, 2 = Page
+ status TEXT NOT NULL,
+ format TEXT NOT NULL,
+ published TEXT NOT NULL CHECK (datetime(published) = published),
+ modified TEXT NOT NULL CHECK (datetime(modified) = modified),
+ title TEXT NOT NULL,
+ content TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path);
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
+CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (parent);
+CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
+CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);
+
+-- FIXME: rename to english
+CREATE TABLE IF NOT EXISTS recherches
+-- Recherches enregistrées
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
+ intitule TEXT NOT NULL,
+ creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
+ cible TEXT NOT NULL, -- "membres" ou "compta"
+ type TEXT NOT NULL, -- "json" ou "sql"
+ contenu TEXT NOT NULL
+);
+
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache
+-- Cache des hash de mots de passe compromis
+(
+ hash TEXT NOT NULL PRIMARY KEY
+);
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
+-- Cache des préfixes de mots de passe compromis
+(
+ prefix TEXT NOT NULL PRIMARY KEY,
+ date INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS emails (
+-- List of emails addresses
+-- We are not storing actual email addresses here for privacy reasons
+-- So that we can keep the record (for opt-out reasons) even when the
+-- email address has been removed from the users table
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL,
+ verified INTEGER NOT NULL DEFAULT 0,
+ optout INTEGER NOT NULL DEFAULT 0,
+ invalid INTEGER NOT NULL DEFAULT 0,
+ fail_count INTEGER NOT NULL DEFAULT 0,
+ sent_count INTEGER NOT NULL DEFAULT 0,
+ fail_log TEXT NULL,
+ last_sent TEXT NULL,
+ added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash);
+
+CREATE TABLE IF NOT EXISTS emails_queue (
+-- List of emails waiting to be sent
+ id INTEGER NOT NULL PRIMARY KEY,
+ sender TEXT NULL,
+ recipient TEXT NOT NULL,
+ recipient_hash TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ content TEXT NOT NULL,
+ content_html TEXT NULL,
+ sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
+ sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started
+ context INTEGER NOT NULL
+);
diff --git a/src/include/migrations/1.3/1.3.0-rc12.sql b/src/include/migrations/1.3/1.3.0-rc12.sql
new file mode 100644
index 0000000..207e300
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc12.sql
@@ -0,0 +1 @@
+UPDATE config_users_fields SET required = 0 WHERE system & (0x01 << 1);
diff --git a/src/include/migrations/1.3/1.3.0-rc13.sql b/src/include/migrations/1.3/1.3.0-rc13.sql
new file mode 100644
index 0000000..05aad9f
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc13.sql
@@ -0,0 +1,34 @@
+ALTER TABLE config_users_fields RENAME TO config_users_fields_old;
+DROP INDEX config_users_fields_name;
+
+CREATE TABLE IF NOT EXISTS config_users_fields (
+ id INTEGER NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ label TEXT NOT NULL,
+ help TEXT NULL,
+ required INTEGER NOT NULL DEFAULT 0,
+ user_access_level INTEGER NOT NULL DEFAULT 0,
+ management_access_level INTEGER NOT NULL DEFAULT 1,
+ list_table INTEGER NOT NULL DEFAULT 0,
+ options TEXT NULL,
+ default_value TEXT NULL,
+ sql TEXT NULL,
+ system TEXT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name);
+
+INSERT INTO config_users_fields
+ SELECT id, name, sort_order, type, label, help, required,
+ CASE WHEN write_access = 1 THEN 2
+ WHEN read_access = 1 THEN 1
+ ELSE 0 END,
+ 1,
+ list_table,
+ options,
+ default_value,
+ sql,
+ system
+ FROM config_users_fields_old;
diff --git a/src/include/migrations/1.3/1.3.0-rc14.php b/src/include/migrations/1.3/1.3.0-rc14.php
new file mode 100644
index 0000000..d10e10b
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc14.php
@@ -0,0 +1,11 @@
+beginSchemaUpdate();
+$db->import(ROOT . '/include/migrations/1.3/1.3.0-rc14.sql');
+$db->commitSchemaUpdate();
+
+Sync::flatten();
diff --git a/src/include/migrations/1.3/1.3.0-rc14.sql b/src/include/migrations/1.3/1.3.0-rc14.sql
new file mode 100644
index 0000000..c17d8c2
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc14.sql
@@ -0,0 +1,41 @@
+ALTER TABLE web_pages RENAME TO web_pages_old;
+DROP INDEX IF EXISTS web_pages_path;
+DROP INDEX IF EXISTS web_pages_dir_path;
+DROP INDEX IF EXISTS web_pages_uri;
+DROP INDEX IF EXISTS web_pages_parent;
+DROP INDEX IF EXISTS web_pages_published;
+DROP INDEX IF EXISTS web_pages_title;
+
+CREATE TABLE IF NOT EXISTS web_pages
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE,
+ uri TEXT NOT NULL, -- Page identifier
+ type INTEGER NOT NULL, -- 1 = Category, 2 = Page
+ status TEXT NOT NULL,
+ format TEXT NOT NULL,
+ published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published),
+ modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
+ title TEXT NOT NULL,
+ content TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
+CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent);
+CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
+CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);
+
+INSERT INTO web_pages SELECT
+ id,
+ (SELECT id FROM web_pages_old WHERE path = a.parent),
+ uri,
+ type,
+ status,
+ format,
+ published,
+ modified,
+ title,
+ content
+ FROM web_pages_old AS a;
+
+DROP TABLE web_pages_old;
diff --git a/src/include/migrations/1.3/1.3.0-rc15.sql b/src/include/migrations/1.3/1.3.0-rc15.sql
new file mode 100644
index 0000000..defd522
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc15.sql
@@ -0,0 +1,26 @@
+-- Replace old placeholders
+UPDATE services_reminders SET body = REPLACE(body, '#IDENTITE', '{{$identity}}'),
+ subject = REPLACE(subject, '#IDENTITE', '');
+UPDATE services_reminders SET body = REPLACE(body, '#NB_JOURS', '{{$nb_days}}'),
+ subject = REPLACE(subject, '#NB_JOURS', '');
+UPDATE services_reminders SET body = REPLACE(body, '#DELAI', '{{$delay}}'),
+ subject = REPLACE(subject, '#DELAI', '');
+UPDATE services_reminders SET body = REPLACE(body, '#DATE_RAPPEL', '{{$reminder_date}}'),
+ subject = REPLACE(subject, '#DATE_RAPPEL', '');
+UPDATE services_reminders SET body = REPLACE(body, '#DATE_EXPIRATION', '{{$expiry_date}}'),
+ subject = REPLACE(subject, '#DATE_EXPIRATION', '');
+
+UPDATE services_reminders SET body = REPLACE(body, '#NOM_ASSO', '{{$config.org_name}}'),
+ subject = REPLACE(subject, '#NOM_ASSO', (SELECT value FROM config WHERE key = 'org_name'));
+UPDATE services_reminders SET body = REPLACE(body, '#ADRESSE_ASSO', '{{$config.org_address}}'),
+ subject = REPLACE(subject, '#ADRESSE_ASSO', '');
+UPDATE services_reminders SET body = REPLACE(body, '#EMAIL_ASSO', '{{$config.org_email}}'),
+ subject = REPLACE(subject, '#EMAIL_ASSO', '');
+UPDATE services_reminders SET body = REPLACE(body, '#SITE_ASSO', '{{$config.org_web}}'),
+ subject = REPLACE(subject, '#SITE_ASSO', '');
+UPDATE services_reminders SET body = REPLACE(body, '#URL_RACINE', '{{$root_url}}'),
+ subject = REPLACE(subject, '#URL_RACINE', '');
+UPDATE services_reminders SET body = REPLACE(body, '#URL_SITE', '{{$site_url}}'),
+ subject = REPLACE(subject, '#URL_SITE', '');
+UPDATE services_reminders SET body = REPLACE(body, '#URL_ADMIN', '{{$admin_url}}'),
+ subject = REPLACE(subject, '#URL_ADMIN', '');
\ No newline at end of file
diff --git a/src/include/migrations/1.3/1.3.0-rc2.php b/src/include/migrations/1.3/1.3.0-rc2.php
new file mode 100644
index 0000000..ecbddd7
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc2.php
@@ -0,0 +1,19 @@
+beginSchemaUpdate();
+
+$fields = DynamicFields::getInstance();
+
+foreach ($fields->all() as $field) {
+ if ($field->type == 'generated') {
+ $field->delete();
+ }
+}
+
+$fields->save();
+
+$db->commitSchemaUpdate();
diff --git a/src/include/migrations/1.3/1.3.0-rc5.php b/src/include/migrations/1.3/1.3.0-rc5.php
new file mode 100644
index 0000000..fe25edb
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc5.php
@@ -0,0 +1,42 @@
+beginSchemaUpdate();
+$db->dropIndexes();
+
+$db->import(ROOT . '/include/migrations/1.3/1.3.0-rc5.sql');
+
+$db->commitSchemaUpdate();
+
+if (FILE_STORAGE_BACKEND == 'FileSystem' && file_exists(FILE_STORAGE_CONFIG)) {
+ // Trash works differently now
+ $db->exec('UPDATE files SET trash = NULL WHERE trash IS NOT NULL;');
+
+ rename(FILE_STORAGE_CONFIG, FILE_STORAGE_CONFIG . '.deprecated');
+ Files::disableVersioning();
+
+ foreach (Files::all() as $file) {
+ if ($file->isDir()) {
+ continue;
+ }
+
+ // Copy files from old location to new
+ $path = sprintf(FILE_STORAGE_CONFIG . '.deprecated/%.2s/%1$s', md5($file->id()));
+
+ if (!file_exists($path)) {
+ $file->delete();
+ continue;
+ }
+
+ $file->store(compact('path'));
+ Utils::safe_unlink($path);
+ }
+}
diff --git a/src/include/migrations/1.3/1.3.0-rc5.sql b/src/include/migrations/1.3/1.3.0-rc5.sql
new file mode 100644
index 0000000..d693c9d
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc5.sql
@@ -0,0 +1,8 @@
+ALTER TABLE web_pages RENAME TO web_pages_old;
+
+.read schema.sql
+
+-- Drop foreign key constant between web_pages and files, as files can just be a cache,
+-- with missing web pages directories
+INSERT INTO web_pages SELECT * FROM web_pages_old;
+DROP TABLE web_pages_old;
diff --git a/src/include/migrations/1.3/1.3.0-rc7.php b/src/include/migrations/1.3/1.3.0-rc7.php
new file mode 100644
index 0000000..1e8f2ef
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0-rc7.php
@@ -0,0 +1,9 @@
+exec('BEGIN;
+ REPLACE INTO config VALUES (\'file_versioning_policy\', \'none\');
+ REPLACE INTO config VALUES (\'file_versioning_max_size\', 5);
+ UPDATE web_pages SET format = \'encrypted\' WHERE format = \'skriv/encrypted\';
+ COMMIT;');
diff --git a/src/include/migrations/1.3/1.3.0.php b/src/include/migrations/1.3/1.3.0.php
new file mode 100644
index 0000000..f8aa883
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0.php
@@ -0,0 +1,214 @@
+beginSchemaUpdate();
+
+Files::disableQuota();
+Files::disableVersioning();
+
+// There seems to be some plugins table left on some database even when the plugin has been removed
+if (!$db->test('plugins', 'id = ?', 'taima')) {
+ $db->exec('
+ DROP TABLE IF EXISTS plugin_taima_entries;
+ DROP TABLE IF EXISTS plugin_taima_tasks;
+ ');
+}
+
+// We need to drop indexes, are they will be left, but linked to old tables
+// and new ones won't be re-created
+$db->dropIndexes();
+
+// Get old keys
+$config = (object) $db->getAssoc('SELECT key, value FROM config WHERE key IN (\'champs_membres\', \'champ_identifiant\', \'champ_identite\');');
+
+// Create config_users_fields table
+$db->exec('
+CREATE TABLE IF NOT EXISTS config_users_fields (
+ id INTEGER NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ label TEXT NOT NULL,
+ help TEXT NULL,
+ required INTEGER NOT NULL DEFAULT 0,
+ user_access_level INTEGER NOT NULL DEFAULT 0,
+ management_access_level INTEGER NOT NULL DEFAULT 1,
+ list_table INTEGER NOT NULL DEFAULT 0,
+ options TEXT NULL,
+ default_value TEXT NULL,
+ sql TEXT NULL,
+ system TEXT NULL
+);');
+
+// Migrate users table
+$df = \Paheko\Users\DynamicFields::fromOldINI($config->champs_membres, $config->champ_identifiant, $config->champ_identite, 'numero');
+$df->save(false);
+
+$trim_field = function (string $name) use ($db) {
+ $db->exec(sprintf("UPDATE users SET %s = TRIM(REPLACE(REPLACE(%1\$s, X'0D' || X'0A', X'0A'), X'0D', X'0A'), ' ' || X'0D' || X'0A') WHERE %1\$s IS NOT NULL AND %1\$s != '';", $db->quoteIdentifier($name)));
+};
+
+// Normalize line breaks in user fields, and trim
+foreach ($df->all() as $name => $field) {
+ if (!$df->isText($name)) {
+ continue;
+ }
+
+ try {
+ $trim_field($name);
+ }
+ catch (DB_Exception $e) {
+ if (false === strpos($e->getMessage(), 'UNIQUE constraint failed')
+ || $name !== $config->champ_identifiant
+ || !$df->get('numero')) {
+ throw $e;
+ }
+
+ // Change login field if current login field is not unique after trim
+ $df->changeLoginField('numero');
+ $trim_field($name);
+ }
+}
+
+// Migrate other stuff
+$db->import(ROOT . '/include/migrations/1.3/1.3.0.sql');
+
+// Reindex all files in search, as moving files was broken
+$db->exec('DELETE FROM files_search;');
+
+Files::ensureContextsExists();
+
+if (FILE_STORAGE_BACKEND == 'FileSystem') {
+ $root = FILE_STORAGE_CONFIG;
+
+ // Move skeletons to new path
+ if (file_exists($root . '/skel')) {
+ if (!file_exists($root . '/modules')) {
+ Utils::safe_mkdir($root . '/modules', 0777, true);
+ }
+
+ if (!file_exists($root . '/modules/web')) {
+ rename($root . '/skel', $root . '/modules/web');
+ }
+ }
+
+ Storage::sync();
+ WebSync::sync();
+}
+else {
+ // Move files from old table to new
+ $db->exec('
+ REPLACE INTO files
+ SELECT f.id, path, parent, name, type, mime, size, modified, image, md5(fc.content), NULL
+ FROM files_old f INNER JOIN files_contents_old fc ON fc.id = f.id;
+ REPLACE INTO files_contents (id, content) SELECT id, content FROM files_contents_old;');
+
+ // Move skeletons from skel/ to modules/web/
+ Files::mkdir('modules/web');
+ $db->exec('UPDATE files SET path = REPLACE(path, \'skel/\', \'modules/web\'), parent = REPLACE(parent, \'skel/\', \'modules/web\')
+ WHERE parent LIKE \'skel/%\';');
+}
+
+$db->exec('
+ DROP TABLE files_contents_old;
+ DROP TABLE files_old;
+');
+
+// Prepend "./" to includes functions file parameter in web skeletons
+foreach (Files::list('modules/web') as $file) {
+ if ($file->type == $file::TYPE_DIRECTORY) {
+ continue;
+ }
+
+ foreach (Files::list(File::CONTEXT_MODULES . '/web') as $file) {
+ if ($file->type != File::TYPE_FILE || !preg_match('/\.(?:txt|css|js|html|htm)$/', $file->name)) {
+ continue;
+ }
+
+ $file->setContent(preg_replace('/(\s+file=")(\w+)/', '$1./$2', $file->fetch()));
+ }
+}
+
+// Update searches
+foreach ($db->iterate('SELECT * FROM searches;') as $row) {
+ if ($row->type == 'json') {
+ $json = json_decode($row->content);
+
+ if (!$json) {
+ $db->delete('searches', 'id = ?', $row->id);
+ continue;
+ }
+
+ $json->groups = $json->query;
+ unset($json->query, $json->limit);
+
+ $content = json_encode($json);
+ }
+ else {
+ $content = preg_replace('/\bmembres\b/', 'users', $row->content);
+ }
+
+ $db->update('searches', ['content' => $content], 'id = ' . (int) $row->id);
+}
+
+// Add signature to config files
+$files = $db->firstColumn('SELECT value FROM config WHERE key = \'files\';');
+$files = json_decode($files);
+$files->signature = null;
+$db->exec(sprintf('REPLACE INTO config (key, value) VALUES (\'files\', %s);', $db->quote(json_encode($files))));
+
+Modules::refresh();
+
+if ($db->test('sqlite_master', 'type = \'table\' AND name = ?', 'plugin_reservations_categories')) {
+ $db->import(__DIR__ . '/1.3.0_bookings.sql');
+ $m = Modules::get('bookings');
+
+ if ($m) {
+ $m->enabled = true;
+ $m->save();
+ }
+}
+
+$db->commitSchemaUpdate();
+
+$db->begin();
+
+// Delete index.txt files
+// This needs to be done AFTER commit schema update as it disables foreign key actions
+foreach (Files::all() as $file) {
+ if ($file->isDir()) {
+ continue;
+ }
+
+ if ($file->context() == $file::CONTEXT_WEB && $file->name == 'index.txt') {
+ $file->delete();
+ continue;
+ }
+
+ // Reindex file contents
+ $file->indexForSearch();
+
+ // Save files in DB
+ $file->save();
+
+}
+
+// Reindex web pages
+foreach (Web::listAll() as $page) {
+ $page->syncSearch();
+}
+
+
+$db->commit();
diff --git a/src/include/migrations/1.3/1.3.0.sql b/src/include/migrations/1.3/1.3.0.sql
new file mode 100644
index 0000000..b367001
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0.sql
@@ -0,0 +1,114 @@
+-- The new users table has already been created and copied
+ALTER TABLE plugins RENAME TO plugins_old;
+ALTER TABLE plugins_signaux RENAME TO plugins_signaux_old;
+
+-- References old membres table
+ALTER TABLE services_users RENAME TO services_users_old; -- Also take id_fee into account for unique key
+ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old;
+ALTER TABLE acc_transactions RENAME TO acc_transactions_old;
+ALTER TABLE acc_transactions_users RENAME TO acc_transactions_users_old;
+
+-- Fix error on foreign key
+ALTER TABLE acc_charts RENAME TO acc_charts_old;
+
+ALTER TABLE emails_queue RENAME TO emails_queue_old;
+
+DROP VIEW acc_accounts_balances;
+
+ALTER TABLE files RENAME TO files_old;
+ALTER TABLE files_contents RENAME TO files_contents_old;
+ALTER TABLE web_pages RENAME TO web_pages_old;
+
+UPDATE web_pages_old SET format = 'encrypted' WHERE format = 'skriv/encrypted';
+
+.read 1.3.0_schema.sql
+
+INSERT OR IGNORE INTO web_pages
+ SELECT id,
+ CASE WHEN parent = '' THEN NULL ELSE (SELECT id FROM web_pages_old WHERE uri = replace(a.parent, rtrim(a.parent, replace(a.parent, '/', '')), '')) END,
+ uri, type, status, format, published, modified, title, content
+ FROM web_pages_old AS a;
+DROP TABLE web_pages_old;
+
+UPDATE acc_charts_old SET country = 'FR' WHERE country IS NULL;
+INSERT INTO acc_charts SELECT * FROM acc_charts_old;
+DROP TABLE acc_charts_old;
+
+-- Add recipient_pgp_key column
+INSERT INTO emails_queue
+ SELECT id, sender, recipient, recipient_hash, NULL, subject, content, content_html, sending, sending_started, context
+ FROM emails_queue_old;
+
+DROP TABLE emails_queue_old;
+
+INSERT INTO users_sessions SELECT * FROM membres_sessions;
+DROP TABLE membres_sessions;
+
+INSERT INTO services_users SELECT * FROM services_users_old;
+
+INSERT INTO services_reminders_sent SELECT * FROM services_reminders_sent_old;
+
+INSERT INTO acc_transactions
+ SELECT
+ id, type, status, label, notes, reference, date,
+ NULL, NULL, NULL, --hash/prev_id/prev_hash
+ id_year, id_creator, id_related
+ FROM acc_transactions_old;
+
+INSERT INTO acc_transactions_users SELECT * FROM acc_transactions_users_old;
+
+DROP TABLE services_reminders_sent_old;
+DROP TABLE acc_transactions_users_old;
+DROP TABLE acc_transactions_old;
+DROP TABLE services_users_old;
+
+-- Remove old plugin as it cannot be uninstalled as it no longer exists
+DELETE FROM plugins_old WHERE id = 'ouvertures';
+DELETE FROM plugins_signaux_old WHERE plugin = 'ouvertures';
+
+-- Delete old reservations plugin
+DELETE FROM plugins_old WHERE id = 'reservations';
+
+-- Rename plugins table columns to English
+INSERT INTO plugins (name, label, description, author, author_url, version, config, enabled, menu, restrict_level, restrict_section)
+ SELECT id, nom, description, auteur, url, version, config, 1, menu,
+ CASE WHEN menu_condition IS NOT NULL THEN 2 ELSE NULL END,
+ CASE WHEN menu_condition IS NOT NULL THEN 'users' ELSE NULL END
+ FROM plugins_old;
+INSERT INTO plugins_signals SELECT * FROM plugins_signaux_old;
+
+DROP TABLE plugins_signaux_old;
+DROP TABLE plugins_old;
+
+INSERT INTO searches SELECT * FROM recherches;
+UPDATE searches SET target = 'accounting' WHERE target = 'compta';
+UPDATE searches SET target = 'users' WHERE target = 'membres';
+
+DROP TABLE recherches;
+
+INSERT INTO config VALUES ('log_retention', 365);
+
+-- Rename config keys to english
+UPDATE config SET key = 'default_category' WHERE key = 'categorie_membres';
+UPDATE config SET key = 'color1' WHERE key = 'couleur1';
+UPDATE config SET key = 'color2' WHERE key = 'couleur2';
+UPDATE config SET key = 'country' WHERE key = 'pays';
+UPDATE config SET key = 'currency' WHERE key = 'monnaie';
+UPDATE config SET key = 'backup_frequency' WHERE key = 'frequence_sauvegardes';
+UPDATE config SET key = 'backup_limit' WHERE key = 'nombre_sauvegardes';
+
+UPDATE config SET key = 'org_name' WHERE key = 'nom_asso';
+UPDATE config SET key = 'org_address' WHERE key = 'adresse_asso';
+UPDATE config SET key = 'org_email' WHERE key = 'email_asso';
+UPDATE config SET key = 'org_phone' WHERE key = 'telephone_asso';
+UPDATE config SET key = 'org_web' WHERE key = 'site_asso';
+
+-- This is now part of the config_users_fields table
+DELETE FROM config WHERE key IN ('champs_membres', 'champ_identite', 'champ_identifiant');
+
+-- Seems that some installations had this leftover? Let's just drop it.
+DROP TABLE IF EXISTS srs_old;
+
+-- Drop membres
+DROP TABLE IF EXISTS membres;
+
diff --git a/src/include/migrations/1.3/1.3.0_bookings.sql b/src/include/migrations/1.3/1.3.0_bookings.sql
new file mode 100644
index 0000000..5068b45
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0_bookings.sql
@@ -0,0 +1,43 @@
+CREATE TABLE IF NOT EXISTS module_data_bookings (
+ id INTEGER NOT NULL PRIMARY KEY,
+ key TEXT NULL,
+ document TEXT NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS module_data_bookings_key ON module_data_bookings (key);
+
+INSERT INTO module_data_bookings SELECT
+ NULL, uuid(),
+ json_object(
+ 'type', 'event',
+ 'label', nom,
+ 'description', description,
+ 'use_openings', json('false'),
+ 'openings_seats', NULL,
+ 'openings_slots', NULL,
+ 'openings_delay', NULL,
+ 'use_closings', json('false'),
+ 'fields', json_array(),
+ 'email', NULL,
+ 'archived', json('false')
+ )
+ FROM plugin_reservations_categories;
+
+INSERT INTO module_data_bookings SELECT
+ NULL, uuid(),
+ json_object(
+ 'type', 'booking',
+ 'event', (SELECT key FROM module_data_bookings
+ WHERE json_extract(document, '$.label') = (SELECT nom FROM plugin_reservations_categories
+ WHERE id = (SELECT categorie FROM plugin_reservations_creneaux WHERE id = plugin_reservations_personnes.creneau))),
+ 'slot', NULL,
+ 'date', date,
+ 'name', nom,
+ 'email', NULL,
+ 'id_user', NULL,
+ 'fields', NULL
+ )
+ FROM plugin_reservations_personnes;
+
+DROP TABLE plugin_reservations_categories;
+DROP TABLE plugin_reservations_creneaux;
+DROP TABLE plugin_reservations_personnes;
diff --git a/src/include/migrations/1.3/1.3.0_schema.sql b/src/include/migrations/1.3/1.3.0_schema.sql
new file mode 100644
index 0000000..db7e618
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.0_schema.sql
@@ -0,0 +1,607 @@
+---
+--- Main stuff
+---
+
+CREATE TABLE IF NOT EXISTS config (
+-- Configuration, key/value store
+ key TEXT PRIMARY KEY NOT NULL,
+ value TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS config_users_fields (
+ id INTEGER NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ label TEXT NOT NULL,
+ help TEXT NULL,
+ required INTEGER NOT NULL DEFAULT 0,
+ user_access_level INTEGER NOT NULL DEFAULT 0,
+ management_access_level INTEGER NOT NULL DEFAULT 1,
+ list_table INTEGER NOT NULL DEFAULT 0,
+ options TEXT NULL,
+ default_value TEXT NULL,
+ sql TEXT NULL,
+ system TEXT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ label TEXT NOT NULL,
+ description TEXT NULL,
+ author TEXT NULL,
+ author_url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INT NOT NULL DEFAULT 0,
+ home_button INT NOT NULL DEFAULT 0,
+ restrict_section TEXT NULL,
+ restrict_level INT NULL,
+ config TEXT NULL,
+ enabled INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS plugins_name ON plugins (name);
+
+CREATE TABLE IF NOT EXISTS plugins_signals
+-- Link between plugins and signals
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (name),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS modules
+-- List of modules
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ label TEXT NOT NULL,
+ description TEXT NULL,
+ author TEXT NULL,
+ author_url TEXT NULL,
+ menu INT NOT NULL DEFAULT 0,
+ home_button INT NOT NULL DEFAULT 0,
+ restrict_section TEXT NULL,
+ restrict_level INT NULL,
+ config TEXT NULL,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ web INTEGER NOT NULL DEFAULT 0,
+ system INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name);
+
+CREATE TABLE IF NOT EXISTS modules_templates
+-- List of forms special templates
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
+ name TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name);
+
+CREATE TABLE IF NOT EXISTS api_credentials
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ label TEXT NOT NULL,
+ key TEXT NOT NULL,
+ secret TEXT NOT NULL,
+ created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_use TEXT NULL,
+ access_level INT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key);
+
+CREATE TABLE IF NOT EXISTS searches
+-- Saved searches
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, -- If not NULL, then search will only be visible by this user
+ label TEXT NOT NULL,
+ created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created),
+ target TEXT NOT NULL, -- "users" ou "accounting"
+ type TEXT NOT NULL, -- "json" ou "sql"
+ content TEXT NOT NULL
+);
+
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache
+-- Cache des hash de mots de passe compromis
+(
+ hash TEXT NOT NULL PRIMARY KEY
+);
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
+-- Cache des préfixes de mots de passe compromis
+(
+ prefix TEXT NOT NULL PRIMARY KEY,
+ date INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS emails (
+-- List of emails addresses
+-- We are not storing actual email addresses here for privacy reasons
+-- So that we can keep the record (for opt-out reasons) even when the
+-- email address has been removed from the users table
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL,
+ verified INTEGER NOT NULL DEFAULT 0,
+ optout INTEGER NOT NULL DEFAULT 0,
+ invalid INTEGER NOT NULL DEFAULT 0,
+ fail_count INTEGER NOT NULL DEFAULT 0,
+ sent_count INTEGER NOT NULL DEFAULT 0,
+ fail_log TEXT NULL,
+ last_sent TEXT NULL,
+ added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash);
+
+CREATE TABLE IF NOT EXISTS emails_queue (
+-- List of emails waiting to be sent
+ id INTEGER NOT NULL PRIMARY KEY,
+ sender TEXT NULL,
+ recipient TEXT NOT NULL,
+ recipient_hash TEXT NOT NULL,
+ recipient_pgp_key TEXT NULL,
+ subject TEXT NOT NULL,
+ content TEXT NOT NULL,
+ content_html TEXT NULL,
+ sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
+ sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started
+ context INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS emails_queue_attachments (
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_queue INTEGER NOT NULL REFERENCES emails_queue (id) ON DELETE CASCADE,
+ path TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS mailings (
+ id INTEGER NOT NULL PRIMARY KEY,
+ subject TEXT NOT NULL,
+ body TEXT NULL,
+ sender_name TEXT NULL,
+ sender_email TEXT NULL,
+ sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent),
+ anonymous INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent);
+
+CREATE TABLE IF NOT EXISTS mailings_recipients (
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE,
+ email TEXT NULL,
+ id_email TEXT NULL REFERENCES emails (id) ON DELETE CASCADE,
+ extra_data TEXT NULL
+);
+
+CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id);
+
+---
+--- Users
+---
+
+-- CREATE TABLE users (...);
+-- Organization users table, dynamically created, see config_users_fields table
+
+CREATE TABLE IF NOT EXISTS users_categories
+-- Users categories, mainly used to manage rights
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+
+ -- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin
+ perm_web INTEGER NOT NULL DEFAULT 1,
+ perm_documents INTEGER NOT NULL DEFAULT 1,
+ perm_users INTEGER NOT NULL DEFAULT 1,
+ perm_accounting INTEGER NOT NULL DEFAULT 1,
+
+ perm_subscribe INTEGER NOT NULL DEFAULT 0,
+ perm_connect INTEGER NOT NULL DEFAULT 1,
+ perm_config INTEGER NOT NULL DEFAULT 0,
+
+ hidden INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden);
+CREATE INDEX IF NOT EXISTS users_categories_name ON users_categories (name);
+CREATE INDEX IF NOT EXISTS users_categories_hidden_name ON users_categories (hidden, name);
+
+CREATE TABLE IF NOT EXISTS users_sessions
+-- Permanent sessions for logged-in users
+(
+ selector TEXT NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL,
+ id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
+ expiry INT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS logs
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
+ type INTEGER NOT NULL,
+ details TEXT NULL,
+ created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created),
+ ip_address TEXT NULL
+);
+
+CREATE INDEX IF NOT EXISTS logs_ip ON logs (ip_address, type, created);
+CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, type, created);
+CREATE INDEX IF NOT EXISTS logs_created ON logs (created);
+
+---
+--- Services
+---
+
+CREATE TABLE IF NOT EXISTS services
+-- Services types (French: cotisations)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
+ start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
+ end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
+);
+
+CREATE TABLE IF NOT EXISTS services_fees
+-- Services fees
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ amount INTEGER NULL,
+ formula TEXT NULL, -- Formula to calculate fee amount dynamically (this contains a SQL statement)
+
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
+ id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting
+ id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL
+);
+
+CREATE TABLE IF NOT EXISTS services_users
+-- Records of services and fees linked to users
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service
+
+ paid INTEGER NOT NULL DEFAULT 0,
+ expected_amount INTEGER NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, id_fee, date);
+
+CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
+CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
+CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
+CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date);
+
+CREATE TABLE IF NOT EXISTS services_reminders
+-- Reminders for service expiry
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+
+ delay INTEGER NOT NULL, -- Delay in days before or after expiry date
+
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS services_reminders_sent
+-- Records of sent reminders, to keep track
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,
+
+ sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
+ due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);
+
+CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
+CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);
+
+--
+-- Accounting
+--
+
+CREATE TABLE IF NOT EXISTS acc_charts
+-- Accounting charts (plans comptables)
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ country TEXT NOT NULL,
+ code TEXT NULL, -- the code is NULL if the chart is user-created or imported
+ label TEXT NOT NULL,
+ archived INTEGER NOT NULL DEFAULT 0 -- 1 = archived, cannot be changed
+);
+
+CREATE TABLE IF NOT EXISTS acc_accounts
+-- Accounts of the charts (comptes)
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_chart INTEGER NOT NULL REFERENCES acc_charts (id) ON DELETE CASCADE,
+
+ code TEXT NOT NULL, -- can contain numbers and letters, eg. 53A, 53B...
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ position INTEGER NOT NULL, -- position in the balance sheet (position actif/passif/charge/produit)
+ type INTEGER NOT NULL DEFAULT 0, -- type (category) of favourite account: bank, cash, third party, etc.
+ user INTEGER NOT NULL DEFAULT 1, -- 0 = is part of the original chart, 0 = has been added by the user
+ bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
+CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
+CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);
+CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code);
+
+-- Balance des comptes par exercice
+CREATE VIEW IF NOT EXISTS acc_accounts_balances
+AS
+ SELECT id_year, id, label, code, type, debit, credit, bookmark,
+ CASE -- 3 = dynamic asset or liability depending on balance
+ WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
+ WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
+ ELSE position
+ END AS position,
+ CASE
+ WHEN position IN (1, 4) -- 1 = asset, 4 = expense
+ OR (position = 3 AND (debit - credit) > 0)
+ THEN
+ debit - credit
+ ELSE
+ credit - debit
+ END AS balance,
+ CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt
+ FROM (
+ SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark,
+ SUM(l.credit) AS credit,
+ SUM(l.debit) AS debit
+ FROM acc_accounts a
+ INNER JOIN acc_transactions_lines l ON l.id_account = a.id
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ GROUP BY t.id_year, a.id
+ );
+CREATE TABLE IF NOT EXISTS acc_projects
+-- Analytical projects
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ code TEXT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ archived INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code);
+CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code);
+
+CREATE TABLE IF NOT EXISTS acc_years
+-- Years (exercices)
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ label TEXT NOT NULL,
+
+ start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
+ end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),
+
+ closed INTEGER NOT NULL DEFAULT 0, -- 0 = open, 1 = closed
+
+ id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
+);
+
+CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);
+
+-- Make sure id_account is reset when a year is deleted
+CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
+ UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
+END;
+
+CREATE TABLE IF NOT EXISTS acc_transactions
+-- Transactions (écritures comptables)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced
+ status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask)
+
+ label TEXT NOT NULL,
+ notes TEXT NULL,
+ reference TEXT NULL, -- N° de pièce comptable
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ hash TEXT NULL,
+ prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL,
+ prev_hash TEXT NULL,
+
+ id_year INTEGER NOT NULL REFERENCES acc_years(id),
+ id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
+ id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- linked transaction (eg. payment of a debt)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
+CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related);
+CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);
+CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash);
+CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_lines
+-- Transactions lines (lignes des écritures)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_account INTEGER NOT NULL REFERENCES acc_accounts (id),
+
+ credit INTEGER NOT NULL,
+ debit INTEGER NOT NULL,
+
+ reference TEXT NULL, -- Usually a payment reference (par exemple numéro de chèque)
+ label TEXT NULL,
+
+ reconciled INTEGER NOT NULL DEFAULT 0,
+
+ id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL,
+
+ CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
+ CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_users
+-- Linking transactions and users
+(
+ id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_user, id_transaction, id_service_user)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);
+
+---------- FILES ----------------
+
+CREATE TABLE IF NOT EXISTS files
+-- Files metadata
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ path TEXT NOT NULL,
+ parent TEXT NULL REFERENCES files(path) ON DELETE CASCADE ON UPDATE CASCADE,
+ name TEXT NOT NULL, -- File name
+ type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory
+ mime TEXT NULL,
+ size INT NULL,
+ modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
+ image INT NOT NULL DEFAULT 0,
+ md5 TEXT NULL,
+ trash TEXT NULL CHECK (datetime(trash) IS NULL OR datetime(trash) = trash),
+
+ CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL))
+);
+
+-- Unique index as this is used to make up a file path
+CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path);
+CREATE INDEX IF NOT EXISTS files_parent ON files (parent);
+CREATE INDEX IF NOT EXISTS files_type_parent ON files (type, parent, path);
+CREATE INDEX IF NOT EXISTS files_name ON files (name);
+CREATE INDEX IF NOT EXISTS files_modified ON files (modified);
+CREATE INDEX IF NOT EXISTS files_trash ON files (trash);
+CREATE INDEX IF NOT EXISTS files_size ON files (size);
+
+CREATE TABLE IF NOT EXISTS files_contents
+-- Files contents (empty if using another storage backend)
+(
+ id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
+ content BLOB NOT NULL
+);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4
+-- Search inside files content
+(
+ tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012)
+ path TEXT NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT NULL, -- Text content
+ notindexed=path
+);
+
+-- Delete/insert search item when item is deleted/inserted from files
+CREATE TRIGGER IF NOT EXISTS files_search_bd BEFORE DELETE ON files BEGIN
+ DELETE FROM files_search WHERE docid = OLD.rowid;
+END;
+
+CREATE TRIGGER IF NOT EXISTS files_search_ai AFTER INSERT ON files BEGIN
+ INSERT INTO files_search (docid, path, title, content) VALUES (NEW.rowid, NEW.path, NEW.name, NULL);
+END;
+
+CREATE TRIGGER IF NOT EXISTS files_search_au AFTER UPDATE OF name, path ON files BEGIN
+ UPDATE files_search SET path = NEW.path, title = NEW.name WHERE docid = NEW.rowid;
+END;
+
+CREATE TABLE IF NOT EXISTS acc_transactions_files
+-- Link between transactions and files
+(
+ id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_files_transaction ON acc_transactions_files (id_transaction);
+
+CREATE TABLE IF NOT EXISTS users_files
+-- Link between users and files
+(
+ id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
+ id_user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ field TEXT NOT NULL REFERENCES config_users_fields (name) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS users_files_user ON users_files (id_user);
+CREATE INDEX IF NOT EXISTS users_files_user_field ON users_files (id_user, field);
+
+CREATE TABLE IF NOT EXISTS web_pages
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE,
+ uri TEXT NOT NULL, -- Page identifier
+ type INTEGER NOT NULL, -- 1 = Category, 2 = Page
+ status TEXT NOT NULL,
+ format TEXT NOT NULL,
+ published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published),
+ modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
+ title TEXT NOT NULL,
+ content TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
+CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent);
+CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
+CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);
+
+CREATE TABLE IF NOT EXISTS web_pages_versions
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_page INTEGER NOT NULL REFERENCES web_pages ON DELETE CASCADE,
+ id_user INTEGER NULL REFERENCES users (id) ON DELETE SET NULL,
+ date TEXT NOT NULL CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+ size INTEGER NOT NULL,
+ changes INTEGER NOT NULL,
+ content TEXT NOT NULL
+);
diff --git a/src/include/migrations/1.3/1.3.2.sql b/src/include/migrations/1.3/1.3.2.sql
new file mode 100644
index 0000000..180fff0
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.2.sql
@@ -0,0 +1 @@
+UPDATE config_users_fields SET default_value = 'NOW()' WHERE default_value = '''=NOW''' OR default_value = '=NOW';
diff --git a/src/include/migrations/1.3/1.3.3.sql b/src/include/migrations/1.3/1.3.3.sql
new file mode 100644
index 0000000..f34e4f0
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.3.sql
@@ -0,0 +1,57 @@
+CREATE TABLE IF NOT EXISTS acc_transactions_links
+(
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE,
+ id_related INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE CHECK (id_transaction != id_related),
+ PRIMARY KEY (id_transaction, id_related)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_transaction ON acc_transactions_links (id_transaction);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_related ON acc_transactions_links (id_related);
+
+-- Move id_related to separate many-to-many table
+INSERT INTO acc_transactions_links SELECT id, id_related FROM acc_transactions WHERE id_related IS NOT NULL;
+
+DROP INDEX acc_transactions_related;
+DROP INDEX acc_transactions_year;
+DROP INDEX acc_transactions_date;
+DROP INDEX acc_transactions_type;
+DROP INDEX acc_transactions_status;
+DROP INDEX acc_transactions_hash;
+DROP INDEX acc_transactions_reference;
+
+ALTER TABLE acc_transactions RENAME TO acc_transactions_old;
+
+CREATE TABLE IF NOT EXISTS acc_transactions
+-- Transactions (écritures comptables)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced
+ status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask)
+
+ label TEXT NOT NULL,
+ notes TEXT NULL,
+ reference TEXT NULL, -- N° de pièce comptable
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ hash TEXT NULL,
+ prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL,
+ prev_hash TEXT NULL,
+
+ id_year INTEGER NOT NULL REFERENCES acc_years(id),
+ id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
+CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);
+CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash);
+CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference);
+
+INSERT INTO acc_transactions
+ SELECT id, type, status, label, notes, reference, date, hash, prev_id, prev_hash, id_year, id_creator
+ FROM acc_transactions_old;
+
+DROP TABLE acc_transactions_old;
diff --git a/src/include/migrations/1.3/1.3.5.sql b/src/include/migrations/1.3/1.3.5.sql
new file mode 100644
index 0000000..c5c29f3
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.5.sql
@@ -0,0 +1 @@
+CREATE VIEW IF NOT EXISTS users_view AS SELECT * FROM users;
diff --git a/src/include/migrations/1.3/1.3.6.php b/src/include/migrations/1.3/1.3.6.php
new file mode 100644
index 0000000..9d4258e
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.6.php
@@ -0,0 +1,12 @@
+beginSchemaUpdate();
+$db->import(ROOT . '/include/migrations/1.3/1.3.6.sql');
+$db->commitSchemaUpdate();
+
diff --git a/src/include/migrations/1.3/1.3.6.sql b/src/include/migrations/1.3/1.3.6.sql
new file mode 100644
index 0000000..eacf55b
--- /dev/null
+++ b/src/include/migrations/1.3/1.3.6.sql
@@ -0,0 +1,111 @@
+-- Delete old unmaintained plugin
+DELETE FROM plugins_signals WHERE plugin = 'git_documents';
+DELETE FROM plugins WHERE name = 'git_documents';
+
+-- Fix access level of number field
+UPDATE config_users_fields SET user_access_level = 1 WHERE user_access_level = 2 AND name = 'numero';
+
+-- Update services to add archived column
+ALTER TABLE services RENAME TO services_old;
+
+CREATE TABLE IF NOT EXISTS services
+-- Services types (French: cotisations)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
+ start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
+ end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))),
+
+ archived INTEGER NOT NULL DEFAULT 0
+);
+
+INSERT INTO services
+ SELECT *, CASE WHEN end_date IS NOT NULL AND end_date < datetime() THEN 1 ELSE 0 END FROM services_old;
+
+DROP TABLE services_old;
+
+-- Update services_reminders to add not_before_date
+ALTER TABLE services_reminders RENAME TO services_reminders_old;
+
+CREATE TABLE IF NOT EXISTS services_reminders
+-- Reminders for service expiry
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+
+ delay INTEGER NOT NULL, -- Delay in days before or after expiry date
+
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL,
+
+ not_before_date TEXT NULL CHECK (date(not_before_date) IS NULL OR date(not_before_date) = not_before_date) -- Don't send reminder to users if they expire before this date
+);
+
+INSERT INTO services_reminders SELECT *, NULL FROM services_reminders_old;
+DROP TABLE services_reminders_old;
+
+ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old;
+DROP INDEX IF EXISTS srs_index;
+DROP INDEX IF EXISTS srs_reminder;
+DROP INDEX IF EXISTS srs_user;
+
+-- Allow NULL for id_reminder
+CREATE TABLE IF NOT EXISTS services_reminders_sent
+-- Records of sent reminders, to keep track
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_reminder INTEGER NULL REFERENCES services_reminders (id) ON DELETE SET NULL,
+
+ sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
+ due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
+);
+
+INSERT INTO services_reminders_sent SELECT * FROM services_reminders_sent_old;
+DROP TABLE services_reminders_sent_old;
+
+CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);
+
+CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
+CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);
+
+-- Update mailings
+ALTER TABLE mailings RENAME TO mailings_old;
+
+DROP INDEX IF EXISTS mailings_sent;
+
+CREATE TABLE IF NOT EXISTS mailings (
+ id INTEGER NOT NULL PRIMARY KEY,
+ subject TEXT NOT NULL,
+ body TEXT NULL,
+ target_type TEXT NULL,
+ target_value TEXT NULL,
+ target_label TEXT NULL,
+ sender_name TEXT NULL,
+ sender_email TEXT NULL,
+ sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent),
+ anonymous INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent);
+
+INSERT INTO mailings (id, subject, body, sender_name, sender_email, sent, anonymous)
+ SELECT id, subject, body, sender_name, sender_email, sent, anonymous
+ FROM mailings_old;
+
+DROP TABLE mailings_old;
+
+CREATE TABLE IF NOT EXISTS mailings_optouts (
+ email_hash TEXT NOT NULL,
+ target_type TEXT NOT NULL,
+ target_value TEXT NOT NULL,
+ target_label TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS mailings_optouts_unique ON mailings_optouts (email_hash, target_type, target_value);
diff --git a/src/include/migrations/1.3/schema.sql b/src/include/migrations/1.3/schema.sql
new file mode 100644
index 0000000..5586c75
--- /dev/null
+++ b/src/include/migrations/1.3/schema.sql
@@ -0,0 +1,630 @@
+---
+--- Main stuff
+---
+
+CREATE TABLE IF NOT EXISTS config (
+-- Configuration, key/value store
+ key TEXT PRIMARY KEY NOT NULL,
+ value TEXT NULL
+);
+
+CREATE TABLE IF NOT EXISTS config_users_fields (
+ id INTEGER NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ label TEXT NOT NULL,
+ help TEXT NULL,
+ required INTEGER NOT NULL DEFAULT 0,
+ user_access_level INTEGER NOT NULL DEFAULT 0,
+ management_access_level INTEGER NOT NULL DEFAULT 1,
+ list_table INTEGER NOT NULL DEFAULT 0,
+ options TEXT NULL,
+ default_value TEXT NULL,
+ sql TEXT NULL,
+ system TEXT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name);
+
+CREATE TABLE IF NOT EXISTS plugins
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ label TEXT NOT NULL,
+ description TEXT NULL,
+ author TEXT NULL,
+ author_url TEXT NULL,
+ version TEXT NOT NULL,
+ menu INT NOT NULL DEFAULT 0,
+ home_button INT NOT NULL DEFAULT 0,
+ restrict_section TEXT NULL,
+ restrict_level INT NULL,
+ config TEXT NULL,
+ enabled INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS plugins_name ON plugins (name);
+
+CREATE TABLE IF NOT EXISTS plugins_signals
+-- Link between plugins and signals
+(
+ signal TEXT NOT NULL,
+ plugin TEXT NOT NULL REFERENCES plugins (name),
+ callback TEXT NOT NULL,
+ PRIMARY KEY (signal, plugin)
+);
+
+CREATE TABLE IF NOT EXISTS modules
+-- List of modules
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ label TEXT NOT NULL,
+ description TEXT NULL,
+ author TEXT NULL,
+ author_url TEXT NULL,
+ menu INT NOT NULL DEFAULT 0,
+ home_button INT NOT NULL DEFAULT 0,
+ restrict_section TEXT NULL,
+ restrict_level INT NULL,
+ config TEXT NULL,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ web INTEGER NOT NULL DEFAULT 0,
+ system INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name);
+
+CREATE TABLE IF NOT EXISTS modules_templates
+-- List of forms special templates
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
+ name TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name);
+
+CREATE TABLE IF NOT EXISTS api_credentials
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ label TEXT NOT NULL,
+ key TEXT NOT NULL,
+ secret TEXT NOT NULL,
+ created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_use TEXT NULL,
+ access_level INT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key);
+
+CREATE TABLE IF NOT EXISTS searches
+-- Saved searches
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, -- If not NULL, then search will only be visible by this user
+ label TEXT NOT NULL,
+ created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created),
+ target TEXT NOT NULL, -- "users" ou "accounting"
+ type TEXT NOT NULL, -- "json" ou "sql"
+ content TEXT NOT NULL
+);
+
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache
+-- Cache des hash de mots de passe compromis
+(
+ hash TEXT NOT NULL PRIMARY KEY
+);
+
+CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
+-- Cache des préfixes de mots de passe compromis
+(
+ prefix TEXT NOT NULL PRIMARY KEY,
+ date INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS emails (
+-- List of emails addresses
+-- We are not storing actual email addresses here for privacy reasons
+-- So that we can keep the record (for opt-out reasons) even when the
+-- email address has been removed from the users table
+ id INTEGER NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL,
+ verified INTEGER NOT NULL DEFAULT 0,
+ optout INTEGER NOT NULL DEFAULT 0,
+ invalid INTEGER NOT NULL DEFAULT 0,
+ fail_count INTEGER NOT NULL DEFAULT 0,
+ sent_count INTEGER NOT NULL DEFAULT 0,
+ fail_log TEXT NULL,
+ last_sent TEXT NULL,
+ added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash);
+
+CREATE TABLE IF NOT EXISTS emails_queue (
+-- List of emails waiting to be sent
+ id INTEGER NOT NULL PRIMARY KEY,
+ sender TEXT NULL,
+ recipient TEXT NOT NULL,
+ recipient_hash TEXT NOT NULL,
+ recipient_pgp_key TEXT NULL,
+ subject TEXT NOT NULL,
+ content TEXT NOT NULL,
+ content_html TEXT NULL,
+ sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
+ sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started
+ context INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS emails_queue_attachments (
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_queue INTEGER NOT NULL REFERENCES emails_queue (id) ON DELETE CASCADE,
+ path TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS mailings (
+ id INTEGER NOT NULL PRIMARY KEY,
+ subject TEXT NOT NULL,
+ body TEXT NULL,
+ target_type TEXT NULL,
+ target_value TEXT NULL,
+ target_label TEXT NULL,
+ sender_name TEXT NULL,
+ sender_email TEXT NULL,
+ sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent),
+ anonymous INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent);
+
+CREATE TABLE IF NOT EXISTS mailings_recipients (
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE,
+ email TEXT NULL,
+ id_email TEXT NULL REFERENCES emails (id) ON DELETE CASCADE,
+ extra_data TEXT NULL
+);
+
+CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id);
+
+CREATE TABLE IF NOT EXISTS mailings_optouts (
+ email_hash TEXT NOT NULL,
+ target_type TEXT NOT NULL,
+ target_value TEXT NOT NULL,
+ target_label TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS mailings_optouts_unique ON mailings_optouts (email_hash, target_type, target_value);
+
+---
+--- Users
+---
+
+-- CREATE TABLE users (...);
+-- Organization users table, dynamically created, see config_users_fields table
+
+CREATE TABLE IF NOT EXISTS users_categories
+-- Users categories, mainly used to manage rights
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+
+ -- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin
+ perm_web INTEGER NOT NULL DEFAULT 1,
+ perm_documents INTEGER NOT NULL DEFAULT 1,
+ perm_users INTEGER NOT NULL DEFAULT 1,
+ perm_accounting INTEGER NOT NULL DEFAULT 1,
+
+ perm_subscribe INTEGER NOT NULL DEFAULT 0,
+ perm_connect INTEGER NOT NULL DEFAULT 1,
+ perm_config INTEGER NOT NULL DEFAULT 0,
+
+ hidden INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden);
+CREATE INDEX IF NOT EXISTS users_categories_name ON users_categories (name);
+CREATE INDEX IF NOT EXISTS users_categories_hidden_name ON users_categories (hidden, name);
+
+CREATE TABLE IF NOT EXISTS users_sessions
+-- Permanent sessions for logged-in users
+(
+ selector TEXT NOT NULL PRIMARY KEY,
+ hash TEXT NOT NULL,
+ id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
+ expiry INT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS logs
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
+ type INTEGER NOT NULL,
+ details TEXT NULL,
+ created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created),
+ ip_address TEXT NULL
+);
+
+CREATE INDEX IF NOT EXISTS logs_ip ON logs (ip_address, type, created);
+CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, type, created);
+CREATE INDEX IF NOT EXISTS logs_created ON logs (created);
+
+---
+--- Services
+---
+
+CREATE TABLE IF NOT EXISTS services
+-- Services types (French: cotisations)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
+ start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
+ end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
+);
+
+CREATE TABLE IF NOT EXISTS services_fees
+-- Services fees
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ amount INTEGER NULL,
+ formula TEXT NULL, -- Formula to calculate fee amount dynamically (this contains a SQL statement)
+
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
+ id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting
+ id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL
+);
+
+CREATE TABLE IF NOT EXISTS services_users
+-- Records of services and fees linked to users
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service
+
+ paid INTEGER NOT NULL DEFAULT 0,
+ expected_amount INTEGER NULL,
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+ expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, id_fee, date);
+
+CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
+CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
+CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
+CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date);
+
+CREATE TABLE IF NOT EXISTS services_reminders
+-- Reminders for service expiry
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+
+ delay INTEGER NOT NULL, -- Delay in days before or after expiry date
+
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL,
+
+ not_before_date TEXT NULL CHECK (date(not_before_date) IS NULL OR date(not_before_date) = not_before_date) -- Don't send reminder to users if they expire before this date
+);
+
+CREATE TABLE IF NOT EXISTS services_reminders_sent
+-- Records of sent reminders, to keep track
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
+ id_reminder INTEGER NULL REFERENCES services_reminders (id) ON DELETE SET NULL,
+
+ sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
+ due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);
+
+CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
+CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);
+
+--
+-- Accounting
+--
+
+CREATE TABLE IF NOT EXISTS acc_charts
+-- Accounting charts (plans comptables)
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ country TEXT NOT NULL,
+ code TEXT NULL, -- the code is NULL if the chart is user-created or imported
+ label TEXT NOT NULL,
+ archived INTEGER NOT NULL DEFAULT 0 -- 1 = archived, cannot be changed
+);
+
+CREATE TABLE IF NOT EXISTS acc_accounts
+-- Accounts of the charts (comptes)
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_chart INTEGER NOT NULL REFERENCES acc_charts (id) ON DELETE CASCADE,
+
+ code TEXT NOT NULL, -- can contain numbers and letters, eg. 53A, 53B...
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ position INTEGER NOT NULL, -- position in the balance sheet (position actif/passif/charge/produit)
+ type INTEGER NOT NULL DEFAULT 0, -- type (category) of favourite account: bank, cash, third party, etc.
+ user INTEGER NOT NULL DEFAULT 1, -- 0 = is part of the original chart, 0 = has been added by the user
+ bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
+CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
+CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);
+CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code);
+
+-- Balance des comptes par exercice
+CREATE VIEW IF NOT EXISTS acc_accounts_balances
+AS
+ SELECT id_year, id, label, code, type, debit, credit, bookmark,
+ CASE -- 3 = dynamic asset or liability depending on balance
+ WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
+ WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
+ ELSE position
+ END AS position,
+ CASE
+ WHEN position IN (1, 4) -- 1 = asset, 4 = expense
+ OR (position = 3 AND (debit - credit) > 0)
+ THEN
+ debit - credit
+ ELSE
+ credit - debit
+ END AS balance,
+ CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt
+ FROM (
+ SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark,
+ SUM(l.credit) AS credit,
+ SUM(l.debit) AS debit
+ FROM acc_accounts a
+ INNER JOIN acc_transactions_lines l ON l.id_account = a.id
+ INNER JOIN acc_transactions t ON t.id = l.id_transaction
+ GROUP BY t.id_year, a.id
+ );
+
+CREATE TABLE IF NOT EXISTS acc_projects
+-- Analytical projects
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ code TEXT NULL,
+
+ label TEXT NOT NULL,
+ description TEXT NULL,
+
+ archived INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code);
+CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code);
+
+CREATE TABLE IF NOT EXISTS acc_years
+-- Years (exercices)
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+
+ label TEXT NOT NULL,
+
+ start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
+ end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),
+
+ closed INTEGER NOT NULL DEFAULT 0, -- 0 = open, 1 = closed
+
+ id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
+);
+
+CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);
+
+-- Make sure id_account is reset when a year is deleted
+CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
+ UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
+END;
+
+CREATE TABLE IF NOT EXISTS acc_transactions
+-- Transactions (écritures comptables)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced
+ status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask)
+
+ label TEXT NOT NULL,
+ notes TEXT NULL,
+ reference TEXT NULL, -- N° de pièce comptable
+
+ date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
+
+ hash TEXT NULL,
+ prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL,
+ prev_hash TEXT NULL,
+
+ id_year INTEGER NOT NULL REFERENCES acc_years(id),
+ id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
+CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
+CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);
+CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash);
+CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_lines
+-- Transactions lines (lignes des écritures)
+(
+ id INTEGER PRIMARY KEY NOT NULL,
+
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_account INTEGER NOT NULL REFERENCES acc_accounts (id),
+
+ credit INTEGER NOT NULL,
+ debit INTEGER NOT NULL,
+
+ reference TEXT NULL, -- Usually a payment reference (par exemple numéro de chèque)
+ label TEXT NULL,
+
+ reconciled INTEGER NOT NULL DEFAULT 0,
+
+ id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL,
+
+ CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
+ CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_links
+(
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE,
+ id_related INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE CHECK (id_transaction != id_related),
+ PRIMARY KEY (id_transaction, id_related)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_transaction ON acc_transactions_links (id_transaction);
+CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_related ON acc_transactions_links (id_related);
+
+CREATE TABLE IF NOT EXISTS acc_transactions_users
+-- Linking transactions and users
+(
+ id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
+ id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,
+
+ PRIMARY KEY (id_user, id_transaction, id_service_user)
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);
+
+---------- FILES ----------------
+
+CREATE TABLE IF NOT EXISTS files
+-- Files metadata
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ path TEXT NOT NULL,
+ parent TEXT NULL REFERENCES files(path) ON DELETE CASCADE ON UPDATE CASCADE,
+ name TEXT NOT NULL, -- File name
+ type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory
+ mime TEXT NULL,
+ size INT NULL,
+ modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
+ image INT NOT NULL DEFAULT 0,
+ md5 TEXT NULL,
+ trash TEXT NULL CHECK (datetime(trash) IS NULL OR datetime(trash) = trash),
+
+ CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL))
+);
+
+-- Unique index as this is used to make up a file path
+CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path);
+CREATE INDEX IF NOT EXISTS files_parent ON files (parent);
+CREATE INDEX IF NOT EXISTS files_type_parent ON files (type, parent, path);
+CREATE INDEX IF NOT EXISTS files_name ON files (name);
+CREATE INDEX IF NOT EXISTS files_modified ON files (modified);
+CREATE INDEX IF NOT EXISTS files_trash ON files (trash);
+CREATE INDEX IF NOT EXISTS files_size ON files (size);
+
+CREATE TABLE IF NOT EXISTS files_contents
+-- Files contents (empty if using another storage backend)
+(
+ id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
+ content BLOB NOT NULL
+);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4
+-- Search inside files content
+(
+ tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012)
+ path TEXT NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT NULL, -- Text content
+ notindexed=path
+);
+
+-- Delete/insert search item when item is deleted/inserted from files
+CREATE TRIGGER IF NOT EXISTS files_search_bd BEFORE DELETE ON files BEGIN
+ DELETE FROM files_search WHERE docid = OLD.rowid;
+END;
+
+CREATE TRIGGER IF NOT EXISTS files_search_ai AFTER INSERT ON files BEGIN
+ INSERT INTO files_search (docid, path, title, content) VALUES (NEW.rowid, NEW.path, NEW.name, NULL);
+END;
+
+CREATE TRIGGER IF NOT EXISTS files_search_au AFTER UPDATE OF name, path ON files BEGIN
+ UPDATE files_search SET path = NEW.path, title = NEW.name WHERE docid = NEW.rowid;
+END;
+
+CREATE TABLE IF NOT EXISTS acc_transactions_files
+-- Link between transactions and files
+(
+ id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
+ id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS acc_transactions_files_transaction ON acc_transactions_files (id_transaction);
+
+CREATE TABLE IF NOT EXISTS users_files
+-- Link between users and files
+(
+ id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
+ id_user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ field TEXT NOT NULL REFERENCES config_users_fields (name) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS users_files_user ON users_files (id_user);
+CREATE INDEX IF NOT EXISTS users_files_user_field ON users_files (id_user, field);
+
+CREATE TABLE IF NOT EXISTS web_pages
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE,
+ uri TEXT NOT NULL, -- Page identifier
+ type INTEGER NOT NULL, -- 1 = Category, 2 = Page
+ status TEXT NOT NULL,
+ format TEXT NOT NULL,
+ published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published),
+ modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
+ title TEXT NOT NULL,
+ content TEXT NOT NULL
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
+CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent);
+CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
+CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);
+
+CREATE TABLE IF NOT EXISTS web_pages_versions
+(
+ id INTEGER NOT NULL PRIMARY KEY,
+ id_page INTEGER NOT NULL REFERENCES web_pages ON DELETE CASCADE,
+ id_user INTEGER NULL REFERENCES users (id) ON DELETE SET NULL,
+ date TEXT NOT NULL CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
+ size INTEGER NOT NULL,
+ changes INTEGER NOT NULL,
+ content TEXT NOT NULL
+);
diff --git a/src/include/test_required.php b/src/include/test_required.php
new file mode 100644
index 0000000..9c4f724
--- /dev/null
+++ b/src/include/test_required.php
@@ -0,0 +1,96 @@
+\n\n\nErreur \n \n";
+ echo '';
+ echo "\n\n\nErreur \nLe problème suivant empêche Paheko de fonctionner : \n";
+ echo '' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '
';
+ echo 'Pour plus d\'informations consulter ';
+ echo 'l\'aide sur les problèmes à l\'installation .
';
+ echo "\n\n";
+ }
+ else
+ {
+ echo "[ERREUR] Le problème suivant empêche Paheko de fonctionner :\n";
+ echo $message . "\n";
+ echo "Pour plus d'informations consulter http://fossil.kd2.org/paheko/wiki?name=Probl%C3%A8mes%20fr%C3%A9quents\n";
+ }
+
+ exit;
+}
+
+test_required(
+ version_compare(phpversion(), '7.4', '>='),
+ 'PHP 7.4 ou supérieur requis. PHP version ' . phpversion() . ' installée.'
+);
+
+test_required(
+ defined('CRYPT_BLOWFISH') && CRYPT_BLOWFISH,
+ 'L\'algorithme de hashage de mot de passe Blowfish n\'est pas présent (pas installé ou pas compilé).'
+);
+
+test_required(
+ class_exists('\IntlDateFormatter') && function_exists('\idn_to_ascii'),
+ 'L\'extension "intl" n\'est pas installée mais est nécessaire (apt install php-intl).'
+);
+
+test_required(
+ function_exists('\mb_strlen'),
+ 'L\'extension "mbstring" n\'est pas installée mais est nécessaire (apt install php-mbstring).'
+);
+
+test_required(
+ class_exists('SQLite3'),
+ 'Le module de base de données SQLite3 n\'est pas disponible.'
+);
+
+$v = \SQLite3::version();
+
+test_required(
+ //$db->requireFeatures('cte', 'json_patch', 'fts4', 'date_functions_in_constraints', 'index_expressions', 'rename_column', 'upsert');
+ // 3.25.0 = RENAME COLUMN + UPSERT
+ version_compare($v['versionString'], '3.25', '>='),
+ 'SQLite3 version 3.25 ou supérieur requise. Version installée : ' . $v['versionString']
+);
+
+test_required(
+ file_exists(__DIR__ . '/lib/KD2'),
+ 'Librairie KD2 non disponible.'
+);
+
+$db = new \SQLite3(':memory:');
+$r = $db->query('PRAGMA compile_options;');
+$options = [];
+while ($row = $r->fetchArray(\SQLITE3_NUM)) {
+ $options[] = $row[0];
+}
+
+test_required(
+ in_array('ENABLE_FTS4', $options) || in_array('ENABLE_FTS3', $options),
+ 'Le module SQLite3 FTS4 (permettant de faire des recherches) n\'est pas installé ou activé.'
+);
+
+test_required(
+ in_array('ENABLE_JSON1', $options)
+ || (version_compare($v['versionString'], '3.38', '>=') && !in_array('OMIT_JSON', $options)),
+ 'Le module SQLite3 JSON1 (utilisé dans les formulaires) n\'est pas installé.'
+);
+
+test_required(
+ class_exists('Phar'),
+ 'Le module "Phar" n\'est pas disponible, il faut l\'installer.'
+);
diff --git a/src/index.php b/src/index.php
new file mode 100644
index 0000000..6a2df71
--- /dev/null
+++ b/src/index.php
@@ -0,0 +1,6 @@
+
+
+ Merci de confirmer la réservation
+
+
+
+ Vous allez réserver pour :
+ {{$event.label}}
+ {{$date|date_long}} à {{$date|date_hour}}
+ {{:linkbutton shape="left" label="Annuler" href="?event=%s"|args:$event.key}}
+
+
+
+
+
+ Mes informations
+
+
+ {{:input type="text" name="name" label="Prénom et nom" required=true default=$logged_user._name onfocus="this.select()"}}
+ {{if !$event.email}}
+ {{:input type="checkbox" name="notify" value="1" label="Recevoir une confirmation d'inscription par e-mail" onchange="g.toggle('dl.email', this.checked);" help="facultatif"}}
+ {{/if}}
+
+ {{if !$event.email && !$_POST.email}}
+ {{:assign email_hidden=true}}
+ {{#restrict section="users" level=$module.config.access}}
+ {{:assign email_disabled=false}}
+ {{else}}
+ {{if $logged_user._email}}
+ {{:assign email_disabled=true}}
+ {{/if}}
+ {{/restrict}}
+ {{/if}}
+
+ {{:input type="email" name="email" label="Adresse e-mail" required=true help="Une confirmation d'inscription vous sera envoyée à cette adresse" default=$logged_user._email onfocus="this.select()" onkeyup="g.toggle('.mycaptcha', this.value != '');" disabled=$email_disabled}}
+
+
+ {{#foreach from=$event.fields key="i" item="field"}}
+ {{if $field.type == 'checkbox'}}
+ {{:assign value="Coché"}}
+ {{else}}
+ {{:assign value=null}}
+ {{/if}}
+ {{:input name="fields[%d]"|args:$i label=$field.label type=$field.type help=$field.help required=$field.required value=$value}}
+
+ {{/foreach}}
+
+
+
+
+ {{if !$logged_user}}
+
Vérification contre les robots {{:captcha html=true}}
+ {{/if}}
+
+ {{:button type="submit" name="book" label="Confirmer la réservation" shape="right" class="main"}}
+
+
\ No newline at end of file
diff --git a/src/modules/bookings/_create_slots_list.tpl b/src/modules/bookings/_create_slots_list.tpl
new file mode 100644
index 0000000..3bc7122
--- /dev/null
+++ b/src/modules/bookings/_create_slots_list.tpl
@@ -0,0 +1,121 @@
+{{if $event === null}}
+ {{:error admin="Missing mandatory variable"}}
+{{/if}}
+
+
+{{**** Construction de la liste des créneaux ****}}
+{{:assign repeat="%d-1"|math:$event.max_weeks}}
+
+{{if $event.use_closings || $event.use_openings}}
+ {{#module name="openings"}}
+ {{* Prendre en compte les périodes de fermeture depuis le module "ouvertures" *}}
+ {{if $event.use_closings}}
+ {{#foreach from=$config.closed item="slot"}}
+ {{:assign
+ start="%s %s, 00:00:00"|args:$slot.close_day:$slot.close_month|strtotime
+ end="%s %s, 23:59:59"|args:$slot.reopen_day:$slot.reopen_month|strtotime
+ }}
+ {{if $end < $start}}
+ {{:assign
+ end="%s %s, 23:59:59, +1 year"|args:$slot.reopen_day:$slot.reopen_month|strtotime
+ }}
+ {{/if}}
+ {{:assign var="closed." start=$start end=$end}}
+ {{/foreach}}
+ {{/if}}
+
+ {{* Créer les créneaux à partir du module "ouvertures" *}}
+ {{if $event.use_openings && $event.openings_slots > 0}}
+ {{:assign now="now"|strtotime}}
+ {{#foreach from=$config.open item="slot"}}
+ {{if $slot.frequency != "this"}}
+ {{:assign
+ start="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime
+ end="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime
+ }}
+ {{* Try next month *}}
+ {{if $end < $now}}
+ {{:assign
+ start="%s %s of next month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime
+ end="%s %s of next month, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime
+ }}
+ {{/if}}
+ {{else}}
+ {{:assign
+ start="%s %s, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime
+ end="%s %s, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime
+ }}
+ {{* Try next week *}}
+ {{if $end < $now}}
+ {{:assign
+ start="next %s, %s:00"|args:$slot.day:$slot.open|strtotime
+ end="next %s, %s:00"|args:$slot.day:$slot.close|strtotime
+ }}
+ {{/if}}
+ {{/if}}
+
+ {{#foreach count=$event.openings_slots key="i"}}
+ {{if $start >= $end}}
+ {{:break}}
+ {{/if}}
+ {{:assign var="slots.%d"|args:$start frequency=$slot.frequency day=$slot.day open=$start|date:'H:i' seats=$event.openings_seats}}
+ {{:assign start="%d+(%d*60)"|math:$start:$event.openings_delay}}
+ {{/foreach}}
+
+ {{if $slot.frequency === 'this' && $repeat > 0}}
+ {{#foreach count=$repeat}}
+ {{:assign date=$start|date:'Y-m-d'}}
+ {{:assign start="%s, next %s, %s"|args:$date:$slot.day:$slot.open|strtotime}}
+ {{#foreach count=$event.openings_slots key="i"}}
+ {{:assign var="slots.%d"|args:$start frequency=$slot.frequency day=$slot.day open=$start|date:'H:i' seats=$event.openings_seats}}
+ {{:assign start="%d+(%d*60)"|math:$start:$event.openings_delay}}
+ {{/foreach}}
+ {{/foreach}}
+ {{/if}}
+ {{/foreach}}
+ {{/if}}
+ {{/module}}
+{{/if}}
+
+{{if !$event.use_openings}}
+ {{#load
+ type="slot"
+ event=$event.key
+ where="$$.seats > 0 AND ($$.date IS NULL OR ($$.date > date() OR ($$.date = date() AND $$.open <= strftime('%H:%M'))))"
+ order="$$.frequency = 'only' DESC, $$.frequency = 'this' DESC, $$.date, $$.day = 'monday' DESC, $$.day = 'tuesday' DESC, $$.day = 'wednesday' DESC, $$.day = 'thursday' DESC, $$.day = 'friday' DESC, $$.open ASC"}}
+ {{if $frequency == 'this'}}
+ {{:assign timestamp="%s %s, %s"|args:$frequency:$day:$open|strtotime}}
+ {{elseif !$date}}
+ {{:assign timestamp="%s %s of this month, %s"|args:$frequency:$day:$open|strtotime}}
+ {{else}}
+ {{:assign timestamp="%s %s"|args:$date:$open|strtotime}}
+ {{/if}}
+
+ {{* Make sure slot is in the future, can't book for past dates *}}
+ {{if $frequency != 'only' && $timestamp < $now|date:'U'}}
+ {{if $frequency == 'this'}}
+ {{:assign timestamp="next %s, %s"|args:$day:$open|strtotime}}
+ {{else}}
+ {{:assign timestamp="%s %s of next month, %s"|args:$frequency:$day:$open|strtotime}}
+ {{/if}}
+ {{/if}}
+
+ {{* This shouldn't happen, but make sure it doesn't *}}
+ {{if $timestamp < $now|date:'U'}}
+ {{:continue}}
+ {{/if}}
+
+ {{:assign .="slots.%d"|args:$timestamp}}
+
+ {{if $frequency == 'this' && $repeat > 0}}
+ {{#foreach count=$repeat}}
+ {{:assign date=$timestamp|date:'Y-m-d'}}
+ {{:assign timestamp="%s, next %s, %s"|args:$date:$day:$open|strtotime}}
+ {{:assign ..="slots.%d"|args:$timestamp}}
+ {{/foreach}}
+ {{/if}}
+ {{/load}}
+{{/if}}
+
+{{* Make sure we sort slots by datetime *}}
+{{:assign slots=$slots|ksort}}
diff --git a/src/modules/bookings/_form.html b/src/modules/bookings/_form.html
new file mode 100644
index 0000000..67d692d
--- /dev/null
+++ b/src/modules/bookings/_form.html
@@ -0,0 +1,188 @@
+{{if "random_int(1, 10)"|math == 10}}
+ {{* Prune old bookings from time to time *}}
+ {{:delete type="booking" where="date($$.date) < date('now', '-1 month')"}}
+{{/if}}
+
+{{if !$module.config.access}}
+ {{:assign var="module.config.access" value="write"}}
+{{/if}}
+
+
+{{if $_GET.event}}
+ {{#load type="event" key=$_GET.event assign="event" archived=false}}
+ {{:assign event_selected=true}}
+ {{else}}
+ {{:error message="L'événement indiqué est introuvable"}}
+ {{/load}}
+{{else}}
+ {{#load type="event" archived=false group="$$.type HAVING COUNT(*) = 1" assign="event"}}
+ {{/load}}
+{{/if}}
+
+{{#form on="cancel"}}
+ {{:delete key=$_POST.cancel type="booking"}}
+ {{:redirect to="./?deleted=%s"|args:$_POST.cancel}}
+{{/form}}
+
+{{:form_errors}}
+
+{{if $_GET.deleted}}
+
+ La réservation a bien été annulée.
+
+{{/if}}
+
+{{if !$_GET.slot_time}}
+
+ {{* New booking *}}
+ {{if $_GET.b}}
+ {{if $_GET.booked}}
+
+ Votre réservation a bien été enregistrée.
+
+ {{/if}}
+
+ {{#load type="booking" key=$_GET.b}}
+ {{#load type="event" key=$event assign="e"}}{{/load}}
+ {{:assign var="booking" label=$e.label name=$name event=$e.key key=$key date_str=$date|date_long date=$date|strtotime time_str=$date|date_hour}}
+
+ Vous avez réservé :
+ {{$e.label}}
+ {{$date|date_long}} à {{$date|date_hour}}
+ (Au nom de : {{$name}})
+
+ {{:button type="submit" name="cancel" value=$key label="Annuler cette réservation" shape="delete" class="main"}}
+
+
+ {{else}}
+ La réservation indiquée est introuvable, elle a peut-être déjà été annulée.
+ {{/load}}
+ {{/if}}
+ {{* My bookings *}}
+ {{if $logged_user.id}}
+ {{#load type="booking" id_user=$logged_user.id where="key != :booking" :booking=$_GET.b|or:''}}
+ {{#load type="event" key=$event assign="e"}}{{/load}}
+
+ Vous avez réservé :
+ {{$e.label}}
+ {{$date|date_long}} à {{$date|date_hour}}
+ (Au nom de : {{$name}})
+
+ {{:button type="submit" name="cancel" value=$key label="Annuler cette réservation" shape="delete" }}
+
+
+ {{/load}}
+ {{/if}}
+
+
+ Vous avez réservé :
+ #label#
+ #date_str# à #time_str#
+ (Au nom de : #name#)
+
+ {{:button type="submit" name="cancel" value="#key#" label="Annuler cette réservation" shape="delete" }}
+
+
+
+
+
+ {{if !$logged_user}}
+
+ {{/if}}
+{{/if}}
+
+{{if !$event}}
+
+
+ Réservations
+ Choisir un événement :
+ {{#load type="event" order="$$.label" archived=false}}
+
+
+
+ {{else}}
+ Il n'y a aucun événement réservable.
+ {{/load}}
+
+
+{{else}}
+ {{#form on="book"}}
+ {{if !$_GET.slot_time || !$_GET.slot_time|parse_datetime}}
+ {{:error message="Date invalide"}}
+ {{/if}}
+
+ {{if $_GET.slot_key}}
+ {{* Vérification que le créneau sélectionné existe bien *}}
+ {{#load key=$_GET.slot_key type="slot" assign="slot"}}
+ {{else}}
+ {{:error message="Créneau introuvable"}}
+ {{/load}}
+
+ {{* Vérification qu'il y a encore des places dispo *}}
+ {{#load event=$event.key where="datetime($$.date) = datetime(:date)" slot=$slot.key :date=$_GET.slot_time type="booking" count=true}}
+ {{if $count >= $slot.seats}}
+ {{:error message="Ce créneau est déjà plein, aucune place n'est disponible, désolé."}}
+ {{/if}}
+ {{/load}}
+ {{/if}}
+
+ {{if !$_POST.name|trim}}
+ {{:error message="Le nom doit être renseigné."}}
+ {{/if}}
+
+ {{if $event.email && !$_POST.email|trim}}
+ {{:error message="L'adresse e-mail doit être renseignée."}}
+ {{/if}}
+
+ {{if $_POST.email|trim && !$_POST.email|trim|check_email}}
+ {{:error message="Adresse e-mail invalide."}}
+ {{/if}}
+
+ {{if $_POST.email|trim && !$logged_user}}
+ {{:captcha verify=true}}
+ {{/if}}
+
+ {{:assign fields=null}}
+
+ {{#foreach from=$event.fields key="i" item="field"}}
+ {{:assign var="value" from="_POST.fields.%d"|args:$i}}
+ {{:assign var="fields." label=$field.label value=$value|trim|or:null}}
+ {{if $field.required && !$value|trim}}
+ {{:error message="Le champ '%s' doit être renseigné."|args:$field.label}}
+ {{/if}}
+ {{/foreach}}
+
+ {{:assign key=""|uuid}}
+ {{:save type="booking"
+ key=$key
+ validate_schema="./booking.schema.json"
+ slot=$slot.key
+ event=$event.key
+ date=$_GET.slot_time
+ name=$_POST.name|trim
+ email=$_POST.email|trim|or:null
+ id_user=$logged_user.id
+ fields=$fields
+ }}
+
+ {{if $_POST.email|trim}}
+ {{:assign my_date=$_GET.slot_time|parse_datetime|date_long:true}}
+ {{:assign url="%s?b=%s"|args:$module.url:$key}}
+ {{:mail to=$_POST.email|trim subject="Réservation du %s"|args:$my_date body="Vous avez réservé pour : %s\n\nLe : %s\n\nPour annuler votre réservation, cliquez ici :\n%s"|args:$event.label:$my_date:$url}}
+ {{/if}}
+
+ {{:redirect to="./?b=%s&booked=1"|args:$key}}
+ {{/form}}
+
+ {{if $_GET.slot_key !== null && $_GET.slot_time}}
+
+ {{:include file="./_confirm_slot_form.html"}}
+
+ {{else}}
+
+ {{:include file="./_create_slots_list.tpl" event=$event keep="slots,closed"}}
+ {{:include file="./_slots_list.html" event=$event slots=$slots event_selected=$event_selected closed=$closed}}
+
+ {{/if}}
+
+{{/if}}
\ No newline at end of file
diff --git a/src/modules/bookings/_nav.html b/src/modules/bookings/_nav.html
new file mode 100644
index 0000000..3cf3310
--- /dev/null
+++ b/src/modules/bookings/_nav.html
@@ -0,0 +1,45 @@
+{{if !$module.config.access}}
+ {{:assign var="module.config.access" value="write"}}
+{{/if}}
+
+{{#restrict section="config" level="admin"}}
+ {{:assign var="access_config" value=true}}
+{{/restrict}}
+{{#restrict section="users" level=$module.config.access}}
+ {{:assign var="access_bookings" value=true}}
+{{/restrict}}
+
+{{if $access_config || $access_bookings}}
+
+ {{if $current == 'config'}}
+
+ {{if $event}}
+ {{:linkbutton shape="plus" label="Nouveau créneau" href="config_slot.html?new=%s"|args:$event.key target="_dialog"}}
+ {{else}}
+ {{:linkbutton shape="plus" label="Nouvel événement" href="config_event.html?new=1"}}
+ {{/if}}
+
+ {{/if}}
+
+
+
+ {{if $event}}
+
+ {{elseif $current === 'config'}}
+
+ {{/if}}
+
+{{/if}}
diff --git a/src/modules/bookings/_slots_list.html b/src/modules/bookings/_slots_list.html
new file mode 100644
index 0000000..673eef3
--- /dev/null
+++ b/src/modules/bookings/_slots_list.html
@@ -0,0 +1,104 @@
+{{if $slots === null || $event === null}}
+ {{:error admin="Missing mandatory variable"}}
+{{/if}}
+
+{{:assign last_date=null}}
+{{:assign slots_available=false}}
+
+
+
+
+ {{if $event_selected}}
+ {{:linkbutton shape="left" label="Liste des événements" href="./"}}
+ {{/if}}
+
+ {{$event.label}}
+
+ {{if $event.description}}
+ {{$event.description|raw|markdown}}
+ {{/if}}
+
+
+
+ Créneaux disponibles
+
+{{#foreach from=$slots item="slot" key="timestamp"}}
+ {{* take into account the closing dates *}}
+ {{if $event.use_closings}}
+ {{#foreach from=$closed item="closing"}}
+ {{if $closing.start <= $timestamp && $closing.end >= $timestamp}}
+ {{if $slot.frequency == 'only'}}
+ {{* Don't cancel specific dates if they are in a closed time, it's probably an event during a closed time *}}
+ {{:continue}}
+ {{elseif $slot.frequency == 'this'}}
+ {{:assign end_date="%d+86400"|math:$closing.end|date:'Y-m-d'}}
+ {{:assign timestamp="%s, %s %s, %s"|args:$end_date:$slot.frequency:$slot.day:$slot.open|strtotime}}
+ {{else}}
+ {{:assign end_date="%d+86400"|math:$closing.end|date:'Y-m'}}
+ {{:assign timestamp="%s, %s %s, %s"|args:$end_date:$slot.frequency:$slot.day:$slot.open|strtotime}}
+ {{/if}}
+ {{/if}}
+ {{/foreach}}
+ {{/if}}
+
+ {{:assign this_date=$timestamp|date:"Y-m-d"}}
+ {{:assign this_datetime=$timestamp|date:"Y-m-d H:i"}}
+
+ {{:assign count=0}}
+ {{if $slot.key}}
+ {{#load count=true slot=$slot.key date=$this_datetime}}
+ {{:assign count=$count}}
+ {{/load}}
+ {{else}}
+ {{#load count=true where="$$.slot IS NULL" date=$this_datetime}}
+ {{:assign count=$count}}
+ {{/load}}
+ {{/if}}
+
+ {{:assign available="max(0, %d-%d)"|math:$slot.seats:$count}}
+
+ {{if $available > 0 && $slots_available === false}}
+ {{:assign slots_available=true}}
+ {{/if}}
+
+ {{if $this_date != $last_date}}
+ {{if $last_date}}
+
+
+ {{/if}}
+ {{:assign last_date=$this_date}}
+
+ {{$timestamp|date_long}}
+
+ {{/if}}
+
+ {{if !$available}}
+ {{:assign disabled=true}}
+ {{else}}
+ {{:assign disabled=false}}
+ {{/if}}
+
+ {{if $available == 1}}
+ {{:assign label="1 place disponible"}}
+ {{else}}
+ {{:assign label="%d places disponibles"|args:$available}}
+ {{/if}}
+
+
+
+ {{$timestamp|date_hour}}
+ {{$label}}
+
+
+{{/foreach}}
+
+{{if $last_date}}
+
+
+{{/if}}
+
+{{if !$slots_available}}
+ Aucun créneau n'est disponible.
+{{/if}}
+
+
\ No newline at end of file
diff --git a/src/modules/bookings/booking.schema.json b/src/modules/bookings/booking.schema.json
new file mode 100644
index 0000000..f943412
--- /dev/null
+++ b/src/modules/bookings/booking.schema.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["booking"]
+ },
+ "slot": {
+ "type": ["null", "string"]
+ },
+ "event": {
+ "type": "string"
+ },
+ "date": {
+ "type": "string",
+ "format": "datetime"
+ },
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "type": ["null", "string"]
+ },
+ "id_user": {
+ "type": ["null", "integer"]
+ },
+ "fields": {
+ "type": ["null", "array"],
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "value": {
+ "type": ["null", "string"]
+ }
+ }
+ }
+ }
+ },
+ "required": ["type", "event", "slot", "date", "name", "email", "id_user", "fields"]
+}
\ No newline at end of file
diff --git a/src/modules/bookings/bookings.html b/src/modules/bookings/bookings.html
new file mode 100644
index 0000000..26a3081
--- /dev/null
+++ b/src/modules/bookings/bookings.html
@@ -0,0 +1,84 @@
+{{if !$module.config.access}}
+ {{:assign var="module.config.access" value="write"}}
+{{/if}}
+
+{{#restrict section="users" level=$module.config.access block=true}}{{/restrict}}
+
+{{if $_GET.event}}
+ {{#load type="event" key=$_GET.event assign="event"}}
+ {{else}}
+ {{:error message="L'événement indiqué est introuvable"}}
+ {{/load}}
+{{else}}
+ {{#load type="event" group="$$.type HAVING COUNT(*) = 1" assign="event"}}
+ {{/load}}
+{{/if}}
+
+{{if $event}}
+ {{:assign title="Inscriptions — %s"|args:$event.label}}
+{{else}}
+ {{:assign title="Inscriptions"}}
+{{/if}}
+
+{{:admin_header title=$title custom_css="./style.css" current="module_bookings"}}
+
+{{:include file="./_nav.html" current="bookings"}}
+
+{{if !$event}}
+
+
+ {{#load type="event" order="$$.label"}}
+
+
+ Voir les inscrits pour cet événement
+
+ {{else}}
+ Il n'y a aucun événement.
+ {{/load}}
+
+
+{{else}}
+
+ {{if $_GET.cancel}}
+ {{:delete key=$_GET.cancel type="booking" event=$event.key}}
+ {{:redirect to="?event=%s"|args:$event.key}}
+ {{/if}}
+
+
+
+
+ {{:assign current_day=null}}
+
+ {{:exportmenu right=true}}
+
+ {{#list select="date($$.date) AS 'Date'; $$.date AS 'Heure'; $$.name AS 'Nom'; json_object('email', json_quote($$.email), 'fields', json($$.fields)) AS 'Autres informations'" type="booking" event=$event.key where="date($$.date) >= date()" order="2"}}
+
+
+ {{if $col1 !== $current_day}}
+ {{$date|date_long}}
+ {{:assign current_day=$col1}}
+ {{/if}}
+
+ {{$date|date_hour}}
+ {{$name}}
+
+ {{if $email}}{{$email}} {{/if}}
+ {{#foreach from=$fields item="field"}}
+ {{$field.label}} : {{$field.value}}
+ {{/foreach}}
+
+
+ {{:linkbutton shape="delete" label="Annuler" href="?event=%s&cancel=%s"|args:$event.key:$key onclick="return confirm('Annuler ?');"}}
+
+
+ {{else}}
+ Aucune réservation.
+ {{/list}}
+
+ Note : les réservations passées (hier et avant) ne sont pas affichées et sont automatiquement supprimées.
+
+
+{{/if}}
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/config.html b/src/modules/bookings/config.html
new file mode 100644
index 0000000..86c800e
--- /dev/null
+++ b/src/modules/bookings/config.html
@@ -0,0 +1,38 @@
+{{#restrict section="config" level="admin" block=true}}{{/restrict}}
+{{:admin_header title="Configuration" current="module_bookings"}}
+
+{{:include file="./_nav.html" current="config"}}
+
+{{if $_GET.ok}}
+ Configuration enregistrée.
+{{/if}}
+
+{{#list select="$$.label AS \'Événément\'" where="$$.type = 'event'" order="1"}}
+
+ {{$label}}
+
+ {{if !$use_openings}}
+ {{:linkbutton label="Configurer les créneaux" href="config_event_slots.html?id=%s"|args:$key shape="calendar"}}
+ {{/if}}
+ {{:linkbutton label="Modifier" href="config_event.html?id=%s"|args:$key shape="edit"}}
+ {{:linkbutton label="Supprimer" href="config_event_delete.html?id=%s"|args:$key shape="delete" target="_dialog"}}
+
+
+{{else}}
+
+ Il n'y a aucun événement configuré.
+ {{:linkbutton shape="plus" label="Nouvel événement" href="config_event.html?new=1"}}
+
+{{/list}}
+
+
+
Accès à la réservation
+
Les membres connectés pourront réserver un créneau via le menu « Réservations » à gauche.
+
Les non-membres pourront réserver un créneau via l'adresse suivante :
+ {{:input copy=true name="url" type="url" readonly=true default=$module.public_url size=$module.public_url|strlen}}
+ {{:linkbutton href=$module.public_url label="Ouvrir" target="_blank" shape="eye"}}
+
+
Les gestionnaires pourront visionner les réservations et gérer les inscrit⋅e⋅s dans le menu « Réservations ».
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/config_access.html b/src/modules/bookings/config_access.html
new file mode 100644
index 0000000..749e642
--- /dev/null
+++ b/src/modules/bookings/config_access.html
@@ -0,0 +1,25 @@
+{{#restrict section="config" level="admin" block=true}}{{/restrict}}
+{{#form on="save"}}
+ {{:save key="config" access=$_POST.access}}
+ {{:redirect to="config.html?ok=1"}}
+{{/form}}
+
+{{:admin_header title="Configuration des accès" current="module_bookings"}}
+
+{{:include file="./_nav.html" current="config" subcurrent="access"}}
+
+
+
+ Qui peut voir et gérer les inscriptions ?
+
+ {{:input type="radio" name="access" source=$module.config value="none" label="Tous les membres connectés"}}
+ {{:input type="radio" name="access" source=$module.config value="read" label="Seulement les membres pouvant lire la liste des membres"}}
+ {{:input type="radio" name="access" source=$module.config default="write" value="write" label="Seulement les membres pouvant ajouter et modifier des membres"}}
+
+
+ {{:button type="submit" label="Enregistrer" name="save" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/config_event.html b/src/modules/bookings/config_event.html
new file mode 100644
index 0000000..b17e5ce
--- /dev/null
+++ b/src/modules/bookings/config_event.html
@@ -0,0 +1,158 @@
+{{#restrict section="config" level="admin" block=true}}{{/restrict}}
+{{:admin_header title="Configuration d'un événement" current="module_bookings"}}
+
+{{if $_GET.id}}
+ {{#load key=$_GET.id type="event" assign="event"}}
+ {{:assign key=$key}}
+ {{else}}
+ {{:error message="Événément introuvable"}}
+ {{/load}}
+{{else}}
+ {{:assign key=""|uuid}}
+{{/if}}
+
+{{:include file="./_nav.html" current="config" event=null}}
+
+{{:assign var="types"
+ checkbox="Case à cocher"
+ date="Date"
+ datetime="Date et heure"
+ tel="Numéro de téléphone"
+ text="Texte"
+ textarea="Texte multi-lignes"
+}}
+
+{{#form on="save"}}
+ {{#foreach from=$_POST.fields|array_transpose item="field"}}
+ {{if !$field.label|trim || !$field.type}}
+ {{:continue}}
+ {{/if}}
+ {{if !$types|keys|has:$field.type}}
+ {{:error message="Type inconnu: %s"|args:$field.type}}
+ {{/if}}
+ {{:assign var="fields." required=$field.required|boolval type=$field.type label=$field.label help=$field.help|or:null}}
+ {{/foreach}}
+ {{:save key=$key
+ validate_schema="./event.schema.json"
+ type="event"
+ label=$_POST.label|trim
+ description=$_POST.description|trim|or:null
+ max_weeks=$_POST.max_weeks|intval|or:1
+ use_closings=$_POST.use_closings|boolval
+ use_openings=$_POST.use_openings|boolval
+ openings_seats=$_POST.openings_seats|intval|or:null
+ openings_slots=$_POST.openings_slots|intval|or:null
+ openings_delay=$_POST.openings_delay|intval|or:null
+ email=$_POST.email|boolval
+ archived=$_POST.archived|boolval
+ fields=$fields|arrayval
+ }}
+ {{:redirect to="./config.html?ok=1"}}
+{{/form}}
+
+{{:form_errors}}
+
+
+
+ Événément
+
+ {{:input type="text" name="label" label="Nom" required=true source=$event}}
+ {{:input type="textarea" cols=70 rows=7 name="description" label="Description" required=false source=$event help="Sera affiché sur la page d'inscription à l'événement"}}
+ Syntaxe MarkDown acceptée. {{:linkbutton shape="help" target="_dialog" href="!static/doc/markdown.html" label="Aide de la syntaxe MarkDown"}}
+ {{:input type="checkbox" label="Archiver cet événement" name="archived" source=$event value=1}}
+ Si coché, cet événement ne sera plus visible dans la liste et ne pourra plus être réservé.
+ {{:input type="number" min=1 step=1 max=12 name="max_weeks" label="Nombre de semaines réservables pour les événements hebdomadaires" help="Par exemple, pour un événement tous les vendredis, inscrire le chiffre 2 ici permettra de réserver pour ce vendredi, et le vendredi suivant." source=$event default=1}}
+ {{#module name="openings"}}
+ Synchronisation avec les ouvertures
+ {{:input type="checkbox" name="use_closings" value=1 source=$event label="Ne pas proposer de créneaux pendant les périodes de fermeture"}}
+ {{:input type="checkbox" name="use_openings" value=1 source=$event label="Utiliser les horaires d'ouverture comme créneaux"}}
+ Si cette case est cochée, les créneaux seront automatiquement configurés à partir des informations indiquées dans l'extension {{:link href="%sconfig.html"|args:$url label="Horaires d'ouverture"}}. Sinon, il faudra configurer les créneaux manuellement.
+ {{:linkbutton shape="settings" href="%sconfig.html"|args:$url label="Configurer les ouvertures et fermetures"}}
+
+
+
+ Pour chaque ouverture…
+
+ {{:input type="number" min=0 name="openings_slots" source=$event label="Nombre de créneaux par ouverture" default=1 required=true}}
+ {{:input type="number" min=0 name="openings_seats" source=$event label="Nombre de places par créneau" required=true}}
+ {{:assign var="delay_options" 5="5 minutes" 10="10 minutes" 15="15 minutes" 20="20 minutes" 30="30 minutes" 60="1 heure" 120="2 heures" 240="4 heures"}}
+
+
+ {{:input type="select" name="openings_delay" source=$event label="Intervalle entre chaque créneau" options=$delay_options required=true}}
+ {{/module}}
+
+
+
+
+ Informations à demander aux inscrits
+
+ Pour chaque inscription, le nom de la personne sera toujours demandé.
+ {{:input type="checkbox" name="email" value="1" label="Demander aussi l'adresse e-mail" source=$event}}
+ Si la personne indique une adresse e-mail, une confirmation d'inscription lui sera envoyée. Cocher cette case pour rendre l'adresse e-mail obligatoire.
+
+
+
+
+ Autres informations à demander aux inscrits
+
+ À utiliser si l'inscription nécessite d'autres informations, comme un numéro de téléphone par exemple.
+
+
+
+
+ Type de champ
+ Libellé du champ
+ Texte d'aide en dessous du champ
+ Champ obligatoire ?
+
+
+
+
+ {{:assign var="select" 0="Facultatif" 1="Obligatoire"}}
+ {{#foreach from=$event.fields item="field"}}
+
+ {{:input type="select" name="fields[type][]" default=$field.type options=$types}}
+ {{:input type="text" name="fields[label][]" default=$field.label}}
+ {{:input type="text" name="fields[help][]" default=$field.help}}
+ {{:input type="select" name="fields[required][]" default=$field.required options=$select required=true}}
+ {{:button shape="minus" label="Enlever cette ligne" onclick="this.parentNode.parentNode.remove();"}}
+
+ {{else}}
+
+ {{:input type="select" name="fields[type][]" options=$types default="text"}}
+ {{:input type="text" name="fields[label][]" default=$field.label}}
+ {{:input type="text" name="fields[help][]" default=$field.help}}
+ {{:input type="select" name="fields[required][]" default=$field.required options=$select required=true}}
+ {{:button shape="minus" label="Enlever ce champ" onclick="this.parentNode.parentNode.remove();"}}
+
+ {{/foreach}}
+
+
+
+ {{:button shape="plus" label="Ajouter un champ" onclick="var a = $('.fields tbody')[0].lastElementChild; var b = a.cloneNode(true); b.querySelectorAll('input, select').forEach((e) => e.value = null); a.parentNode.append(b);"}}
+
+
+
+
+ {{:button type="submit" shape="right" name="save" label="Enregistrer" class="main"}}
+
+
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/config_event_delete.html b/src/modules/bookings/config_event_delete.html
new file mode 100644
index 0000000..55bab8c
--- /dev/null
+++ b/src/modules/bookings/config_event_delete.html
@@ -0,0 +1,17 @@
+{{#restrict section="config" level="admin" block=true}}{{/restrict}}
+
+{{#load key=$_GET.id type="event" assign="event"}}
+{{else}}
+ {{:error message="Événément introuvable"}}
+{{/load}}
+
+{{#form on="delete"}}
+ {{:delete where="key = :key OR $$.event = :key" :key=$event.key}}
+ {{:redirect to="./config.html"}}
+{{/form}}
+
+{{:admin_header title="Supprimer un événement" current="module_bookings"}}
+
+{{:delete_form legend="Supprimer cet événement ?" warning="Supprimer l'événement \"%s\" ?"|args:$event.label info="Les créneaux et réservations liées seront perdues."}}
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/config_event_slots.html b/src/modules/bookings/config_event_slots.html
new file mode 100644
index 0000000..0ec2396
--- /dev/null
+++ b/src/modules/bookings/config_event_slots.html
@@ -0,0 +1,69 @@
+{{#restrict section="config" level="admin" block=true}}{{/restrict}}
+
+{{if $_GET.id}}
+ {{#load key=$_GET.id assign="event"}}{{/load}}
+{{/if}}
+
+{{if !$event.key}}
+ {{:error message="Événément introuvable"}}
+{{/if}}
+
+{{:admin_header title="%s — Créneaux"|args:$event.label current="module_bookings"}}
+
+{{:include file="./_nav.html" current="config" event=$event}}
+
+{{if $_GET.ok}}
+ Créneaux enregistrés.
+{{/if}}
+
+{{:include file="./_common.tpl" keep="frequencies,days"}}
+
+{{#load count=true type="slot" event=$event.key}}
+ {{if $count}}
+
+
+ {{#load type="slot" event=$event.key order="$$.frequency = 'only' DESC, $$.frequency = 'this' DESC, $$.date, $$.day = 'monday' DESC, $$.day = 'tuesday' DESC, $$.day = 'wednesday' DESC, $$.day = 'thursday' DESC, $$.day = 'friday' DESC, $$.open ASC"}}
+
+
+ {{if $date}}
+ {{$date|date_short}}
+ {{else}}
+ {{:assign var="f" from="frequencies.%s"|args:$frequency}}
+ {{:assign var="d" from="days.%s"|args:$day}}
+ {{$f}}
+ {{$d}}
+ {{/if}}
+ à {{$open}}
+
+
+ {{$seats}} places
+
+
+ {{:linkbutton label="Modifier" href="config_slot.html?id=%s"|args:$key shape="edit" target="_dialog"}}
+ {{:linkbutton label="Supprimer" href="config_slot_delete.html?id=%s"|args:$key shape="delete" target="_dialog"}}
+
+
+ {{/load}}
+
+
+
+ {{else}}
+
+
+ Il n'y a aucun créneau configuré.
+ {{:linkbutton shape="plus" label="Nouveau créneau" href="config_slot.html?new=%s"|args:$event.key target="_dialog"}}
+
+
+ {{/if}}
+{{/load}}
+
+
+
Accès la réservation de créneaux pour cet événement
+
Les non-membres pourront réserver un créneau de cet événement directement via l'adresse suivante :
+ {{:assign url="%s?event=%s"|args:$module.url:$event.key}}
+ {{:input copy=true name="url" type="url" readonly=true default=$url size=$url|strlen}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/config_slot.html b/src/modules/bookings/config_slot.html
new file mode 100644
index 0000000..e86eefb
--- /dev/null
+++ b/src/modules/bookings/config_slot.html
@@ -0,0 +1,100 @@
+{{#restrict section="config" level="admin" block=true}}{{/restrict}}
+
+{{if $_GET.id}}
+ {{#load key=$_GET.id assign="slot"}}
+ {{:assign key=$key}}
+ {{:assign event=$event}}
+ {{else}}
+ {{:error message="Créneau introuvable"}}
+ {{/load}}
+{{else}}
+ {{:assign key=""|uuid}}
+ {{#load key=$_GET.new}}
+ {{:assign event=$key}}
+ {{else}}
+ {{:error message="Événement inconnu"}}
+ {{/load}}
+{{/if}}
+
+{{:admin_header title="Configuration d'un créneau" current="module_bookings"}}
+{{:include file="./_common.tpl" keep="frequencies,days"}}
+
+{{#form on="save"}}
+ {{if !$frequencies|keys|has:$_POST.frequency}}
+ {{:error message="Fréquence invalide"}}
+ {{/if}}
+ {{if $_POST.frequency == 'only'}}
+ {{:assign date=$_POST.date|parse_date}}
+ {{:assign day=null}}
+ {{if !$date}}
+ {{:error message="Date invalide"}}
+ {{/if}}
+ {{else}}
+ {{:assign date=null}}
+ {{:assign day=$_POST.day}}
+ {{if !$days|keys|has:$day}}
+ {{:error message="Jour invalide"}}
+ {{/if}}
+ {{/if}}
+ {{if !$_POST.open|parse_time}}
+ {{:error message="Heure de début invalide"}}
+ {{/if}}
+
+ {{:save key=$key
+ validate_schema="./slot.schema.json"
+ type="slot"
+ event=$event
+ frequency=$_POST.frequency
+ day=$day
+ date=$date|or:null
+ open=$_POST.open|parse_time
+ seats=$_POST.seats|intval
+ }}
+ {{:redirect to="./config_event_slots.html?id=%s&ok=1"|args:$event}}
+{{/form}}
+
+{{:form_errors}}
+
+
+
+ Date et heure
+
+ {{:input type="select" name="frequency" required=true label="Fréquence" options=$frequencies source=$slot default="every"}}
+
+
+ {{:input type="date" name="date" required=true label="Date" source=$slot}}
+
+
+ {{:input type="select" name="day" required=true label="Jour" options=$days source=$slot}}
+
+
+ {{:input type="time" name="open" required=true label="Heure de début" source=$slot}}
+
+
+
+
+ Jauge
+
+ {{:input type="number" name="seats" required=true min=0 step=1 label="Nombre de places disponibles" source=$slot}}
+
+
+
+
+ {{:button type="submit" shape="right" name="save" label="Enregistrer" class="main"}}
+
+
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/config_slot_delete.html b/src/modules/bookings/config_slot_delete.html
new file mode 100644
index 0000000..3f818fe
--- /dev/null
+++ b/src/modules/bookings/config_slot_delete.html
@@ -0,0 +1,17 @@
+{{#restrict section="config" level="admin" block=true}}{{/restrict}}
+
+{{#load key=$_GET.id type="slot" assign="slot"}}
+{{else}}
+ {{:error message="Événément introuvable"}}
+{{/load}}
+
+{{#form on="delete"}}
+ {{:delete where="key = :key OR $$.slot = :key" :key=$slot.key}}
+ {{:redirect to="./config_event_slots.html?id=%s"|args:$slot.event}}
+{{/form}}
+
+{{:admin_header title="Supprimer un créneau" current="module_bookings"}}
+
+{{:delete_form legend="Supprimer ce créneau ?" warning="Supprimer le créneau ?" info="Les réservations liées à ce créneau seront perdues."}}
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/event.schema.json b/src/modules/bookings/event.schema.json
new file mode 100644
index 0000000..88169b8
--- /dev/null
+++ b/src/modules/bookings/event.schema.json
@@ -0,0 +1,62 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["event"]
+ },
+ "label": {
+ "type": "string"
+ },
+ "description": {
+ "type": ["null", "string"]
+ },
+ "use_openings": {
+ "type": "boolean"
+ },
+ "max_weeks": {
+ "type": "integer"
+ },
+ "openings_seats": {
+ "type": ["null", "integer"]
+ },
+ "openings_slots": {
+ "type": ["null", "integer"]
+ },
+ "openings_delay": {
+ "type": ["null", "integer"]
+ },
+ "use_closings": {
+ "type": "boolean"
+ },
+ "fields": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "help": {
+ "type": ["string", "null"]
+ },
+ "required": {
+ "type": "boolean"
+ }
+ },
+ "required": ["type", "label", "help"]
+ }
+ },
+ "email": {
+ "type": "boolean"
+ },
+ "archived": {
+ "type": "boolean"
+ }
+ },
+ "required": ["type", "label", "description", "use_openings", "openings_seats", "openings_slots", "openings_delay", "use_closings", "fields", "email", "archived"]
+}
\ No newline at end of file
diff --git a/src/modules/bookings/icon.svg b/src/modules/bookings/icon.svg
new file mode 100644
index 0000000..38369ee
--- /dev/null
+++ b/src/modules/bookings/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/modules/bookings/index.html b/src/modules/bookings/index.html
new file mode 100644
index 0000000..72f24dd
--- /dev/null
+++ b/src/modules/bookings/index.html
@@ -0,0 +1,18 @@
+{{:assign var="custom_css." value="./style.css?2023-11-14"}}
+{{:assign var="custom_css." value="/content.css"}}
+{{if !$logged_user}}{{:assign layout="public"}}{{/if}}
+{{:admin_header title="Réservations" custom_css=$custom_css layout=$layout current="module_bookings"}}
+
+{{if $logged_user}}
+ {{:include file="./_nav.html" current="index"}}
+{{/if}}
+
+{{:include file="./_form.html" current="index"}}
+
+{{if !$logged_user}}
+
+ {{:linkbutton shape="left" label="Retourner sur notre site" href=$site_url}}
+
+{{/if}}
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/bookings/local_bookings.js b/src/modules/bookings/local_bookings.js
new file mode 100644
index 0000000..a50c64f
--- /dev/null
+++ b/src/modules/bookings/local_bookings.js
@@ -0,0 +1,56 @@
+/**
+ * Save bookings to local storage
+ * @return {[type]} [description]
+ */
+(function () {
+ var s = localStorage;
+ var bookings = s.getItem('bookings');
+
+ if (typeof bookings == 'string') {
+ bookings = JSON.parse(bookings);
+ }
+
+ if (typeof bookings != 'object' || bookings === null) {
+ bookings = {};
+ }
+
+ // Delete booking
+ if (a = document.querySelector('[data-delete-booking]')) {
+ var key = a.dataset.deleteBooking;
+
+ if (bookings.hasOwnProperty(key)) {
+ delete bookings[key];
+ s.setItem('bookings', JSON.stringify(bookings));
+ }
+ }
+ // Append booking
+ else if (a = document.querySelector('[data-new-booking]')) {
+ var data = JSON.parse(a.dataset.newBooking);
+ bookings[data.key] = data;
+ s.setItem('bookings', JSON.stringify(bookings));
+ return;
+ }
+
+ if (!Object.keys(bookings).length) {
+ return;
+ }
+
+ const escape = (unsafe) => {
+ return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
+ };
+
+ const tpl = document.getElementById('booking');
+
+ Object.values(bookings).forEach((b) => {
+ if (b.date <= Math.floor(Date.now() / 1000)) {
+ // Booking is old, delete it
+ delete bookings[b.key];
+ s.setItem('bookings', JSON.stringify(bookings));
+ }
+
+ let html = tpl.innerHTML;
+
+ html = html.replace(/#(\w+)#/g, (_, m) => escape(b[m] ?? ''));
+ tpl.parentNode.insertAdjacentHTML('beforeend', html);
+ });
+})();
\ No newline at end of file
diff --git a/src/modules/bookings/module.ini b/src/modules/bookings/module.ini
new file mode 100644
index 0000000..28803e6
--- /dev/null
+++ b/src/modules/bookings/module.ini
@@ -0,0 +1,6 @@
+name="Réservations"
+description="Permet aux membres et visiteurs de s'inscrire à des événements et créneaux"
+author="Paheko"
+author_url="https://paheko.cloud/"
+home_button=true
+menu=true
diff --git a/src/modules/bookings/slot.schema.json b/src/modules/bookings/slot.schema.json
new file mode 100644
index 0000000..c713166
--- /dev/null
+++ b/src/modules/bookings/slot.schema.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["slot"]
+ },
+ "event": {
+ "type": "string"
+ },
+ "date": {
+ "description": "Date",
+ "format": "date",
+ "type": ["string", "null"]
+ },
+ "frequency": {
+ "description": "Fréquence",
+ "type": "string",
+ "enum": ["only", "this", "first", "second", "third", "fourth", "fifth", "last"]
+ },
+ "day": {
+ "description": "Jour",
+ "type": ["string", "null"],
+ "enum": [null, "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
+ },
+ "open": {
+ "description": "Heure de début",
+ "type": "string",
+ "pattern": "^(2[0-3]|[01][0-9]):([0-5][0-9])$",
+ "minimum": 0
+ },
+ "seats": {
+ "description": "Nombre de places",
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": ["type", "event", "date", "frequency", "day", "open", "seats"]
+}
diff --git a/src/modules/bookings/style.css b/src/modules/bookings/style.css
new file mode 100644
index 0000000..14247d6
--- /dev/null
+++ b/src/modules/bookings/style.css
@@ -0,0 +1,188 @@
+.booking_event, .booking_events {
+ max-width: 50rem;
+}
+
+.booking_event > .event > h1 {
+ margin-bottom: 1rem;
+ text-align: center;
+}
+
+.booking_event > .event .web-content {
+ padding-top: .8rem;
+ border-top: .2rem solid #999;
+}
+
+.booking_event fieldset {
+ border: none;
+ border-top: .2rem solid #999;
+}
+
+.booking_event fieldset legend {
+ text-align: center;
+ font-size: 1.5em;
+}
+
+.booking_event fieldset article {
+ max-width: 30rem;
+ margin: 1rem auto;
+}
+
+.booking_event fieldset.info {
+ text-align: center;
+ border-radius: 0;
+}
+
+.booking_event fieldset.info .help {
+ font-size: .9em;
+}
+
+.booking_event p.submit {
+ max-width: 30rem;
+ margin: 1rem auto;
+ text-align: center;
+}
+
+.booking_event fieldset article h3 {
+ margin-bottom: .5rem;
+}
+
+.booking_event .slots {
+ text-align: center;
+}
+
+.booking_event .slots article {
+ border: 1px solid #ccc;
+ margin: 1em;
+ border-radius: .5rem;
+ overflow: hidden;
+}
+
+.booking_event .slots article h3 {
+ background: #eee;
+ padding: .5rem;
+}
+
+.booking_event .slots ul {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.booking_event .slots li a {
+ display: block;
+ background: #dfd;
+ box-shadow: 0px 0px 5px #ccc;
+ padding: .5rem;
+ text-decoration: none;
+ margin: .5rem 1rem;
+ color: unset;
+ border-radius: .5rem;
+ text-align: center;
+ transition: background-color .2s, box-shadow .2s;
+}
+
+.booking_event .slots li a:hover {
+ box-shadow: 0px 0px 5px orange;
+ background: #FFD580;
+}
+
+.booking_event .slots li a strong {
+ display: block;
+ font-size: 1.5em;
+ text-decoration: underline;
+}
+
+.booking_event .slots li a em {
+ color: #666;
+ font-style: normal;
+}
+
+.booking_event .slots li .available_1 {
+ background: #fcc;
+}
+
+.booking_event .slots li .available_2 {
+ background: #ffc;
+}
+
+.booking_event .slots li .available_0, .booking_event .slots li a.available_0:hover {
+ background: #ddd;
+ cursor: not-allowed;
+ color: #999;
+ box-shadow: 0px 0px 5px #ccc;
+}
+
+.booking_event .slots li .available_0 strong {
+ text-decoration: line-through;
+}
+
+.mycaptcha {
+ text-align: center;
+ padding: .5em;
+ background: #ddd;
+ border-radius: .5em;
+}
+
+.mycaptcha h4 {
+ border-bottom: .1rem solid #666;
+ margin-bottom: .5rem;
+ font-size: 1.2em;
+}
+
+.mycaptcha label {
+ display: block;
+}
+
+.booking_events {
+ text-align: center;
+}
+
+.booking_events article {
+ margin: 1rem;
+ font-size: 1.2em;
+}
+
+.my_bookings {
+ max-width: 40rem;
+ margin: 1rem 5rem;
+}
+
+.my_bookings article {
+ margin: 1rem;
+ text-align: center;
+ padding: .5em;
+ border: .2rem solid #ddd;
+ border-radius: .5em;
+}
+
+.my_bookings article form {
+ margin: 1rem 0;
+}
+
+.my_bookings article h5 {
+ font-weight: normal;
+ color: #666;
+ margin: .5rem;
+}
+
+.my_bookings article h3 span {
+ border-radius: .5rem;
+ padding: .2rem .3rem;
+ color: darkred;
+ background: #fdd;
+}
+
+.booking_list article {
+ margin: 1rem;
+}
+
+p.back {
+ text-align: center;
+ margin-top: 5em;
+}
+
+.booking_event p.back {
+ margin-top: 0;
+ margin-bottom: 2em;
+}
\ No newline at end of file
diff --git a/src/modules/cheque_deposit/config.html b/src/modules/cheque_deposit/config.html
new file mode 100644
index 0000000..0a44fed
--- /dev/null
+++ b/src/modules/cheque_deposit/config.html
@@ -0,0 +1,47 @@
+{{if $module.config.accounts|gettype === 'string'}}
+ {{:include file="/receipt_donation/_upgrade.tpl" keep="module.config.accounts"}}
+{{/if}}
+
+{{#form on="save"}}
+ {{if !$_POST.accounts|count}}
+ {{:error message="Aucun compte n'a été renseigné."}}
+ {{/if}}
+
+ {{:save key="config"
+ accounts=$_POST.accounts|arrayval
+ }}
+ {{:redirect to="?ok=1"}}
+{{/form}}
+
+{{:admin_header title="Configuration bordereau de remise de chèques"}}
+
+{{:form_errors}}
+
+{{if $_GET.ok}}
+ Configuration enregistrée.
+{{/if}}
+
+
+
+
+ Configuration
+
+ {{:assign var="default_account.5112" value="5112 — Chèques en attente de dépôt"}}
+ {{:input required=true name="accounts" multiple=true target="!acc/charts/accounts/selector.php?targets=3&key=code" type="list" label="Comptes liés aux remises de chèques" source=$module.config default=$default_account}}
+
+ Pour chaque compte indiqué dans ce champ, le formulaire de remise de chèque sera proposé (en dessous de la fiche de l'écriture).
+
+
+
+
+
+ Astuce : pour faire apparaître le numéro (RIB) du compte bancaire dans le bordereau, inscrire celui-ci dans la description du compte bancaire au plan comptable.
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/cheque_deposit/icon.svg b/src/modules/cheque_deposit/icon.svg
new file mode 100644
index 0000000..441bc2d
--- /dev/null
+++ b/src/modules/cheque_deposit/icon.svg
@@ -0,0 +1 @@
+
diff --git a/src/modules/cheque_deposit/index.html b/src/modules/cheque_deposit/index.html
new file mode 100644
index 0000000..d16e7b2
--- /dev/null
+++ b/src/modules/cheque_deposit/index.html
@@ -0,0 +1,49 @@
+{{#restrict block=true section="accounting" level="read"}}
+{{/restrict}}
+{{if !$_GET.id}}
+
+ {{:admin_header title="Remise de chèques" current="acc"}}
+
+ Le bordereau de remise de chèque s'affichera en dessous des écritures de remise en banque.
+
+ {{:admin_footer}}
+{{else}}
+ {{#transactions id=$_GET.id}}
+ {{:assign date_short=$date|date_format:"%d-%m-%Y"}}
+ {{:include file="/receipt/_header.html" page_size="A5" title="Bordereau chèques - %d - %s"|args:$id:$date_short}}
+
+ Bordereau de remise de chèques
+
+ Écriture n°{{if $reference}}{{$reference}}{{else}}{{$id}}{{/if}} — {{$date|date_short}}
+
+ {{#transaction_lines transaction=$id where="debit > 0"}}
+ Compte de dépôt: {{$account_label}}
+ {{:assign account_id=$id_account}}
+ {{/transaction_lines}}
+
+ {{#accounts id=$account_id where="description IS NOT NULL"}}
+ {{$description|escape|nl2br}}
+ {{/accounts}}
+
+
+ {{:assign i=1}}
+ {{#transaction_lines transaction=$id where="credit > 0"}}
+
+ {{$i}}.
+ {{$label}}
+ {{$reference}}
+ {{$credit|raw|money_currency}}
+
+ {{:assign i="%d+1"|math:$i}}
+ {{/transaction_lines}}
+
+
+ Nombre de chèques : {{"%d-1"|math:$i}}
+
+ Total : {{$credit|raw|money_currency}}
+
+ {{:include file="/receipt/_footer.html"}}
+ {{else}}
+ {{:error message="Le numéro d'écriture fourni n'existe pas."}}
+ {{/transactions}}
+{{/if}}
\ No newline at end of file
diff --git a/src/modules/cheque_deposit/module.ini b/src/modules/cheque_deposit/module.ini
new file mode 100644
index 0000000..6da0975
--- /dev/null
+++ b/src/modules/cheque_deposit/module.ini
@@ -0,0 +1,7 @@
+name="Bordereau de remise de chèques"
+description="Permet d'imprimer un bordereau de remise de chèques à partir d'une écriture de dépôt."
+author="Paheko"
+author_url="https://paheko.cloud/"
+home_button=false
+restrict_section="accounting"
+restrict_level="read"
\ No newline at end of file
diff --git a/src/modules/cheque_deposit/snippets/transaction_details.html b/src/modules/cheque_deposit/snippets/transaction_details.html
new file mode 100644
index 0000000..95ca9bd
--- /dev/null
+++ b/src/modules/cheque_deposit/snippets/transaction_details.html
@@ -0,0 +1,27 @@
+{{if $module.config.accounts === null}}
+ {{:assign var="module.config.accounts.5112" value='5112'}}
+{{elseif $module.config.accounts|gettype === 'string'}}
+ {{:include file="/receipt_donation/_upgrade.tpl" keep="module.config.accounts"}}
+{{/if}}
+
+{{#foreach from=$transaction_lines item="line"}}
+ {{if !$line.credit}}
+ {{:continue}}
+ {{/if}}
+
+ {{#foreach from=$module.config.accounts key="code"}}
+ {{if $line.account_code|strpos:$code === 0}}
+ {{:assign show=true}}
+ {{:break}}
+ {{/if}}
+ {{/foreach}}
+
+ {{if $show}}
+ {{:break}}
+ {{/if}}
+{{/foreach}}
+
+
+{{if $show}}
+ {{:include file="/receipt/snippets/transaction_details.html" show=true}}
+{{/if}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/NOTES_DEV.md b/src/modules/expenses_claims/NOTES_DEV.md
new file mode 100644
index 0000000..31e563d
--- /dev/null
+++ b/src/modules/expenses_claims/NOTES_DEV.md
@@ -0,0 +1,3 @@
+## Mettre à jour les barème kilométriques
+
+Copier-coller avec LibreOffice les colonnes dans "bareme.csv" à partir de
diff --git a/src/modules/expenses_claims/_calcul_bareme.tpl b/src/modules/expenses_claims/_calcul_bareme.tpl
new file mode 100644
index 0000000..a37f4cc
--- /dev/null
+++ b/src/modules/expenses_claims/_calcul_bareme.tpl
@@ -0,0 +1,50 @@
+{{if !$distance || !$vehicule}}
+ {{:error message="Fonction 'calcul_bareme' : paramètre distance ou vehicule manquant"}}
+{{/if}}
+
+{{if $vehicule === "speedbike"}}
+ {{:assign vehicule="ecyclomoteur"}}
+{{/if}}
+
+{{if $vehicule|substr:0:1 === 'e'}}
+ {{:assign
+ bonus="1.2"
+ vehicule=$vehicule|substr:1
+ }}
+{{else}}
+ {{:assign bonus="1"}}
+{{/if}}
+
+{{:read file="./baremes.csv" assign="baremes"}}
+{{:assign baremes=$baremes|trim|explode:"\n"}}
+
+{{#foreach from=$baremes item="bareme"}}
+ {{:assign bareme=$bareme|str_getcsv}}
+ {{if $vehicule !== $bareme.0}}
+ {{:continue}}
+ {{/if}}
+
+ {{if $bareme.0 === 'cyclomoteur' || $bareme.0|substr:4 === 'moto'}}
+ {{if $distance <= 3000}}
+ {{:assign calcul=$bareme.1}}
+ {{elseif $distance <= 6000}}
+ {{:assign calcul=$bareme.2}}
+ {{else}}
+ {{:assign calcul=$bareme.3}}
+ {{/if}}
+ {{else}}
+ {{if $distance <= 5000}}
+ {{:assign calcul=$bareme.1}}
+ {{elseif $distance <= 20000}}
+ {{:assign calcul=$bareme.2}}
+ {{else}}
+ {{:assign calcul=$bareme.3}}
+ {{/if}}
+ {{/if}}
+ {{:break}}
+{{/foreach}}
+
+
+{{:assign calcul_maths=$calcul|replace:"x":"*"|replace:",":"."|replace:"d":$distance|regexp_replace:"/[^0-9\.*]/":""}}
+
+{{:assign resultat="round(%s*%f, 2)"|math:$calcul_maths:$bonus}}
diff --git a/src/modules/expenses_claims/_config_default.tpl b/src/modules/expenses_claims/_config_default.tpl
new file mode 100644
index 0000000..39b1191
--- /dev/null
+++ b/src/modules/expenses_claims/_config_default.tpl
@@ -0,0 +1,80 @@
+{{#load type="category" limit=1}}
+{{else}}
+ {{:assign var="account" value=null}}
+ {{:assign var="account.6251" value="6251 — Frais de déplacement"}}
+ {{:save type="category" schema="./category.schema.json"
+ key=""|uuid
+ label="Déplacement (forfaitaire)"
+ account=$account
+ expense_type="flat_rate"
+ price=5000
+ }}
+
+ {{:assign var="account" value=null}}
+ {{:assign var="account.6251" value="6251 — Frais de déplacement"}}
+ {{:save type="category" schema="./category.schema.json"
+ key=""|uuid
+ label="Déplacement (au kilomètre)"
+ account=$account
+ expense_type="km_vehicle"
+ }}
+
+ {{:assign var="account" value=null}}
+ {{:assign var="account.626" value="626 — Frais postaux ou télécommunication"}}
+ {{:save type="category" schema="./category.schema.json"
+ key=""|uuid
+ label="Frais postaux ou télécommunication"
+ account=$account
+ expense_type="other"
+ }}
+
+ {{:assign var="account" value=null}}
+ {{:assign var="account.6063" value="6063 — Fournitures d'entretien et petit équipement"}}
+ {{:save type="category" schema="./category.schema.json"
+ key=""|uuid
+ label="Fournitures d'entretien et petit équipement"
+ account=$account
+ expense_type="other"
+ }}
+
+ {{:assign var="account" value=null}}
+ {{:assign var="account.6065" value="6065 — Petits logiciels"}}
+ {{:save type="category" schema="./category.schema.json"
+ key=""|uuid
+ label="Logiciels à faible valeur (< 500 €)"
+ account=$account
+ expense_type="other"
+ }}
+
+ {{:assign var="account" value=null}}
+ {{:assign var="account.625" value="625 — Frais de réception"}}
+ {{:save type="category" schema="./category.schema.json"
+ key=""|uuid
+ label="Nourriture et autres frais de réception"
+ account=$account
+ expense_type="other"
+ }}
+{{/load}}
+
+{{:assign var="vehicles"
+ speedbike="Vélo électrique rapide (speed bike, > 25 km/h)"
+ ecyclomoteur="Moto ou scooter électrique <= 50 cc"
+ emoto1cv="Moto ou scooter électrique > 50 cc — 1 ou 2 CV"
+ emoto3cv="Moto ou scooter électrique > 50 cc — 3 à 5 CV"
+ emoto6cv="Moto ou scooter électrique > 50 cc — 6 CV et plus"
+ e3cv="Voiture électrique — 3CV et moins"
+ e4cv="Voiture électrique — 4CV"
+ e5cv="Voiture électrique — 5CV"
+ e6cv="Voiture électrique — 6CV"
+ e7cv="Voiture électrique — 7CV et plus"
+
+ 3cv="Voiture thermique — 3CV et moins"
+ 4cv="Voiture thermique — 4CV"
+ 5cv="Voiture thermique — 5CV"
+ 6cv="Voiture thermique — 6CV"
+ 7cv="Voiture thermique — 7CV et plus"
+ cyclomoteur="Moto ou scooter thermique <= 50 cc"
+ moto1cv="Moto ou scooter thermique > 50 cc — 1 ou 2 CV"
+ moto3cv="Moto ou scooter thermique > 50 cc — 3 à 5 CV"
+ moto6cv="Moto ou scooter thermique > 50 cc — 6 CV et plus"
+}}
diff --git a/src/modules/expenses_claims/abandon.html b/src/modules/expenses_claims/abandon.html
new file mode 100644
index 0000000..f9d3285
--- /dev/null
+++ b/src/modules/expenses_claims/abandon.html
@@ -0,0 +1,110 @@
+{{#restrict section="accounting" level="write" block=true}}{{/restrict}}
+
+{{#load assign="claim" key=$_GET.claim}}
+{{else}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/load}}
+
+{{if $claim.status !== 'payable'}}
+ {{:error message="Cette note de frais n'est pas en attente de paiement: %s"|args:$claim.status}}
+{{/if}}
+
+{{#transactions id=$claim.id_transaction}}
+ {{:assign total=$debit}}
+{{/transactions}}
+
+{{if $claim.payments|count}}
+ {{#transactions id=$claim.payments}}
+ {{:assign total="%d-%d"|math:$total:$credit}}
+ {{/transactions}}
+{{/if}}
+
+{{if $total <= 0}}
+ {{:error message="Cette note de frais a déjà été réglée ou abandonnée"}}
+{{/if}}
+
+{{#form on="save"}}
+ {{if !$_POST.label|trim}}
+ {{:error message="Le libellé doit être renseigné."}}
+ {{/if}}
+
+ {{:assign var="lines." debit=$total|money_raw account=$_POST.account|keys|key:0}}
+ {{:api
+ method="POST"
+ path="accounting/transaction"
+ assign="result"
+
+ id_year=$_POST.id_year|intval
+ type="revenue"
+ date=$_POST.date
+ label=$_POST.label|trim
+ reference="NDF-%d"|args:$claim.number
+ notes=$_POST.notes|trim|or:null
+ amount=$total|money_raw
+ debit=$_POST.account|keys|key:0
+ credit=$_POST.abandon_account|keys|key:0
+ linked_users=$claim.user_id
+ linked_transactions=$claim.id_transaction|intval
+ }}
+ {{:assign var="claim.payments." value=$result.id}}
+
+ {{:save key=$claim.key status="paid" payments=$claim.payments}}
+ {{:http redirect="details.html?key=%s"|args:$claim.key}}
+{{/form}}
+
+{{#load type="line" claim=$claim.key}}
+ {{:assign var="notes" value="%s\n\n%s"|args:$notes:$description}}
+{{/load}}
+{{:assign var="default_account.4110" value="4110 — Autres usagers"}}
+{{:assign var="default_abandon_account.75412" value="75412 — Abandons de frais par les bénévoles"}}
+{{#years closed=false}}
+ {{:assign var="open_years.%d"|args:$id value=$label}}
+ {{if $start_date <= $now && $end_date >= $now}}
+ {{:assign best_year=$id}}
+ {{/if}}
+{{/years}}
+{{:assign default_label="Abandon note de frais n°%d"|args:$claim.number}}
+
+{{if !$open_years|count}}
+ {{:error message="Aucun exercice n'est ouvert, il n'est pas possible de créer une écriture et donc d'accepter cette note de frais."}}
+{{/if}}
+
+{{:admin_header title="Abandonner la note de frais n°%d"|args:$claim.number}}
+
+{{:form_errors}}
+
+
+
+ Écriture d'abandon de note de frais
+
+ {{:input type="select" default=$best_year name="id_year" label="Exercice" required=true options=$open_years}}
+ {{:input type="date" default=$now name="date" label="Date" required=true}}
+ {{:input type="text" default=$default_label name="label" label="Libellé" required=true}}
+ {{:input type="list" name="account" label="Compte de tiers" required=true target="!acc/charts/accounts/selector.php?targets=4&key=code&year=%d"|args:$best_year default=$default_account}}
+ {{:input type="list" name="abandon_account" label="Compte d'abandon" required=true target="!acc/charts/accounts/selector.php?targets=6&key=code&year=%d"|args:$best_year default=$default_abandon_account}}
+ {{:input type="textarea" name="notes" label="Remarques" default=$notes|trim cols=50 rows=5}}
+
+
+
+
+ {{:button type="submit" shape="right" label="Abandonner le remboursement" name="save" class="main"}}
+
+
+ La note de frais sera marquée comme étant réglée.
+
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/accept.html b/src/modules/expenses_claims/accept.html
new file mode 100644
index 0000000..8cd908b
--- /dev/null
+++ b/src/modules/expenses_claims/accept.html
@@ -0,0 +1,139 @@
+{{#restrict section="accounting" level="write" block=true}}{{/restrict}}
+
+{{#load assign="claim" key=$_GET.claim}}
+{{else}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/load}}
+
+{{if $claim.status !== 'waiting'}}
+ {{:error message="Cette note de frais n'est pas en attente de validation: %s"|args:$claim.status}}
+{{/if}}
+
+{{#form on="save"}}
+ {{:assign total=0}}
+ {{:assign var="upload_path" value="%s/%s"|args:$module.storage_root:$claim.key}}
+
+ {{#foreach from=$_POST.lines|array_transpose}}
+ {{:assign var="lines." debit=$amount label=$label account=$account|keys|key:0}}
+ {{:assign amount=$amount|money_int}}
+ {{:assign total="%d+%d"|math:$amount:$total}}
+ {{/foreach}}
+
+ {{if !$total}}
+ {{:error message="Cette note de frais ne comporte aucune ligne"}}
+ {{/if}}
+
+ {{if !$_POST.label|trim}}
+ {{:error message="Le libellé doit être renseigné."}}
+ {{/if}}
+
+ {{:assign var="lines." credit=$total|money_raw account=$_POST.account|keys|key:0 label="Dette envers le membre"}}
+
+ {{:api
+ method="POST"
+ path="accounting/transaction"
+ assign="result"
+
+ id_year=$_POST.id_year|intval
+ type="advanced"
+ date=$_POST.date
+ label=$_POST.label|trim
+ reference="NDF-%d"|args:$claim.number
+ lines=$lines
+ notes=$_POST.notes|trim|or:null
+ linked_users=$claim.user_id
+ move_attachments_from=$upload_path
+ }}
+ {{:save key=$claim.key status="payable" id_transaction=$result.id}}
+
+ {{#users id=$claim.user_id}}
+ {{if !$_email}}
+ {{:break}}
+ {{/if}}
+ {{:include file="./messages/accept.txt" capture="message" message=$_POST.message|trim claim=$claim}}
+ {{:mail to=$_email subject="Votre note de frais a été acceptée" body=$message|raw}}
+ {{/users}}
+
+ {{:http redirect="details.html?key=%s"|args:$claim.key}}
+{{/form}}
+
+{{#load type="line" claim=$claim.key}}
+ {{:assign var="notes" value="%s\n\n%s"|args:$notes:$description}}
+{{/load}}
+{{:assign var="default_account.4110" value="4110 — Autres usagers"}}
+{{#years closed=false}}
+ {{:assign var="open_years.%d"|args:$id value=$label}}
+ {{if $start_date <= $now && $end_date >= $now}}
+ {{:assign best_year=$id}}
+ {{/if}}
+{{/years}}
+{{:assign default_label="Note de frais n°%d"|args:$claim.number}}
+
+{{if !$open_years|count}}
+ {{:error message="Aucun exercice n'est ouvert, il n'est pas possible de créer une écriture et donc d'accepter cette note de frais."}}
+{{/if}}
+
+{{:admin_header title="Accepter la note de frais n°%d"|args:$claim.number}}
+
+{{:form_errors}}
+
+
+
+ Écriture de note de frais
+
+ {{:input type="select" default=$best_year name="id_year" label="Exercice" required=true options=$open_years}}
+ {{:input type="date" default=$now name="date" label="Date" required=true}}
+ {{:input type="text" default=$claim.label|or:$default_label name="label" label="Libellé" required=true}}
+ {{:input type="list" name="account" label="Compte de tiers" required=true target="!acc/charts/accounts/selector.php?targets=4&key=code&year=%d"|args:$best_year default=$default_account}}
+ {{:input type="textarea" name="notes" label="Remarques" default=$notes|trim cols=50 rows=5}}
+
+
+
+
+ Lignes de la note de frais
+
+
+
+
+ Compte
+ Libellé
+ Référence
+ Montant
+
+
+
+ {{#load type="line" claim=$claim.key}}
+ {{:assign default_account=null}}
+ {{:assign var="default_account.%s"|args:$account value=$account}}
+
+ {{:input type="list" name="lines[account][]" required=true target="!acc/charts/accounts/selector.php?targets=5&key=code&year=%d"|args:$best_year default=$default_account}}
+ {{:input type="text" name="lines[label][]" default=$label required=false}}
+ {{:input type="text" name="lines[reference][]" default=$reference required=false size=10}}
+ {{:input type="money" name="lines[amount][]" default=$amount required=true}}
+
+ {{/load}}
+
+
+
+
+ {{:button type="submit" shape="right" label="Accepter" name="save" class="main"}}
+
+
+ La note de frais sera transformée en écriture comptable. Les fichiers joints seront déplacés dans l'écriture.
+
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/baremes.csv b/src/modules/expenses_claims/baremes.csv
new file mode 100644
index 0000000..b198590
--- /dev/null
+++ b/src/modules/expenses_claims/baremes.csv
@@ -0,0 +1,9 @@
+3cv,"d x 0,529","(d x 0,316) + 1 065","d x 0,370"
+4cv,"d x 0,606","(d x 0,340) + 1 330","d x 0,407"
+5cv,"d x 0,636","(d x 0,357) + 1 395","d x 0,427"
+6cv,"d x 0,665","(d x 0,374) + 1 457","d x 0,447"
+7cv,"d x 0,697","(d x 0,394) + 1 515","d x 0,470"
+moto1cv,"d x 0,395","(d x 0,099) + 891","d x 0,248"
+moto3cv,"d x 0,468","(d x 0,082) + 1 158","d x 0,275"
+moto6cv,"d x 0,606","(d x 0,079) + 1 583","d x 0,343"
+cyclomoteur,"d x 0,315","(d x 0,079) + 711","d x 0,198"
diff --git a/src/modules/expenses_claims/cat_delete.html b/src/modules/expenses_claims/cat_delete.html
new file mode 100644
index 0000000..0f2b40d
--- /dev/null
+++ b/src/modules/expenses_claims/cat_delete.html
@@ -0,0 +1,18 @@
+{{#restrict block=true section="config" level="admin"}}{{/restrict}}
+
+{{#load type="category" key=$_GET.key assign="cat"}}
+ {{:assign key=$key}}
+{{else}}
+ {{:error message="La catégorie indiquée n'existe pas"}}
+{{/load}}
+
+{{#form on="delete"}}
+ {{:delete key=$key}}
+ {{:http redirect="./config.html"}}
+{{/form}}
+
+{{:admin_header title="Supprimer"}}
+
+{{:delete_form legend="Supprimer une catégorie" warning="Supprimer la catégorie '%s' ?"|args:$cat.label}}
+
+{{:admin_footer}}
diff --git a/src/modules/expenses_claims/cat_edit.html b/src/modules/expenses_claims/cat_edit.html
new file mode 100644
index 0000000..d51c10b
--- /dev/null
+++ b/src/modules/expenses_claims/cat_edit.html
@@ -0,0 +1,77 @@
+{{#restrict block=true section="config" level="admin"}}{{/restrict}}
+
+{{if $_GET.key !== 'new'}}
+ {{#load type="category" key=$_GET.key assign="cat"}}
+ {{:assign key=$key}}
+ {{else}}
+ {{:error message="La catégorie indiquée n'existe pas"}}
+ {{/load}}
+{{else}}
+ {{:assign key=""|uuid}}
+{{/if}}
+
+{{#form on="save"}}
+ {{if !$_POST.label|trim}}
+ {{:error message="Le nom de la catégorie ne peut être laissé vide."}}
+ {{/if}}
+
+ {{:save type="category"
+ schema="./category.schema.json"
+ key=$key
+ label=$_POST.label|trim
+ account=$_POST.account|arrayval|or:null
+ price=$_POST.price|money_int|or:null
+ km=$_POST.vehicle|strval|trim|or:null
+ expense_type=$_POST.expense_type
+ notes=$_POST.notes|trim|or:null
+ }}
+ {{:redirect to="./config.html"}}
+
+{{/form}}
+
+{{:admin_header title="Catégorie de note de frais"}}
+
+{{:form_errors}}
+
+{{:assign var="types"
+ other="Autres"
+ flat_rate="Forfaitaire"
+ km_vehicle="Kilométrique, selon le type de véhicule, au barème légal"
+ km_free="Kilométrique, avec un barème personnalisé"
+}}
+
+
+
+
+ Catégorie
+
+ {{:input type="text" name="label" label="Nom de la catégorie" required=true source=$cat}}
+ {{:input type="textarea" cols=50 rows=5 name="notes" label="Instructions" required=false source=$cat help="Ces instructions seront affichées en dessous du champ description.\nUtile par exemple pour indiquer quelles sont les informations supplémentaires que le membre doit renseigner."}}
+ {{:input type="list" required=false name="account" label="Compte de dépense" source=$cat target="!acc/charts/accounts/selector.php?targets=5&key=code"}}
+ {{:input type="select" name="expense_type" required=true label="Type de dépense" options=$types source=$cat}}
+
+
+ {{:input type="money" name="price" label="Prix au kilomètre" required=true source=$cat}}
+
+
+ {{:input type="money" name="price" label="Prix unitaire" required=true source=$cat}}
+
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/category.schema.json b/src/modules/expenses_claims/category.schema.json
new file mode 100644
index 0000000..83944b6
--- /dev/null
+++ b/src/modules/expenses_claims/category.schema.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["category"]
+ },
+ "label": {
+ "type": "string",
+ "description": "Nom de la catégorie"
+ },
+ "notes": {
+ "type": ["string", "null"],
+ "description": "Instructions"
+ },
+ "account": {
+ "type": ["string", "null"],
+ "description": "Code comptable"
+ },
+ "price": {
+ "type": ["integer", "null"],
+ "description": "Prix unitaire"
+ },
+ "expense_type": {
+ "type": "string",
+ "description": "Type de dépense",
+ "enum": ["other", "km_free", "km_vehicle"]
+ }
+ },
+ "required": ["type", "label", "notes", "account", "expense_type"]
+}
diff --git a/src/modules/expenses_claims/claim.schema.json b/src/modules/expenses_claims/claim.schema.json
new file mode 100644
index 0000000..bd3fc36
--- /dev/null
+++ b/src/modules/expenses_claims/claim.schema.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["claim"]
+ },
+ "number": {
+ "description": "Nombre unique",
+ "type": "integer"
+ },
+ "label": {
+ "description": "Libellé",
+ "type": ["string", "null"]
+ },
+ "date": {
+ "description": "Date",
+ "type": "string",
+ "format": "date"
+ },
+ "user_id": {
+ "description": "ID membre",
+ "type": "integer"
+ },
+ "user_name": {
+ "description": "Nom membre",
+ "type": "string"
+ },
+ "status": {
+ "description": "Statut",
+ "type": "string",
+ "enum": ["draft", "waiting", "payable", "paid", "cancelled"]
+ },
+ "id_transaction": {
+ "type": "integer"
+ },
+ "payments": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ }
+ },
+ "required": ["type", "number", "label", "date", "user_id", "user_name", "status"]
+}
diff --git a/src/modules/expenses_claims/config.html b/src/modules/expenses_claims/config.html
new file mode 100644
index 0000000..9528152
--- /dev/null
+++ b/src/modules/expenses_claims/config.html
@@ -0,0 +1,49 @@
+{{#restrict block=true section="config" level="admin"}}{{/restrict}}
+{{:include file="./_config_default.tpl" keep="vehicles"}}
+
+{{#form on="save"}}
+ {{:save key="config" notify_users=$_POST.notify_users|arrayval}}
+ {{:redirect to="./config.html?msg=ok"}}
+{{/form}}
+
+{{:admin_header title="Configuration des notes de frais"}}
+
+{{if $_GET.msg === 'ok'}}
+ La configuration a été enregistrée.
+{{/if}}
+
+{{:form_errors}}
+
+
+
+ {{:linkbutton shape="plus" href="./cat_edit.html?key=new" label="Ajouter une catégorie"}}
+
+ {{:linkbutton shape="left" href="./" label="Liste des notes de frais"}}
+
+
+{{#list type="category" schema="./category.schema.json" columns="label" order="label" disable_user_ordering=true}}
+
+ {{$label}}
+
+ {{:linkbutton shape="edit" href="./cat_edit.html?key=%s"|args:$key label="Modifier"}}
+ {{:linkbutton shape="delete" href="./cat_delete.html?key=%s"|args:$key label="Supprimer" target="_dialog"}}
+
+
+{{/list}}
+
+
+
+
+ Notifications
+
+ {{:input type="list" name="notify_users" source=$module.config default=$config.org_email label="Membres à notifier lors du dépôt d'une nouvelle note de frais" help="Si ce champ est laissé vide, la notification sera envoyée à l'adresse e-mail de l'association. Maximum 10 membres." required=false multiple=true target="!users/selector.php" max=10}}
+
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/delete.html b/src/modules/expenses_claims/delete.html
new file mode 100644
index 0000000..4e72b1b
--- /dev/null
+++ b/src/modules/expenses_claims/delete.html
@@ -0,0 +1,53 @@
+{{#load assign="claim" key=$_GET.claim}}
+{{else}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/load}}
+
+{{#restrict section="accounting" level="write"}}
+ {{:assign is_admin=true}}
+{{else}}
+ {{:assign is_admin=false}}
+ {{if $claim.user_id !== $logged_user.id}}
+ {{:error message="L'accès à cette note de frais est interdit"}}
+ {{/if}}
+{{/restrict}}
+
+{{if $claim.status === 'draft' || $claim.status === 'cancelled'}}
+ {{:assign can_delete=true}}
+{{/if}}
+
+{{if !$is_admin && !$can_delete}}
+ {{:error message="Vous n'avez pas le droit de supprimer cette note de frais"}}
+{{/if}}
+
+{{:assign var="upload_path" value=$claim.key}}
+
+{{#form on="delete"}}
+ {{if !$can_delete && !$_POST.confirm_delete}}
+ {{:error message="La case de confirmation n'est pas cochée"}}
+ {{/if}}
+
+ {{:delete_file path=$upload_path}}
+ {{:delete type="line" claim=$claim.key}}
+ {{:delete type="claim" key=$claim.key}}
+ {{:http redirect="./"}}
+{{/form}}
+
+{{:admin_header title="Supprimer note de frais n°%d"|args:$claim.number}}
+
+{{if !$can_delete}}
+ {{:assign confirm="Cette note de frais a été acceptée, cocher cette case pour supprimer la note de frais (les écritures liées ne seront pas supprimées)"}}
+{{/if}}
+
+{{if $claim.id_transaction}}
+ {{:assign info="Attention : l'écriture #%d liée à la note de frais ne sera pas supprimée."|args:$claim.id_transaction}}
+{{/if}}
+
+{{:delete_form
+ legend="Supprimer une note de frais"
+ warning="Supprimer la note de frais n°%d ?"|args:$claim.number
+ confirm=$confirm
+ info=$info
+}}
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/deny.html b/src/modules/expenses_claims/deny.html
new file mode 100644
index 0000000..6e6b842
--- /dev/null
+++ b/src/modules/expenses_claims/deny.html
@@ -0,0 +1,58 @@
+{{#restrict section="accounting" level="write" block=true}}{{/restrict}}
+
+{{#load assign="claim" key=$_GET.claim}}
+{{else}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/load}}
+
+{{if $claim.status !== 'waiting'}}
+ {{:error message="Cette note de frais n'est pas en attente de validation: %s"|args:$claim.status}}
+{{/if}}
+
+{{:assign var="upload_path" value=$claim.key}}
+
+{{#form on="save"}}
+ {{#users id=$claim.user_id}}
+ {{if !$_email}}
+ {{:break}}
+ {{/if}}
+ {{:include file="./messages/deny.txt" capture="message" message=$_POST.message|trim claim=$claim action=$_POST.action}}
+ {{:mail to=$_email subject="Votre note de frais a été refusée" body=$message|raw}}
+ {{/users}}
+
+ {{if $_POST.action === 'delete'}}
+ {{:delete_file path=$upload_path}}
+ {{:delete type="line" claim=$claim.key}}
+ {{:delete type="claim" key=$claim.key}}
+ {{:redirect to="./"}}
+ {{elseif $_POST.action === 'cancel'}}
+ {{:save status="cancelled" key=$claim.key}}
+ {{else}}
+ {{:save status="draft" key=$claim.key}}
+ {{/if}}
+
+ {{:redirect to="details.html?key=%s"|args:$claim.key}}
+{{/form}}
+
+{{:admin_header title="Refuser la note de frais n°%d"|args:$claim.number}}
+
+{{:form_errors}}
+
+
+
+ Refuser la note de frais ?
+
+ {{:input type="textarea" name="message" cols=50 rows=5 label="Motif de refus" help="Ce message sera envoyé au membre à l'origine de la note de frais."}}
+ Quelle action effectuer ? (obligatoire)
+ {{:input type="radio" name="action" value="draft" label="Repasser la note de frais en brouillon" help="Le membre pourra la modifier et la soumettre à nouveau." default="draft"}}
+ {{:input type="radio" name="action" value="cancel" label="Annuler la note de frais" help="Le membre ne pourra pas la modifier, il ne pourra que la supprimer."}}
+ {{:input type="radio" name="action" value="delete" label="Supprimer la note de frais" help="La note de frais et ses pièces jointes seront supprimées."}}
+
+
+
+
+ {{:button type="submit" shape="right" label="Refuser" name="save" class="main"}}
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/details.html b/src/modules/expenses_claims/details.html
new file mode 100644
index 0000000..d7cb83b
--- /dev/null
+++ b/src/modules/expenses_claims/details.html
@@ -0,0 +1,227 @@
+{{if $_GET.key}}
+ {{#load assign="claim" key=$_GET.key}}{{/load}}
+{{elseif $_GET.num}}
+ {{#load assign="claim" number=$_GET.num|intval}}{{/load}}
+{{/if}}
+
+{{if !$claim}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/if}}
+
+{{#restrict section="accounting" level="write"}}
+ {{:assign is_admin=true}}
+{{else}}
+ {{:assign is_admin=false}}
+ {{if $claim.user_id !== $logged_user.id}}
+ {{:error message="L'accès à cette note de frais est interdit"}}
+ {{/if}}
+{{/restrict}}
+
+{{:assign var="upload_path" value=$claim.key}}
+
+{{if $claim.status === 'draft'}}
+ {{#form on="validate"}}
+ {{:save key=$claim.key status="waiting"}}
+ {{:include file="./messages/validate.txt" claim=$claim capture="message"}}
+
+ {{if $module.config.notify_users|count}}
+ {{#users id=$module.config.notify_users|keys}}
+ {{:assign var="notify_emails." value=$_email}}
+ {{/users}}
+ {{else}}
+ {{:assign var="notify_emails." value=$config.org_email}}
+ {{/if}}
+ {{:mail to=$notify_emails subject="Une nouvelle note de frais a été soumise" body=$message|raw}}
+ {{:http redirect="details.html?key=%s"|args:$claim.key}}
+ {{/form}}
+
+ {{#form on="delete_line"}}
+ {{:delete type="line" claim=$claim.key key=$_POST.delete_line}}
+ {{:http redirect="details.html?key=%s"|args:$claim.key}}
+ {{/form}}
+
+{{elseif $claim.status === 'waiting'}}
+ {{if $claim.user_id === $logged_user.id}}
+ {{#form on="cancel"}}
+ {{:save key=$claim.key status="cancelled"}}
+ {{:http redirect="details.html?key=%s"|args:$claim.key}}
+ {{/form}}
+ {{/if}}
+{{/if}}
+
+{{:admin_header title="Note de frais n°%d — %s"|args:$claim.number:$claim.label}}
+
+
+
+
+
+ {{:linkbutton shape="plus" label="Dupliquer" href="./duplicate.html?claim=%s"|args:$claim.key}}
+{{if $is_admin || $claim.status === 'draft' || $claim.status === 'cancelled'}}
+ {{:linkbutton shape="delete" label="Supprimer" href="./delete.html?claim=%s"|args:$claim.key}}
+{{/if}}
+{{if $claim.status === 'draft' || $claim.status === 'cancelled'}}
+ {{:linkbutton shape="edit" label="Modifier" href="./edit.html?claim=%s"|args:$claim.key target="_dialog"}}
+{{/if}}
+
+ {{:linkbutton shape="left" label="Retour à la liste des notes de frais" href="./"}}
+
+
+
+
+{{:form_errors}}
+
+{{#load select="SUM($$.amount) AS total" where="$$.type = 'line' AND $$.claim = :claim" :claim=$claim.key}}
+ {{:assign total=$total}}
+{{/load}}
+
+
+
+{{if $claim.status === 'draft' && $total}}
+
+
Statut : brouillon
+
{{:button shape="check" name="validate" label="Valider" type="submit" class="main"}}
+
En cliquant sur ce bouton, la note de frais sera transmise aux comptables, elle ne pourra plus être modifiée.
+
+{{elseif $claim.status === 'waiting'}}
+
+
Statut : en attente de validation
+
{{:button shape="delete" name="cancel" label="Annuler" type="submit"}}
+
En cliquant sur ce bouton, la note de frais sera annulée.
+
+{{/if}}
+
+
+{{if $is_admin}}
+ {{if $claim.status === 'waiting'}}
+
+ Accepter cette note de frais ?
+ {{:linkbutton href="accept.html?claim=%s"|args:$claim.key shape="check" label="Accepter cette note de frais" class="main"}}
+ Une confirmation sera demandée. Il sera possible de modifier les comptes associés.
+ {{:linkbutton href="deny.html?claim=%s"|args:$claim.key shape="delete" label="Refuser cette note de frais"}}
+
+ {{/if}}
+{{/if}}
+
+
+ Numéro de note de frais
+ {{$claim.number}}
+ Objet
+ {{$claim.label|or:"— Non spécifié —"}}
+ Date
+ {{$claim.date|date_short}}
+ Membre
+
+ {{if $claim.user_name && $claim.user_id && $is_admin}}
+ {{:link href="!users/details.php?id=%d"|args:$claim.user_id label=$claim.user_name}}
+ {{else}}
+ {{$claim.user_name}}
+ {{/if}}
+
+ Statut
+
+ {{if $claim.status === 'draft'}}
+ Brouillon
+ {{elseif $claim.status === 'waiting'}}
+ En attente d'acceptation
+ {{elseif $claim.status === 'payable'}}
+ Acceptée, en attente de paiement
+ {{elseif $claim.status === 'paid'}}
+ Payée
+ {{elseif $claim.status === 'cancelled'}}
+ Annulée
+ {{/if}}
+
+
+ Montant total
+ {{$total|money_currency:false:false}}
+
+ {{if $claim.id_transaction}}
+ Écriture de note de frais
+ {{:link class="num" href="!acc/transactions/details.php?id=%d"|args:$claim.id_transaction label="#%d"|args:$claim.id_transaction}}
+ {{/if}}
+
+
+ {{#foreach from=$claim.transactions item="id"}}
+ Écriture de paiement
+ {{:link class="num" href="!acc/transactions/details.php?id=%d"|args:$id label="#%d"|args:$id}}
+ {{/foreach}}
+
+
+{{if $claim.status === 'draft'}}
+
+ {{:linkbutton shape="plus" label="Ajouter une ligne à la note de frais" href="./line.html?claim=%s"|args:$claim.key target="_dialog"}}
+
+{{/if}}
+{{#list
+ select="$$.label AS 'Libellé'; $$.description AS 'Description'; $$.reference AS 'Réf. justificatif'; $$.category AS 'Catégorie'; $$.amount AS 'Montant'"
+ order=1
+ desc=false
+ where="$$.type = 'line' AND $$.claim = :claim"
+ :claim=$claim.key
+}}
+
+ {{$label}}
+ {{$description|escape|nl2br}}
+ {{$reference}}
+ {{$category}}
+ {{$amount|money_currency}}
+
+ {{if $.claim.status === 'draft'}}
+ {{:button name="delete_line" type="submit" value=$key label="Supprimer" shape="delete"}}
+ {{/if}}
+
+
+{{else}}
+
+ Aucune ligne dans cette note de frais.
+
+{{/list}}
+
+
+{{if $claim.status === 'payable' || $claim.status === 'paid'}}
+ Les fichiers joints ont été déplacés dans l'écriture de note de frais {{:link class="num" href="!acc/transactions/details.php?id=%d"|args:$claim.id_transaction label="#%d"|args:$claim.id_transaction}}
+ Paiements (remboursements)
+{{else}}
+ {{if $claim.status === 'cancelled'}}
+ {{:assign edit=true upload=false}}
+ {{elseif $claim.user_id === $logged_user.id && $claim.status === 'draft'}}
+ {{:assign edit=true upload=true}}
+ {{elseif $is_admin}}
+ {{:assign edit=true upload=true}}
+ {{else}}
+ {{:assign edit=false upload=false}}
+ {{/if}}
+ {{:admin_files edit=$edit upload=$upload path=$upload_path use_trash=false}}
+{{/if}}
+
+{{if $claim.payments}}
+
+
+
+ Num.
+ Date
+ Libellé
+ Montant
+
+
+
+ {{#transactions id=$claim.payments}}
+
+ {{:link class="num" href="!acc/transactions/details.php?id=%d"|args:$id label="#%d"|args:$id}}
+ {{$date|date_short}}
+ {{$label}}
+ {{$credit|money_currency}}
+
+ {{/transactions}}
+
+
+{{/if}}
+
+{{if $claim.status === 'payable'}}
+
+ {{:linkbutton shape="plus" label="Saisir un paiement" href="payment.html?claim=%s"|args:$claim.key target="_dialog"}}
+ {{:linkbutton shape="reload" label="Transformer en abandon de frais" href="abandon.html?claim=%s"|args:$claim.key target="_dialog"}}
+
+{{/if}}
+
+{{:admin_footer}}
diff --git a/src/modules/expenses_claims/duplicate.html b/src/modules/expenses_claims/duplicate.html
new file mode 100644
index 0000000..682ccb6
--- /dev/null
+++ b/src/modules/expenses_claims/duplicate.html
@@ -0,0 +1,79 @@
+{{#load assign="claim" key=$_GET.claim}}
+{{else}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/load}}
+
+{{#restrict section="accounting" level="write"}}
+ {{:assign is_admin=true}}
+{{else}}
+ {{:assign is_admin=false}}
+{{/restrict}}
+
+{{#form on="save"}}
+ {{#load select="MAX($$.number) AS number" type="claim" assign="max"}}
+ {{/load}}
+ {{:assign new_number="%d+1"|math:$max.number}}
+ {{:assign new_key=""|uuid}}
+
+ {{if $is_admin}}
+ {{#foreach from=$_POST.user key="id" item="name"}}
+ {{:assign user_id=$id user_name=$name}}
+ {{else}}
+ {{:error message="Aucun membre sélectionné"}}
+ {{/foreach}}
+ {{else}}
+ {{:assign user_id=$logged_user.id user_name=$logged_user._name}}
+ {{/if}}
+
+ {{:save
+ validate_schema="./claim.schema.json"
+ key=$new_key
+ number=$new_number
+ type="claim"
+ label=$_POST.label|trim|or:null
+ date=$now|date_format:"%Y-%m-%d"
+ user_id=$user_id
+ user_name=$user_name
+ status="draft"
+ }}
+ {{#load type="line" claim=$claim.key}}
+ {{:save validate_schema="./line.schema.json"
+ key="uuid"
+ type="line"
+ claim=$new_key
+ label=$label
+ category=$category
+ account=$account|strval|or:null
+ description=$description
+ amount=$amount
+ reference=$reference
+
+ }}
+ {{/load}}
+ {{:redirect to="./details.html?key=%s"|args:$new_key}}
+{{/form}}
+
+{{:admin_header title="Dupliquer une note de frais"}}
+
+{{:form_errors}}
+
+
+
+ Dupliquer la note de frais n°{{$claim.number}}
+
+ {{if $is_admin}}
+ {{:assign var="me.%d"|args:$logged_user.id value=$logged_user._name}}
+ {{:input type="list" name="user" label="Membre" required=true default=$me target="!users/selector.php"}}
+ {{else}}
+ Membre
+ {{$logged_user._name}}
+ {{/if}}
+ {{:input type="text" name="label" label="Objet de la note de frais" required=false help="Courte description de l'objet de la note de frais." source=$claim}}
+
+
+
+ {{:button type="submit" shape="right" label="Dupliquer" name="save" class="main"}}
+
+
+
+{{:admin_footer}}
diff --git a/src/modules/expenses_claims/edit.html b/src/modules/expenses_claims/edit.html
new file mode 100644
index 0000000..845d6fb
--- /dev/null
+++ b/src/modules/expenses_claims/edit.html
@@ -0,0 +1,41 @@
+{{#restrict section="accounting" level="write"}}
+ {{:assign is_admin=true}}
+{{else}}
+ {{:assign is_admin=false}}
+{{/restrict}}
+
+{{#load assign="claim" key=$_GET.claim}}
+{{else}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/load}}
+
+{{if !$is_admin && $claim.status !== 'draft'}}
+ {{:error message="Cette note de frais n'est pas en brouillon"}}
+{{/if}}
+
+{{#form on="save"}}
+ {{:save
+ validate_schema="./claim.schema.json"
+ key=$claim.key
+ label=$_POST.label|trim|or:null
+ }}
+ {{:redirect to="./details.html?key=%s"|args:$key}}
+{{/form}}
+
+{{:admin_header title="Modifier la note de frais"}}
+
+{{:form_errors}}
+
+
+
+ Modifier la note de frais
+
+ {{:input type="text" name="label" label="Objet de la note de frais" required=false help="Courte description de l'objet de la note de frais." source=$claim}}
+
+
+
+ {{:button type="submit" shape="right" label="Enregistrer" name="save" class="main"}}
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/icon.svg b/src/modules/expenses_claims/icon.svg
new file mode 100644
index 0000000..ae23317
--- /dev/null
+++ b/src/modules/expenses_claims/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/modules/expenses_claims/index.html b/src/modules/expenses_claims/index.html
new file mode 100644
index 0000000..ccc14f6
--- /dev/null
+++ b/src/modules/expenses_claims/index.html
@@ -0,0 +1,57 @@
+{{:admin_header title="Notes de frais"}}
+
+
+
+ {{#restrict section="config" level="admin"}}
+ {{:linkbutton href="config.html" label="Configuration" shape="settings"}}
+ {{/restrict}}
+ {{:linkbutton href="new.html" label="Nouvelle note de frais" shape="plus"}}
+
+
+ {{#restrict section="accounting" level="write"}}
+
+ {{/restrict}}
+
+
+{{:assign where="$$.user_id = %d"|args:$logged_user.id}}
+
+{{#restrict section="accounting" level="write"}}
+ {{if $_GET.status === 'all'}}
+ {{:assign where="1"}}
+ {{elseif $_GET.status}}
+ {{:assign status=$_GET.status|quote_sql}}
+ {{:assign where="$$.status = %s"|args:$status}}
+ {{/if}}
+{{/restrict}}
+
+{{#list
+ select="$$.number AS 'Numéro'; $$.user_name AS 'Membre'; $$.date AS 'Date'; $$.label AS 'Libellé'; CASE $$.status WHEN 'waiting' THEN 'À accepter' WHEN 'payable' THEN 'À payer' WHEN 'paid' THEN 'Payée' WHEN 'cancelled' THEN 'Annulée' ELSE 'Brouillon' END AS 'Statut'"|args:$config.user_fields.name_sql
+ order=1
+ desc=true
+ where="$$.type = 'claim' AND %s"|args:$where
+}}
+
+ {{:link href="details.html?key=%s"|args:$key label=$number}}
+ {{$col2}}
+ {{$date|date_short}}
+ {{$col4}}
+ {{$col5}}
+
+ {{:linkbutton href="details.html?key=%s"|args:$key label="Ouvrir" shape="menu"}}
+
+
+{{else}}
+
+ Aucune note de frais ici.
+
+{{/list}}
+
+{{:admin_footer}}
diff --git a/src/modules/expenses_claims/line.html b/src/modules/expenses_claims/line.html
new file mode 100644
index 0000000..6745c72
--- /dev/null
+++ b/src/modules/expenses_claims/line.html
@@ -0,0 +1,189 @@
+{{#load assign="claim" key=$_GET.claim}}
+{{else}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/load}}
+
+{{if $claim.status !== 'draft'}}
+ {{:error message="Cette note de frais n'est pas un brouillon, il n'est pas possible de la modifier."}}
+{{/if}}
+
+{{:include file="./_config_default.tpl" keep="module.config,vehicles"}}
+
+{{:assign var="categories_select.other" value="Autre"}}
+
+{{#load type="category" assign="categories." order="$$.label COLLATE U_NOCASE"}}
+ {{:assign var="categories_select[%s]"|args:$key value=$label}}
+{{/load}}
+
+{{#form on="save"}}
+ {{if !$_POST.label|trim}}
+ {{:error message="Le libellé ne peut être laissé vide"}}
+ {{/if}}
+
+ {{if $_POST.category !== 'other'}}
+ {{#load type="category" key=$_POST.category assign="cat"}}
+ {{else}}
+ {{:error message="Catégorie inconnue"}}
+ {{/load}}
+ {{/if}}
+
+ {{if $cat.expense_type === 'km_vehicle'}}
+ {{if !$_POST.vehicle|trim}}
+ {{:error message="Aucun véhicule sélectionné"}}
+ {{elseif $_POST.distance|intval < 1}}
+ {{:error message="La distance parcourue ne peut être inférieure à 1 km"}}
+ {{/if}}
+
+ {{:include file="./_calcul_bareme.tpl" vehicule=$_POST.vehicle distance=$_POST.distance keep="resultat,calcul,bonus"}}
+ {{if $bonus > 1}}
+ {{:assign bonus="+20% (véhicule électrique)"}}
+ {{else}}
+ {{:assign bonus="Non"}}
+ {{/if}}
+ {{:assign var="vehicle_name" from="vehicles.%s"|args:$_POST.vehicle}}
+ {{:assign
+ amount=$resultat
+ description="Départ : %s\nArrivée : %s\nVéhicule : %s\nDistance : %s km\nCalcul : %s\nBonus : %s\n\n%s"|args:$_POST.depart:$_POST.arrivee:$vehicle_name:$_POST.distance:$calcul:$bonus:$_POST.description|trim
+ }}
+ {{elseif $cat.expense_type === 'km_free'}}
+ {{if $_POST.distance|intval < 1}}
+ {{:error message="La distance parcourue ne peut être inférieure à 1 km"}}
+ {{/if}}
+
+ {{:assign bareme=$cat.price|money_currency:false:false:false}}
+ {{:assign
+ amount="%d*%d"|math:$_POST.distance:$cat.price|money_raw
+ description="Départ : %s\nArrivée : %s\nDistance : %s km\nBarème : %s par km\n\n%s"|args:$_POST.depart:$_POST.arrivee:$_POST.distance:$bareme:$_POST.description|trim
+ }}
+ {{/if}}
+
+ {{:save
+ validate_schema="./line.schema.json"
+ key="uuid"
+ type="line"
+ claim=$claim.key
+ label=$_POST.label|trim
+ category=$cat.label|or:'Autre'
+ account=$cat.account|keys|key:0|strval|or:null
+ description=$description|or:$_POST.description|trim|or:null
+ amount=$amount|or:$_POST.amount|money_int
+ reference=$_POST.reference|trim|or:null
+ }}
+ {{:http redirect="details.html?key=%s"|args:$claim.key}}
+{{/form}}
+
+{{:admin_header title="Ajouter une ligne à la note de frais n°%d"|args:$claim.number}}
+
+{{:form_errors}}
+
+
+
+ Dépense
+
+ {{:input type="text" name="label" label="Libellé" help="Inclure ici une description courte, par exemple 'Abonnement fibre atelier, mars 2023'." required=true}}
+ {{:input type="select" name="category" options=$categories_select label="Catégorie" required=true default_empty="— Sélectionner une catégorie —"}}
+
+
+ {{:input type="textarea" name="description" label="Description"}}
+
+ {{:input type="text" name="reference" label="Référence du justificatif" help="Par exemple : numéro de facture, nom du magasin, etc."}}
+
+
+ {{:input type="select" name="vehicle" options=$vehicles label="Type de véhicule" required=true}}
+ --
+
+
+
+ {{:input type="text" name="depart" label="Lieu de départ" required=true}}
+ {{:input type="text" name="arrivee" label="Lieu d'arrivée" required=true}}
+ {{:input type="number" name="distance" options=$vehicles label="Distance parcourue" required=true min=1 step=1 size=5}}
+
+ Le barème sera calculé lors de l'enregistrement, en fonction de la distance et du type de véhicule.
+
+
+
+ {{:input type="money" name="amount" label="Montant total" required=true}}
+
+
+
+
+ {{:button type="submit" shape="right" label="Enregistrer" name="save" class="main"}}
+
+
+
+
+
+{{:admin_footer}}
diff --git a/src/modules/expenses_claims/line.schema.json b/src/modules/expenses_claims/line.schema.json
new file mode 100644
index 0000000..1be6db9
--- /dev/null
+++ b/src/modules/expenses_claims/line.schema.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["line"]
+ },
+ "claim": {
+ "type": "string"
+ },
+ "label": {
+ "description": "Libellé",
+ "type": ["string"]
+ },
+ "account": {
+ "description": "Code comptable",
+ "type": ["string", "null"]
+ },
+ "category": {
+ "description": "Catégorie",
+ "type": ["string", "null"]
+ },
+ "description": {
+ "description": "Description",
+ "type": ["string", "null"]
+ },
+ "amount": {
+ "description": "Montant",
+ "type": "integer"
+ },
+ "reference": {
+ "description": "Réf.",
+ "type": ["string", "null"]
+ }
+ },
+ "required": ["type", "claim", "label", "account", "category", "description", "amount", "reference"]
+}
diff --git a/src/modules/expenses_claims/messages/accept.txt b/src/modules/expenses_claims/messages/accept.txt
new file mode 100644
index 0000000..879854b
--- /dev/null
+++ b/src/modules/expenses_claims/messages/accept.txt
@@ -0,0 +1,6 @@
+{{**keep_whitespaces**}}
+Bonjour,
+
+votre note de frais n°{{$claim.number}} a été acceptée.
+
+Cordialement.
\ No newline at end of file
diff --git a/src/modules/expenses_claims/messages/deny.txt b/src/modules/expenses_claims/messages/deny.txt
new file mode 100644
index 0000000..feb453a
--- /dev/null
+++ b/src/modules/expenses_claims/messages/deny.txt
@@ -0,0 +1,25 @@
+{{**keep_whitespaces**}}
+Bonjour,
+
+votre note de frais n°{{$claim.number}} a été refusée pour le motif suivant :
+
+{{if !$message}}(aucun motif indiqué){{else}}{{$message|raw}}{{/if}}
+
+{{if $action === 'delete'}}
+La note de frais a été supprimée.
+{{elseif $action === 'draft'}}
+La note de frais est repassée en brouillon.
+
+Vous pouvez la corriger et re-demander sa validation ici :
+
+{{$module.url|raw}}details.html?key={{$claim.key}}
+{{elseif $action === 'cancel'}}
+La note de frais a été annulée.
+
+Vous pouvez la supprimer à cette adresse :
+
+{{$module.url|raw}}details.html?key={{$claim.key}}
+{{/if}}
+
+
+Cordialement.
\ No newline at end of file
diff --git a/src/modules/expenses_claims/messages/validate.txt b/src/modules/expenses_claims/messages/validate.txt
new file mode 100644
index 0000000..5f4ed8d
--- /dev/null
+++ b/src/modules/expenses_claims/messages/validate.txt
@@ -0,0 +1,11 @@
+{{**keep_whitespaces**}}
+Bonjour,
+
+Une nouvelle note de frais a été soumise par un membre.
+
+Cliquez sur ce lien pour la consulter, puis l'accepter ou la refuser :
+
+{{$module.url|raw}}details.html?key={{$claim.key}}
+
+
+Cordialement.
\ No newline at end of file
diff --git a/src/modules/expenses_claims/module.ini b/src/modules/expenses_claims/module.ini
new file mode 100644
index 0000000..8415240
--- /dev/null
+++ b/src/modules/expenses_claims/module.ini
@@ -0,0 +1,7 @@
+name="Notes de frais"
+description="Permet aux membres de créer des notes de frais, et ensuite aux personnes gérant la comptabilité de les valider et transformer en écritures comptables."
+author="Paheko"
+author_url="https://paheko.cloud/"
+home_button=true
+restrict_section="connect"
+restrict_level="read"
diff --git a/src/modules/expenses_claims/new.html b/src/modules/expenses_claims/new.html
new file mode 100644
index 0000000..6ae255c
--- /dev/null
+++ b/src/modules/expenses_claims/new.html
@@ -0,0 +1,60 @@
+{{#restrict section="accounting" level="write"}}
+ {{:assign is_admin=true}}
+{{else}}
+ {{:assign is_admin=false}}
+{{/restrict}}
+
+{{#form on="save"}}
+ {{#load select="MAX($$.number) AS number" type="claim" assign="max"}}
+ {{/load}}
+ {{:assign new_number="%d+1"|math:$max.number}}
+ {{:assign key=""|uuid}}
+
+ {{if $is_admin}}
+ {{#foreach from=$_POST.user key="id" item="name"}}
+ {{:assign user_id=$id user_name=$name}}
+ {{else}}
+ {{:error message="Aucun membre sélectionné"}}
+ {{/foreach}}
+ {{else}}
+ {{:assign user_id=$logged_user.id user_name=$logged_user._name}}
+ {{/if}}
+
+ {{:save
+ validate_schema="./claim.schema.json"
+ key=$key
+ number=$new_number
+ type="claim"
+ label=$_POST.label|trim|or:null
+ date=$now|date_format:"%Y-%m-%d"
+ user_id=$user_id
+ user_name=$user_name
+ status="draft"
+ }}
+ {{:redirect to="./details.html?key=%s"|args:$key}}
+{{/form}}
+
+{{:admin_header title="Nouvelle note de frais"}}
+
+{{:form_errors}}
+
+
+
+ Nouvelle note de frais
+
+ {{if $is_admin}}
+ {{:assign var="me.%d"|args:$logged_user.id value=$logged_user._name}}
+ {{:input type="list" name="user" label="Membre" required=true default=$me target="!users/selector.php"}}
+ {{else}}
+ Membre
+ {{$logged_user._name}}
+ {{/if}}
+ {{:input type="text" name="label" label="Objet de la note de frais" required=false help="Courte description de l'objet de la note de frais."}}
+
+
+
+ {{:button type="submit" shape="right" label="Créer" name="save" class="main"}}
+
+
+
+{{:admin_footer}}
diff --git a/src/modules/expenses_claims/payment.html b/src/modules/expenses_claims/payment.html
new file mode 100644
index 0000000..3726587
--- /dev/null
+++ b/src/modules/expenses_claims/payment.html
@@ -0,0 +1,108 @@
+{{#restrict section="accounting" level="write" block=true}}{{/restrict}}
+
+{{#load assign="claim" key=$_GET.claim}}
+{{else}}
+ {{:error message="Cette note de frais n'existe pas"}}
+{{/load}}
+
+{{if $claim.status !== 'payable'}}
+ {{:error message="Cette note de frais n'est pas en attente de paiement: %s"|args:$claim.status}}
+{{/if}}
+
+{{#form on="save"}}
+ {{:assign var="lines." credit=$_POST.amount account=$_POST.account|keys|key:0}}
+ {{:assign var="lines." debit=$_POST.amount account="4110"}}
+ {{:api
+ method="POST"
+ path="accounting/transaction"
+ assign="result"
+
+ id_year="match"
+ type="advanced"
+ date=$_POST.date
+ label=$_POST.label
+ reference="Remboursement NDF-%d"|args:$claim.number
+ linked_users=$claim.user_id
+ lines=$lines
+ linked_transactions=$claim.id_transaction|intval
+ }}
+
+ {{if $_POST.paid}}
+ {{:assign new_status="paid"}}
+ {{else}}
+ {{:assign new_status=$claim.status}}
+ {{/if}}
+
+ {{:assign var="claim.payments." value=$result.id}}
+ {{:save
+ validate_schema="./claim.schema.json"
+ payments=$claim.payments
+ key=$claim.key
+ status=$new_status
+ }}
+ {{:redirect to="./details.html?key=%s"|args:$claim.key}}
+{{/form}}
+
+{{#transactions id=$claim.id_transaction}}
+ {{:assign total=$debit}}
+{{/transactions}}
+
+{{if $claim.payments|count}}
+ {{#transactions id=$claim.payments}}
+ {{:assign total="%d-%d"|math:$total:$credit}}
+ {{/transactions}}
+{{/if}}
+
+{{if $claim.label}}
+ {{:assign default_label="Remboursement note de frais n°%d : %s"|args:$claim.number:$claim.label}}
+{{else}}
+ {{:assign default_label="Remboursement note de frais n°%d"|args:$claim.number}}
+{{/if}}
+
+{{#years closed=false}}
+ {{:assign var="open_years.%d"|args:$id value=$label}}
+ {{if $start_date <= $now && $end_date >= $now}}
+ {{:assign best_year=$id}}
+ {{/if}}
+{{/years}}
+
+{{if !$open_years|count}}
+ {{:error message="Aucun exercice n'est ouvert, il n'est pas possible de créer une écriture et donc d'accepter cette note de frais."}}
+{{/if}}
+
+{{:admin_header title="Nouveau paiement"}}
+
+{{:form_errors}}
+
+
+
+ Nouveau paiement de remboursement
+
+ {{:input type="select" default=$best_year name="id_year" label="Exercice" required=true options=$open_years}}
+ {{:input type="date" default=$now name="date" label="Date" required=true}}
+ {{:input type="text" default=$default_label name="label" label="Libellé" required=true}}
+ {{:input type="money" name="amount" label="Montant" required=true default=$total}}
+ {{:input type="list" name="account" label="Compte de paiement" required=true target="!acc/charts/accounts/selector.php?targets=1:2:3&key=code&year=%d"|args:$best_year}}
+ {{:input type="checkbox" name="paid" value=1 default=1 label="Marquer cette note de frais comme payée"}}
+
+
+
+ {{:button type="submit" shape="right" label="Enregistrer" name="save" class="main"}}
+
+
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/expenses_claims/snippets/transaction_details.html b/src/modules/expenses_claims/snippets/transaction_details.html
new file mode 100644
index 0000000..9377f33
--- /dev/null
+++ b/src/modules/expenses_claims/snippets/transaction_details.html
@@ -0,0 +1,7 @@
+{{if $transaction.reference|regexp_match:"/\bNDF-\d+\b/"}}
+ {{:assign var="num" value=$transaction.reference|regexp_replace:"/^.*\bNDF-(\d+)\b.*$/":"$1"|intval}}
+ {{$module.label}}
+
+ {{:linkbutton href="%sdetails.html?num=%d"|args:$module.url:$num label="Voir la note de frais associée" shape="eye"}}
+
+{{/if}}
\ No newline at end of file
diff --git a/src/modules/membership_card/_carte.html b/src/modules/membership_card/_carte.html
new file mode 100644
index 0000000..7290e7e
--- /dev/null
+++ b/src/modules/membership_card/_carte.html
@@ -0,0 +1,65 @@
+{{if $module.config.photo}}
+ {{:assign var="_photo" from="%s.0"|args:$module.config.photo}}
+{{/if}}
+
+
+ {{if $module.config.logo == 1 && $config.files.logo}}
+
+ {{elseif $module.config.logo == 2 && $config.files.logo}}
+
+ {{/if}}
+
+ {{if $_photo}}
+
+ {{/if}}
+
+
+ {{if $module.config.fields|has:$number_field}}
+
N° {{$_number}}
+ {{/if}}
+
+ {{if $module.config.header}}
+
+ {{$module.config.header|markdown|raw}}
+
+ {{/if}}
+
+
{{$_name}}
+
+ {{if $module.config.fields}}
+
+ {{if $module.config.fields|has:"_category"}}
+
+ {{if $module.config.show_fields_names}}Catégorie :{{/if}}
+ {{#select name FROM users_categories WHERE id = {$id_category|intval};}}
+ {{$name}}
+ {{/select}}
+
+ {{/if}}
+ {{#select name, label, type FROM config_users_fields}}
+ {{if !$module.config.fields|has:$name}}
+ {{:continue}}
+ {{/if}}
+ {{:assign var="value" from=$name}}
+ {{if $value && $name != $number_field}}
+
+ {{if $module.config.show_fields_names}}{{$label}} :{{/if}}
+ {{:user_field name=$name value=$value}}
+
+ {{/if}}
+ {{/select}}
+
+ {{/if}}
+
+ {{if $module.config.id_service}}
+ {{#subscriptions user=$id id_service=$module.config.id_service active=true}}
+
Jusqu'au {{$expiry_date|date_short}}
+ {{else}}
+ {{#subscriptions user=$id id_service=$module.config.id_service active=false}}
+
Expiré
+ {{/subscriptions}}
+ {{/subscriptions}}
+ {{/if}}
+
+
+
\ No newline at end of file
diff --git a/src/modules/membership_card/carte.css b/src/modules/membership_card/carte.css
new file mode 100644
index 0000000..c111284
--- /dev/null
+++ b/src/modules/membership_card/carte.css
@@ -0,0 +1,142 @@
+@page single {
+ size: 85mm 55mm;
+ margin: 0;
+ padding: 0;
+}
+
+html, * { margin: 0; padding: 0; }
+body {
+ font-family: sans-serif;
+ font-size: 10pt;
+ overflow: hidden;
+}
+
+main {
+ display: flex;
+ flex-wrap: wrap;
+ grid-gap: 10mm;
+ align-items: center;
+ justify-content: center;
+}
+
+main.preview {
+ background: #fff;
+ padding: 2em;
+}
+
+main.preview article {
+ box-shadow: 2px 2px 10px #000;
+}
+
+main.single {
+ page: single;
+}
+
+.sheet {
+ grid-gap: 0;
+ padding: .5em 0;
+ background: #fff;
+}
+
+.sheet table {
+ border-collapse: collapse;
+}
+
+.sheet td {
+ border: 2px dashed #ccc;
+}
+
+article {
+ background: #fff;
+ display: block;
+ position: relative;
+ width: 85mm;
+ height: 55mm;
+ overflow: hidden;
+ break-inside: avoid;
+}
+article > div {
+ padding: 2.5mm;
+}
+article.with-images > div {
+ padding-right: calc(1mm + 100px);
+}
+ul {
+ list-style: none;
+}
+
+.logo {
+ max-width: 100px;
+ max-height: 100px;
+ position: absolute;
+ right: 0;
+ bottom: 0;
+}
+.photo {
+ max-width: 100px;
+ max-height: 100px;
+ position: absolute;
+ right: 0;
+ top: 0;
+ z-index: 100;
+}
+.bglogo {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 100%;
+ margin: 0 auto;
+ z-index: 10;
+ opacity: 0.2;
+}
+.content {
+ margin-bottom: .5em;
+ z-index: 1000;
+}
+h1 {
+ font-size: 14pt;
+ margin-bottom: .2em;
+}
+.number {
+ position: absolute;
+ right: 5px;
+ top: 5px;
+ border-radius: 50%;
+ height: 15mm;
+ width: 15mm;
+ background: #666;
+ color: #fff;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ border: 3px solid #fff;
+ z-index: 10000;
+}
+.number.with-photo {
+ bottom: 5px;
+ top: auto;
+}
+.number.with-photo.with-logo {
+ right: calc(100px / 2);
+ top: calc(55mm / 2 - 15mm / 2);
+}
+.number span:nth-child(1) {
+ font-size: 8pt;
+ margin-top: -.7em;
+}
+.number span:nth-child(2) {
+ font-size: 14pt;
+}
+
+h3 {
+ margin-top: .5em;
+ font-weight: normal;
+ font-size: 11pt;
+}
+
+.web-content p {
+ margin-bottom: .4rem;
+}
\ No newline at end of file
diff --git a/src/modules/membership_card/carte.html b/src/modules/membership_card/carte.html
new file mode 100644
index 0000000..53d3e94
--- /dev/null
+++ b/src/modules/membership_card/carte.html
@@ -0,0 +1,68 @@
+{{#restrict section="users" level="read"}}
+ {{:assign var="id" value=$_GET.id|intval}}
+{{else}}
+ {{* On autorise l'utilisateur à voir sa propre carte de membre *}}
+ {{if $logged_user.id}}
+ {{:assign var="id" value=$logged_user.id}}
+ {{else}}
+ {{:error message="Vous n'avez pas accès à cette page."}}
+ {{/if}}
+{{/restrict}}
+
+{{if !$id}}
+ {{:error message="Aucun numéro de membre n'a été fourni"}}
+{{/if}}
+
+{{if null === $module.config}}
+ {{:assign var="module.config"
+ header="**%s**\n%s"|args:$config.org_name:$config.org_address
+ logo=2
+ fields=null
+ photo="photo"
+ id_service=null
+ }}
+{{/if}}
+
+{{#select name FROM config_users_fields WHERE system & (1 << 3) LIMIT 1}}
+ {{:assign var="number_field" value=$name}}
+{{/select}}
+
+{{#users id=$_GET.id}}
+ {{:assign title="%s - Carte de membre"|args:$_name}}
+
+ {{if $_GET.print == 'pdf'}}
+ {{:http type="pdf" download="%s.pdf"|args:$title}}
+ {{/if}}
+
+
+
+ {{$title}}
+
+
+
+
+
+ {{if $_GET.mode == 'print'}}
+
+ {{elseif $_GET.mode == 'preview'}}
+
+ {{/if}}
+
+ {{:include file="./_carte.html"}}
+
+ {{if $_GET.mode != 'embed'}}
+
+ {{/if}}
+
+ {{if $_GET.print == 'yes'}}
+
+ {{/if}}
+
+
+
+{{else}}
+ {{:error message="Le numéro de membre fourni n'existe pas."}}
+{{/users}}
diff --git a/src/modules/membership_card/config.html b/src/modules/membership_card/config.html
new file mode 100644
index 0000000..894a00e
--- /dev/null
+++ b/src/modules/membership_card/config.html
@@ -0,0 +1,94 @@
+{{:admin_header title="Configuration des cartes de membres"}}
+
+{{#form on="save"}}
+ {{:save key="config"
+ validate_schema="./config.schema.json"
+ header=$_POST.header|trim|or:null
+ logo=$_POST.logo|intval
+ fields=$_POST.fields|or:null
+ photo=$_POST.photo|or:null
+ id_service=$_POST.id_service|intval|or:null
+ show_fields_names=$_POST.show_fields_names|boolval
+ email_subject=$_POST.email_subject|trim
+ email_body=$_POST.email_body|trim
+ }}
+ {{:redirect to="?ok=1"}}
+{{/form}}
+
+{{if $_GET.ok}}
+ Configuration enregistrée.
+{{/if}}
+
+{{:form_errors}}
+
+
+
+
+ Configuration du modèle de carte de membre
+
+ {{:input type="textarea" cols="30" rows="4" name="header" required=false source=$module.config label="En-tête, à afficher en haut de la carte" default="**%s**\n%s"|args:$config.org_name:$config.org_address}}
+ Syntaxe Markdown acceptée.
+ {{:linkbutton shape="help" label="Aide de la syntaxe Markdown" href="!static/doc/markdown.html"}}
+
+
+ Affichage du logo de l'association
+ {{:input type="radio" name="logo" value=0 source=$module.config label="Ne pas afficher" default=1}}
+ {{:input type="radio" name="logo" value=1 source=$module.config label="En petit en bas à droite" default=1}}
+ {{:input type="radio" name="logo" value=2 source=$module.config label="En filigrane (fond)"}}
+
+ Champs des fiches membre à afficher sur la carte de membre
+ (Le nom du membre est toujours affiché.)
+ {{if $module.config.fields|has:"_category"}}
+ {{:assign var="checked" value="_category"}}
+ {{else}}
+ {{:assign var="checked" value=null}}
+ {{/if}}
+
+ {{:input type="checkbox" name="fields[]" value="_category" label="Catégorie" default=$checked}}
+
+ {{#select * FROM config_users_fields WHERE type NOT IN ('file', 'password') AND system & (1 << 4) = 0 ORDER BY sort_order}}
+ {{if $module.config.fields|has:$name}}
+ {{:assign var="checked" value=$name}}
+ {{else}}
+ {{:assign var="checked" value=null}}
+ {{/if}}
+ {{:input type="checkbox" name="fields[]" value=$name label=$label default=$checked}}
+ {{/select}}
+
+ Nom des champs
+ {{:input type="checkbox" name="show_fields_names" value=1 label="Afficher le nom des champs" source=$module.config}}
+
+ Si coché, le nom du champ sera affiché avant sa valeur.
+ Exemple : Nom : Elsa Triolet
+
+
+ {{:assign var="file_fields." value="— Aucun, ne pas afficher de photo —"}}
+ {{#select name, label FROM config_users_fields WHERE type = 'file' ORDER BY sort_order;}}{{:assign var="file_fields.%s"|args:$name value=$label}}{{/select}}
+
+ {{if $file_fields|count > 1}}
+ {{:input type="select" name="photo" label="Champ utilisé pour la photo du membre" options=$file_fields required=true source=$module.config}}
+ {{/if}}
+
+ {{:assign var="services." value="— Aucune, ne pas afficher d'activité —"}}
+ {{#select id, label FROM services WHERE end_date IS NULL OR end_date >= date() ORDER BY label COLLATE U_NOCASE;}}{{:assign var="services.%s"|args:$id value=$label}}{{/select}}
+ {{if $services|count}}
+ {{:input type="select" name="id_service" label="Activité à afficher" options=$services required=true help="Utile pour afficher par exemple la date d'expiration de la cotisation annuelle." source=$module.config}}
+ {{/if}}
+
+
+
+
+ Message par défaut lors de l'envoi de la carte par e-mail
+
+ {{:input name="email_subject" type="text" label="Sujet du message" required=true source=$module.config default="Votre carte de membre"}}
+ {{:input name="email_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config default="Bonjour !\n\nVeuillez trouver ci-joint votre carte de membre au format PDF."}}
+
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/membership_card/config.schema.json b/src/modules/membership_card/config.schema.json
new file mode 100644
index 0000000..c571467
--- /dev/null
+++ b/src/modules/membership_card/config.schema.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "header": {
+ "description": "En-tête",
+ "type": ["string", "null"]
+ },
+ "logo": {
+ "description": "Affichage du logo",
+ "type": "integer",
+ "enum": [0, 1, 2]
+ },
+ "fields": {
+ "description": "Champs à afficher",
+ "type": ["null", "array"],
+ "items": {
+ "type": "string"
+ }
+ },
+ "show_fields_names": {
+ "type": ["boolean"]
+ },
+ "photo": {
+ "description": "Champ pour la photo",
+ "type": ["string", "null"]
+ },
+ "id_service": {
+ "description": "Activité",
+ "type": ["integer", "null"]
+ },
+ "email_subject": {
+ "type": "string"
+ },
+ "email_body": {
+ "type": "string"
+ }
+ },
+ "required": [ "header", "logo", "fields", "show_fields_names", "photo", "id_service", "email_subject", "email_body" ]
+}
\ No newline at end of file
diff --git a/src/modules/membership_card/envoyer.html b/src/modules/membership_card/envoyer.html
new file mode 100644
index 0000000..6592929
--- /dev/null
+++ b/src/modules/membership_card/envoyer.html
@@ -0,0 +1,49 @@
+{{#restrict section="users" level="read" block=true}}{{/restrict}}
+
+{{#users id=$_GET.id assign="user"}}
+{{else}}
+ {{:error message="Ce membre n'existe pas"}}
+{{/users}}
+
+{{if !$user._email}}
+ {{:error message="Ce membre ne dispose pas d'adresse e-mail"}}
+{{/if}}
+
+{{:admin_header title="Envoyer une carte de membre"}}
+
+{{#form on="send"}}
+ {{:mail
+ to=$user._email
+ subject=$_POST.email_subject|trim
+ body=$_POST.email_body|trim
+ attach_from="./carte.html?id=%d&print=pdf&mode=print"|args:$user.id
+ }}
+ {{:redirect to="envoyer.html?id=%d&sent"|args:$user.id}}
+{{/form}}
+
+{{if $_GET.sent}}
+ La carte a bien été envoyée par e-mail.
+{{/if}}
+
+{{:form_errors}}
+
+
+
+ Envoyer une carte de membre
+
+
+
+
+ Destinataire
+ {{$user._name}}
+ {{:input name="email_subject" type="text" source=$module.config default="Votre carte de membre" label="Sujet du message" required=true}}
+ {{:input name="email_body" type="textarea" cols="70" rows=7 source=$module.config default="Bonjour !\n\nVeuillez trouver ci-joint votre carte de membre au format PDF." label="Corps du message" required=true}}
+
+
+
+ {{:button type="submit" label="Envoyer" name="send" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
diff --git a/src/modules/membership_card/icon.svg b/src/modules/membership_card/icon.svg
new file mode 100644
index 0000000..1a9f7f2
--- /dev/null
+++ b/src/modules/membership_card/icon.svg
@@ -0,0 +1 @@
+
diff --git a/src/modules/membership_card/index.html b/src/modules/membership_card/index.html
new file mode 100644
index 0000000..6aa3d88
--- /dev/null
+++ b/src/modules/membership_card/index.html
@@ -0,0 +1,54 @@
+{{#restrict section="users" level="read"}}
+ {{:admin_header title="Cartes de membres"}}
+
+ {{#restrict section="config" level="admin"}}
+
+
+ {{:linkbutton shape="settings" href="config.html" target="_dialog" label="Configuration"}}
+
+
+ {{/restrict}}
+
+
+
+ Créer une planche de cartes de membres
+
+ {{:input type="radio-btn" name="target" value="category" default="category" label="Tous les membres d'une catégorie"}}
+ {{:input type="radio-btn" name="target" value="some" label="Seulement certains membres" help="Sélectionnez cette option pour sélectionner les membres"}}
+
+
+ {{#select id, name FROM users_categories WHERE hidden = 0 ORDER BY name COLLATE NOCASE}}
+ {{:assign var="categories.%d"|args:$id value=$name}}
+ {{/select}}
+ {{:input type="select" name="category" options=$categories label="Catégorie" required=true}}
+
+
+ {{:input type="list" target="!users/selector.php" multiple=true required=true label="Membres à faire figurer" name="users"}}
+
+
+
+ {{if $pdf_enabled}}
+ {{:button type="submit" label="Générer en PDF" name="pdf" shape="pdf" class="main"}}
+ {{/if}}
+ {{:button type="submit" label="Prévisualiser" name="preview" shape="eye"}}
+ {{:button type="submit" label="Imprimer la planche" name="print" shape="print"}}
+
+
+
+
+
+{{else}}
+ {{:admin_header title="Ma carte de membre"}}
+
+ {{:include file="./snippets/user_details.html" user=$logged_user}}
+
+{{/restrict}}
+
+{{:admin_footer}}
diff --git a/src/modules/membership_card/module.ini b/src/modules/membership_card/module.ini
new file mode 100644
index 0000000..c0bd353
--- /dev/null
+++ b/src/modules/membership_card/module.ini
@@ -0,0 +1,5 @@
+name="Carte de membre"
+description="Impression de carte de membre, à l'unité, par planche de plusieurs membres, ou export en PDF."
+author="Paheko"
+author_url="https://paheko.cloud/"
+home_button=true
diff --git a/src/modules/membership_card/planche.html b/src/modules/membership_card/planche.html
new file mode 100644
index 0000000..288a876
--- /dev/null
+++ b/src/modules/membership_card/planche.html
@@ -0,0 +1,54 @@
+{{#restrict section="users" level="read" block=true}}{{/restrict}}
+{{if $_POST.category}}
+ {{#select id FROM users WHERE id_category = :category ORDER BY !name; :category=$_POST.category|intval !name=$config.user_fields.name_sql}}
+ {{:assign var="users." value=$id}}
+ {{$_name}}
+ {{/select}}
+{{else}}
+ {{:assign users=$_POST.users|keys}}
+{{/if}}
+{{:assign count=$users|count}}
+{{:assign title="%d cartes de membre"|args:$count}}
+{{if $_POST.pdf}}
+ {{:http type="pdf" inline="%s.pdf"|args:$title}}
+{{/if}}
+
+
+
+ {{$title}}
+
+
+
+
+
+{{#select name FROM config_users_fields WHERE system & (1 << 3) LIMIT 1}}
+ {{:assign var="number_field" value=$name}}
+{{/select}}
+
+
+
+
+ {{:assign i=0}}
+ {{#users id=$users order=$config.user_fields.name_sql}}
+ {{if "%d %% 2"|math:$i == 0}}
+
+ {{/if}}
+ {{:include file="./_carte.html"}}
+ {{if "%d %% 2"|math:$i != 0}}
+
+ {{/if}}
+ {{:assign i="%d+1"|math:$i}}
+ {{/users}}
+
+
+
+
+{{if $_POST.print}}
+
+{{/if}}
+
+
+
\ No newline at end of file
diff --git a/src/modules/membership_card/snippets/my_details.html b/src/modules/membership_card/snippets/my_details.html
new file mode 100644
index 0000000..0036597
--- /dev/null
+++ b/src/modules/membership_card/snippets/my_details.html
@@ -0,0 +1 @@
+{{:include file="./user_details.html"}}
\ No newline at end of file
diff --git a/src/modules/membership_card/snippets/user_details.html b/src/modules/membership_card/snippets/user_details.html
new file mode 100644
index 0000000..90c33e3
--- /dev/null
+++ b/src/modules/membership_card/snippets/user_details.html
@@ -0,0 +1,16 @@
+{{$module.label}}
+
+
+
+
+
+
+ {{if $pdf_enabled}}
+ {{:linkbutton href="%scarte.html?id=%d&print=pdf&mode=print"|args:$module.url:$user.id label="Télécharger en PDF" shape="download"}}
+ {{/if}}
+ {{:linkbutton href="%scarte.html?id=%d&print=yes&mode=print"|args:$module.url:$user.id target="_blank" label="Imprimer" shape="print"}}
+ {{if $pdf_enabled && $user._email}}
+ {{:linkbutton href="%senvoyer.html?id=%d"|args:$module.url:$user.id target="_dialog" label="Envoyer" shape="mail"}}
+ {{/if}}
+
+
diff --git a/src/modules/openings/_all.html b/src/modules/openings/_all.html
new file mode 100644
index 0000000..c8b304f
--- /dev/null
+++ b/src/modules/openings/_all.html
@@ -0,0 +1,46 @@
+{{:include file="./_common.tpl" keep="months,days,frequencies"}}
+
+
+{{if $config.open|count}}
+
+
Horaires d'ouverture
+
+
+ {{#foreach from=$config.open item="slot"}}
+
+
+ {{if $slot.frequency != "this"}}
+ {{$slot.frequency|replace:$frequencies}}
+ {{$slot.day|replace:$days}}
+ du mois
+ {{else}}
+ {{$slot.day|replace:$days}}
+ {{/if}}
+
+ {{$slot.open}} — {{$slot.close}}
+
+ {{/foreach}}
+
+
+{{/if}}
+
+
+{{if $config.closed|count}}
+
+
Fermetures
+
+
+ {{#foreach from=$config.closed item="slot"}}
+
+ {{if $slot.close_day === $slot.reopen_day && $slot.close_month === $slot.reopen_month}}
+ le {{$slot.close_day}} {{$slot.close_month|replace:$months}}
+ {{else}}
+ du {{$slot.close_day}} {{$slot.close_month|replace:$months}}
+ au {{$slot.reopen_day}} {{$slot.reopen_month|replace:$months}} inclus
+ {{/if}}
+ {{if $slot.reason|trim}}({{$slot.reason}}) {{/if}}
+
+ {{/foreach}}
+
+
+{{/if}}
diff --git a/src/modules/openings/_common.tpl b/src/modules/openings/_common.tpl
new file mode 100644
index 0000000..f73d2f2
--- /dev/null
+++ b/src/modules/openings/_common.tpl
@@ -0,0 +1,34 @@
+{{:assign var="days"
+ monday = "lundi"
+ tuesday = "mardi"
+ wednesday = "mercredi"
+ thursday = "jeudi"
+ friday = "vendredi"
+ saturday = "samedi"
+ sunday = "dimanche"
+}}
+
+{{:assign var="frequencies"
+ this = "tous les"
+ first = "premier"
+ second = "second"
+ third = "troisième"
+ fourth = "quatrième"
+ fifth = "cinquième"
+ last = "dernier"
+}}
+
+{{:assign var="months"
+ january = 'janvier'
+ february = 'février'
+ march = 'mars'
+ april = 'avril'
+ may = 'mai'
+ june = 'juin'
+ july = 'juillet'
+ august = 'août'
+ september = 'septembre'
+ october = 'octobre'
+ november = 'novembre'
+ december = 'décembre'
+}}
\ No newline at end of file
diff --git a/src/modules/openings/_next_opening.html b/src/modules/openings/_next_opening.html
new file mode 100644
index 0000000..630c90e
--- /dev/null
+++ b/src/modules/openings/_next_opening.html
@@ -0,0 +1,72 @@
+{{:assign now="now"|strtotime}}
+{{:assign today=$now|date:'Ymd'}}
+
+{{#foreach from=$config.closed item="slot"}}
+ {{:assign
+ start="%s %s, 00:00:00"|args:$slot.close_day:$slot.close_month|strtotime
+ end="%s %s, 23:59:59"|args:$slot.reopen_day:$slot.reopen_month|strtotime
+ }}
+ {{if $end < $start}}
+ {{:assign
+ end="%s %s, 23:59:59, +1 year"|args:$slot.reopen_day:$slot.reopen_month|strtotime
+ }}
+ {{/if}}
+ {{if $now >= $start && $now <= $end}}
+ Actuellement fermé jusqu'au {{$end|date_long}} inclus
+ {{:assign stop=1 closed=1}}
+ {{:break}}
+ {{/if}}
+{{/foreach}}
+
+{{if !$stop}}
+ {{#foreach from=$config.open item="slot"}}
+ {{if $slot.frequency != "this"}}
+ {{:assign
+ start="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime
+ end="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime
+ }}
+ {{* Try next month *}}
+ {{if $start < $now && $end < $now}}
+ {{:assign
+ start="%s %s of next month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime
+ end="%s %s of next month, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime
+ }}
+ {{/if}}
+ {{else}}
+ {{:assign
+ start="%s %s, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime
+ end="%s %s, %s:00"|args:$slot.frequency:$slot.day:$slot.close|strtotime
+ }}
+
+ {{* Try next week *}}
+ {{if $end < $now}}
+ {{:assign
+ start="next %s, %s:00"|args:$slot.day:$slot.open|strtotime
+ end="next %s, %s:00"|args:$slot.day:$slot.close|strtotime
+ }}
+ {{/if}}
+ {{/if}}
+ {{if $now >= $start && $now <= $end}}
+ Ouvert aujourd'hui jusqu'à {{$slot.close}}
+ {{:assign stop=1}}
+ {{:break}}
+ {{elseif $end < $now}}
+ {{:continue}}
+ {{/if}}
+
+ {{:assign diff="%d-%d"|math:$now:$start}}
+ {{if $diff > $closest}}
+ {{:assign closest=$diff closest_slot=$slot closest_date=$start}}
+ {{/if}}
+ {{/foreach}}
+{{/if}}
+
+{{if !$stop && $closest}}
+ {{if $closest_date|date:'Ymd' == $now|date:'Ymd'}}
+ Prochaine ouverture : aujourd'hui à {{$closest_slot.open}}
+ {{elseif $closest_date|date:'Ymd' == "tomorrow"|strtotime|date:'Ymd'}}
+ Prochaine ouverture : demain à {{$closest_slot.open}}
+ {{else}}
+ Prochaine ouverture : le {{$closest_date|strftime:"%A %e %B"}} à {{$closest_slot.open}}
+ {{/if}}
+{{/if}}
\ No newline at end of file
diff --git a/src/modules/openings/config.html b/src/modules/openings/config.html
new file mode 100644
index 0000000..266aae2
--- /dev/null
+++ b/src/modules/openings/config.html
@@ -0,0 +1,104 @@
+{{#restrict section="config" level="admin" block=true}}{{/restrict}}
+{{:admin_header title="Configuration des horaires d'ouverture"}}
+
+{{if $_POST.save}}
+ {{#foreach from=$_POST.slots|array_transpose key="i" item="slot"}}
+ {{:assign line="%d+1"|math:$i}}
+ {{:assign open=$slot.open|explode:':' close=$slot.close|explode:':'}}
+ {{:assign var="slot"
+ day=$slot.day
+ frequency=$slot.frequency
+ open='%02d:%02d'|args:$open.0:$open.1
+ close='%02d:%02d'|args:$close.0:$close.1
+ }}
+ {{* Use timestamp to order slots *}}
+ {{:assign timestamp="%s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.open|strtotime}}
+
+ {{if !$timestamp}}
+ {{:error message="Date invalide : %s %s of this month, %s:00"|args:$slot.frequency:$slot.day:$slot.open}}
+ {{/if}}
+
+ {{:assign var="slots.%d"|args:$timestamp value=$slot}}
+
+ {{if !"%s %s"|args:$slot.frequency:$slot.day|trim|strtotime}}
+ {{:assign error="Ouvertures - ligne %d : le sélecteur de jour est invalide: %s %s"|args:$line:$slot.frequency:$slot.day}}
+ {{:break}}
+ {{elseif !$slot.open|regexp_match:'/^(2[0-3]|[01][0-9]):([0-5][0-9])$/'}}
+ {{:assign error="Ouvertures - ligne %d: heure d'ouverture invalide."|args:$line}}
+ {{:break}}
+ {{elseif !$slot.close|regexp_match:'/^(2[0-3]|[01][0-9]):([0-5][0-9])$/'}}
+ {{:assign error="Ouvertures - ligne %d: heure de fermeture invalide."|args:$line}}
+ {{:break}}
+ {{/if}}
+ {{/foreach}}
+
+ {{if $error}}
+ {{$error}}
+ {{else}}
+ {{:save key="config"
+ validate_schema="./schema.json"
+ open=$slots|ksort|values
+ closed=$_POST.closed|array_transpose
+ }}
+ {{:http redirect="?ok=1"}}
+ {{/if}}
+{{/if}}
+
+{{if $_GET.ok}}
+ Configuration enregistrée.
+{{/if}}
+
+
+
+
+ Heures d'ouvertures
+ Indiquer ici les jours et heures d'ouverture. Ils apparaîtront sur le site web.
+
+
+
+ Occurrence
+ Jour
+ De
+ À
+
+
+
+
+
+
+ {{:button shape="plus" label="Ajouter une ligne"}}
+
+
+
+ Jours de fermeture exceptionnelle
+ Indiquer ici les périodes de fermeture pour jours fériés, congés, etc.
+
+
+
+ Du
+ Au
+ Raison (facultatif)
+
+
+
+
+
+
+ {{:button shape="plus" label="Ajouter une ligne"}}
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+ {{:include file="./_common.tpl" keep="months,frequencies,days"}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/openings/config.js b/src/modules/openings/config.js
new file mode 100644
index 0000000..a9f3c3d
--- /dev/null
+++ b/src/modules/openings/config.js
@@ -0,0 +1,152 @@
+if (!open_data) {
+ open_data = {
+ 'closed': [{'close_day': '25', 'close_month': 'december', 'reopen_day': '2', 'reopen_month': 'january'}],
+ 'open': [{
+ 'frequency': 'this',
+ 'day': 'saturday',
+ 'open': '15:00',
+ 'close': '19:00'
+ }]
+ };
+}
+
+const open_row = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enlever cette ligne
+
+ `;
+
+const close_row = `
+
+
+
+
+
+
+
+
+
+ inclus
+
+
+
+
+
+ Enlever cette ligne
+
+ `;
+
+const populate_select = (s, data) => {
+ Object.entries(data).forEach((e) => {
+ const [k, v] = e;
+ var o = `${v} `;
+ s.insertAdjacentHTML('beforeend', o);
+ });
+}
+
+const add_slot_row = (data) => {
+ $('fieldset.slots table tbody')[0].insertAdjacentHTML('beforeend', open_row);
+ var r = $('fieldset.slots table tbody tr:last-child')[0];
+ r.querySelector('button').onclick = removeRow;
+ var s = r.querySelectorAll('select');
+ populate_select(s[0], frequencies);
+ populate_select(s[1], days);
+
+ if (!data) {
+ return;
+ }
+
+ Object.entries(data).forEach((e) => {
+ const [k, v] = e;
+ r.querySelector('[name*=' + k + ']').value = !v ? '' : v;
+ });
+};
+
+const add_closed_row = (data) => {
+ $('fieldset.closed table tbody')[0].insertAdjacentHTML('beforeend', close_row);
+ var r = $('fieldset.closed table tbody tr:last-child')[0];
+ r.querySelector('button').onclick = removeRow;
+
+ var s = r.querySelectorAll('select');
+ populate_select(s[0], months);
+ populate_select(s[1], months);
+
+ if (!data) {
+ return;
+ }
+
+ Object.entries(data).forEach((e) => {
+ const [k, v] = e;
+ r.querySelector('[name*=' + k + ']').value = !v ? '' : v;
+ });
+};
+
+$('fieldset.slots p.actions button')[0].onclick = () => {
+ var rows = $('fieldset.slots table tbody tr');
+
+ if (!rows.length) {
+ add_slot_row();
+ return;
+ }
+
+ var n = rows[rows.length - 1].cloneNode(true);
+ n.querySelector('button').onclick = removeRow;
+ rows[0].parentNode.appendChild(n);
+};
+
+$('fieldset.closed p.actions button')[0].onclick = () => {
+ var rows = $('fieldset.closed table tbody tr');
+
+ if (!rows.length) {
+ add_closed_row();
+ return;
+ }
+
+ var n = rows[rows.length - 1].cloneNode(true);
+ n.querySelector('button').onclick = removeRow;
+ rows[0].parentNode.appendChild(n);
+
+}
+
+open_data.open.forEach((slot) => {
+ add_slot_row(slot);
+});
+
+open_data.closed.forEach((slot) => {
+ add_closed_row(slot);
+});
+
+
+function removeRow(e) {
+ var row = e.target.parentNode.parentNode;
+ var table = row.parentNode.parentNode;
+
+ if (table.rows.length <= 2)
+ {
+ return false;
+ }
+
+ row.parentNode.removeChild(row);
+ return false;
+}
+
+function addRow(e) {
+ var table = e.parentNode.parentNode.querySelector('table');
+ var row = table.rows[table.rows.length-1];
+ row.parentNode.appendChild(row.cloneNode(true));
+ return false;
+}
diff --git a/src/modules/openings/icon.svg b/src/modules/openings/icon.svg
new file mode 100644
index 0000000..8163f63
--- /dev/null
+++ b/src/modules/openings/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/modules/openings/module.ini b/src/modules/openings/module.ini
new file mode 100644
index 0000000..70652fa
--- /dev/null
+++ b/src/modules/openings/module.ini
@@ -0,0 +1,4 @@
+name="Horaires d'ouverture"
+description="Permet d'afficher sur la page d'accueil les jours et horaires d'ouverture"
+author="Paheko"
+author_url="https://paheko.cloud/"
diff --git a/src/modules/openings/schema.json b/src/modules/openings/schema.json
new file mode 100644
index 0000000..fdf27d0
--- /dev/null
+++ b/src/modules/openings/schema.json
@@ -0,0 +1,74 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "closed": {
+ "description": "Jours de fermeture",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "day_close": {
+ "description": "Numéro du jour",
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 31
+ },
+ "month_close": {
+ "description": "Nom du jour",
+ "type": "string",
+ "enum": ["january", "february", "march", "april", "may", "june",
+ "july", "august", "september", "october", "november", "december"]
+ },
+ "day_reopen": {
+ "description": "Numéro du jour",
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 31
+ },
+ "month_reopen": {
+ "description": "Nom du jour",
+ "type": "string",
+ "enum": ["january", "february", "march", "april", "may", "june",
+ "july", "august", "september", "october", "november", "december"]
+ }
+ }
+ }
+ },
+ "open": {
+ "description": "Jours d'ouverture",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "frequency": {
+ "description": "Fréquence",
+ "type": "string",
+ "enum": ["this", "first", "second", "third", "fourth", "fifth", "last"]
+ },
+ "day": {
+ "description": "Jour",
+ "type": "string",
+ "enum": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
+ },
+ "open": {
+ "description": "Heure d'ouverture",
+ "type": "string",
+ "pattern": "^(2[0-3]|[01][0-9]):([0-5][0-9])$",
+ "minimum": 0
+ },
+ "close": {
+ "description": "Heure de fermeture",
+ "type": "string",
+ "pattern": "^(2[0-3]|[01][0-9]):([0-5][0-9])$",
+ "minimum": 0
+ }
+ },
+ "required": ["frequency", "day", "open", "close"]
+ },
+ "minItems": 0,
+ "maxItems": 100
+ }
+ },
+ "required": [ "closed", "open" ]
+}
\ No newline at end of file
diff --git a/src/modules/openings/snippets/home_button.html b/src/modules/openings/snippets/home_button.html
new file mode 100644
index 0000000..ac8f766
--- /dev/null
+++ b/src/modules/openings/snippets/home_button.html
@@ -0,0 +1,3 @@
+{{#restrict section="web" level="write"}}
+ {{:linkbutton label="Heures d'ouverture" href="%sconfig.html"|args:$module.url icon="%sicon.svg"|args:$module.url}}
+{{/restrict}}
\ No newline at end of file
diff --git a/src/modules/receipt/_footer.html b/src/modules/receipt/_footer.html
new file mode 100644
index 0000000..33a261e
--- /dev/null
+++ b/src/modules/receipt/_footer.html
@@ -0,0 +1,13 @@
+
+
+
+
+{{if $_GET.print == 'yes'}}
+
+{{/if}}
+
+
+
\ No newline at end of file
diff --git a/src/modules/receipt/_header.html b/src/modules/receipt/_header.html
new file mode 100644
index 0000000..e19396c
--- /dev/null
+++ b/src/modules/receipt/_header.html
@@ -0,0 +1,67 @@
+{{if $_GET.print === 'pdf'}}
+ {{:http type="pdf" download="%s.pdf"|args:$title}}
+{{/if}}
+
+
+
+ {{$title}}
+
+
+
+
+
+
+{{if !$_GET.print && !$hide_buttons}}
+
+ {{if $back_link && !$dialog}}
+ {{:linkbutton href=$back_link label="Retour" shape="left"}}
+ {{/if}}
+ {{:linkbutton href="%s&print=yes"|args:$request_url label="Imprimer" target="_blank" shape="print"}}
+ {{if $pdf_enabled}}
+ {{:linkbutton href="%s&print=pdf"|args:$request_url label="Télécharger en PDF" shape="download"}}
+ {{/if}}
+ {{$buttons|raw}}
+
+{{/if}}
+
+
+
+{{if !$hide_header}}
+
+ {{if $config.files.logo}}
+
+
+
+ {{/if}}
+
+ {{$config.org_name}}
+ {{if $config.org_infos}}{{$config.org_infos|escape|nl2br}} {{/if}}
+ {{$config.org_address|replace:"\n":" — "}}
+
+
+
+{{/if}}
+
+
\ No newline at end of file
diff --git a/src/modules/receipt/form.css b/src/modules/receipt/form.css
new file mode 100644
index 0000000..3ba88f7
--- /dev/null
+++ b/src/modules/receipt/form.css
@@ -0,0 +1,270 @@
+@font-face {
+ font-family: 'paheko';
+ src: url('../../admin/static/font/paheko.eot');
+ src: url('../../admin/static/font/paheko.eot#iefix') format('embedded-opentype'),
+ url('../../admin/static/font/paheko.woff') format('woff'),
+ url('../../admin/static/font/paheko.woff2') format('woff2'),
+ url('../../admin/static/font/paheko.ttf') format('truetype'),
+ url('../../admin/static/font/paheko.svg#paheko') format('svg');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@media print {
+ @page {
+ margin: 1cm;
+ size: A4;
+ @bottom {
+ content: "Page " counter(page) " / " counter(pages);
+ font-size: 8pt;
+ margin-bottom: 10mm;
+ text-align: center;
+ }
+ }
+
+ #buttons {
+ display: none;
+ }
+}
+
+@media screen {
+ html, body {
+ background: #666;
+ }
+ #page {
+ position: relative;
+ margin: 0 auto;
+ padding: 1cm;
+ background: #fff;
+ width: 210mm;
+ height: 297mm;
+ }
+}
+
+body, div, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6,
+pre, form, fieldset, input, textarea, p, blockquote, th, td,
+figure, article, aside, section, header, footer {
+ padding: 0;
+ margin: 0;
+}
+fieldset, img {
+ border: 0;
+}
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+caption, th {
+ text-align: left;
+}
+th, td {
+ vertical-align: top;
+}
+
+h1 { font-size: 2em; }
+h2 { font-size: 1.5em; }
+h3 { font-size: 1.2em; }
+h4 { font-size: 1em; }
+h5 { font-size: 0.9em; }
+h6 { font-size: 0.8em; }
+
+dl, ul, ol, h1, h2, h3, h4, h5, h6, pre, p, blockquote, figure, table {
+ margin-bottom: .75rem;
+}
+
+body {
+ font-family: Arial, Helvetica, sans-serif;
+ color: #000;
+ font-size: 10pt;
+}
+
+header.organization {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+header.organization + main h1:first-child {
+ border-top: 2px solid #999;
+ padding-top: 1rem;
+}
+
+header.organization * {
+ margin: 0;
+ padding: 0;
+}
+
+header.organization .logo img {
+ margin-right: 1em;
+ max-height: 80px;
+}
+
+header.organization h1, header.organization h2 {
+ margin-bottom: .5rem;
+}
+
+header.organization h1 {
+ font-size: 1.2rem;
+}
+header.organization h2 {
+ font-size: 1rem;
+ font-weight: normal;
+}
+header.organization h3 {
+ font-size: .8rem;
+ font-weight: normal;
+}
+header.organization h4 {
+ font-size: .8rem;
+ font-weight: normal;
+ display: flex;
+}
+header.organization h4 a {
+ display: block;
+ margin-right: 1rem;
+}
+
+#buttons {
+ text-align: center;
+}
+
+#buttons a {
+ margin: 1em;
+ font-size: 1.2em;
+}
+
+
+a.icn-btn {
+ cursor: pointer;
+ color: #fff;
+ border: 1px solid #ccc;
+ background: #333;
+ user-select: none;
+ display: inline-block;
+ font-size: inherit;
+ border-radius: .2em;
+ padding: .2em .4em;
+ margin: .2em .5em;
+ white-space: pre;
+ transition: box-shadow .2s;
+ text-decoration: underline;
+}
+
+a.icn-btn:hover {
+ text-decoration: none;
+ box-shadow: 0px 0px 5px orange;
+}
+
+[data-icon]:before, .main[data-icon]:after {
+ display: inline-block;
+ font-family: "paheko", sans-serif;
+ text-shadow: 1px 1px 1px #000;
+ padding-right: .5em;
+ font-size: 1.2em;
+ line-height: .8em;
+ vertical-align: middle;
+ content: attr(data-icon);
+ font-weight: normal;
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+table th, table td {
+ padding: .2em;
+ border: 1px solid #999;
+ text-align: left;
+}
+
+td.money {
+ text-align: right;
+}
+
+.watermark {
+ position: absolute;
+ font-size: 180pt;
+ color: rgba(0, 0, 0, 0.2);
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.watermark-red {
+ color: rgba(128, 0, 0, 0.4);
+}
+
+.watermark div {
+ transform: rotate(-45deg);
+}
+
+figure.signature {
+ text-align: center;
+}
+
+figure.signature img {
+ max-height: 2.5cm;
+}
+
+.placeholder {
+ border-bottom: 1px dashed #666;
+ min-height: 1em;
+}
+
+.info.block {
+ border: 1px solid black;
+ padding: .5em;
+ margin: 1rem 0;
+}
+
+.info.block h2 {
+ border-bottom: 2px solid #000;
+}
+
+.info.block *:last-child {
+ margin-bottom: 0;
+}
+
+.info.block h4 strong {
+ background: yellow;
+ border: 1px solid #999;
+ padding: .2rem;
+}
+
+.user.block {
+ border: 1px solid black;
+ padding: .2em;
+ margin: 1rem 0;
+}
+
+td ul.checkboxes {
+ margin: 0;
+}
+
+ul.checkboxes {
+ list-style: none;
+}
+
+ul.checkboxes li {
+ margin-right: 1.5em;
+ display: inline-block;
+}
+
+ul.checkboxes li::before {
+ content: "";
+ width: 1.5em;
+ height: 1.5em;
+ vertical-align: middle;
+ background: url('data:image/svg+xml;utf8, ');
+ background-size: contain;
+ margin-right: .5em;
+ display: inline-block;
+}
\ No newline at end of file
diff --git a/src/modules/receipt/icon.svg b/src/modules/receipt/icon.svg
new file mode 100644
index 0000000..58b4b21
--- /dev/null
+++ b/src/modules/receipt/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/modules/receipt/index.html b/src/modules/receipt/index.html
new file mode 100644
index 0000000..c56d650
--- /dev/null
+++ b/src/modules/receipt/index.html
@@ -0,0 +1,80 @@
+{{if $_GET.me}}
+ {{#transactions user=$logged_user.id}}
+ {{if $id == $_GET.me}}
+ {{:assign id=$id}}
+ {{:break}}
+ {{/if}}
+ {{/transactions}}
+ {{if !$id}}
+ {{:error message="Ce reçu n'existe pas"}}
+ {{/if}}
+{{else}}
+ {{#restrict block=true section="accounting" level="read"}}
+ {{/restrict}}
+ {{:assign id=$_GET.id trusted=true}}
+{{/if}}
+
+{{if !$id}}
+
+ {{:admin_header title="Reçu de paiement" current="acc"}}
+
+ Aucun numéro d'écriture n'a été fourni.
+
+
+
+ Créer un reçu de paiement
+
+ {{:input type="number" name="id" label="Numéro d'écriture" required=true}}
+
+
+
+
+ {{:button type="submit" name="save" label="Voir le reçu" shape="right" class="main"}}
+
+
+
+ {{:admin_footer}}
+
+{{else}}
+ {{#transactions id=$id}}
+ {{if $trusted}}
+ {{:assign var="name" value=$_GET.name|or:$users_names}}
+ {{else}}
+ {{:assign var="name" value=$users_names}}
+ {{/if}}
+
+ {{:include file="./_header.html" page_size="A5" title="Reçu de paiement %d - %s"|args:$id:$name}}
+
+ Reçu de paiement
+
+ Référence n°{{$id}} — {{$date|date_short}}
+
+ L'association « {{$config.nom_asso}} » atteste avoir reçu de la part de :
+
+ {{$name}}
+
+ un paiement d'un montant de :
+
+ {{$credit|raw|money_currency:false}}
+
+ pour le motif suivant :
+
+ {{$label}}
+
+ {{if $notes}}
+ {{$notes|escape|nl2br}}
+ {{/if}}
+
+
+ Ce reçu n'est pas un reçu fiscal et ne donne pas droit à une réduction d'impôt au bénéfice des dispositions du code général des impôts.
+
+
+ (Reçu généré le {{$now|date_short}})
+
+ {{:signature}}
+
+ {{:include file="./_footer.html"}}
+ {{else}}
+ {{:error message="Le numéro d'écriture fourni n'existe pas."}}
+ {{/transactions}}
+{{/if}}
diff --git a/src/modules/receipt/module.ini b/src/modules/receipt/module.ini
new file mode 100644
index 0000000..14d54f0
--- /dev/null
+++ b/src/modules/receipt/module.ini
@@ -0,0 +1,5 @@
+name="Reçu de paiement"
+description="Reçu de paiement simple pour les écritures liées à des membres, et créditant un compte de produit, ou de tiers. Le reçu sera accessible sous chaque écriture comptable, et dans la page 'Mes activités' de chaque membre."
+author="Paheko"
+author_url="https://paheko.cloud/"
+system=1
\ No newline at end of file
diff --git a/src/modules/receipt/send.html b/src/modules/receipt/send.html
new file mode 100644
index 0000000..677c8dc
--- /dev/null
+++ b/src/modules/receipt/send.html
@@ -0,0 +1,39 @@
+{{:admin_header title="Envoyer un reçu"}}
+
+{{#transaction_users id_transaction=$_GET.id|intval limit=1 assign="user"}}
+{{/transaction_users}}
+
+{{#form on="send"}}
+ {{:assign var="name" value=$_POST.name|urlencode}}
+ {{:mail
+ to=$_POST.email_to|trim
+ subject=$_POST.email_subject|trim
+ body=$_POST.email_body|trim
+ attach_from="./index.html?id=%d&print=pdf&name=%s"|args:$_GET.id:$name
+ }}
+ {{:redirect to="send.html?id=%d&sent"|args:$_GET.id}}
+{{/form}}
+
+{{if $_GET.sent !== null}}
+ Le reçu a bien été envoyé par e-mail.
+{{/if}}
+
+{{:form_errors}}
+
+
+
+ Envoyer un reçu
+
+ {{:input type="text" name="name" required=true default=$user._name label="Nom de la personne à inscrire sur le reçu"}}
+ {{:input type="email" name="email_to" required=true default=$user._email label="Adresse du destinataire"}}
+ {{:input name="email_subject" type="text" source=$module.config default="Votre reçu" label="Sujet du message" required=true}}
+ {{:input name="email_body" type="textarea" cols="70" rows=7 source=$module.config default="Bonjour !\n\nVeuillez trouver ci-joint votre reçu au format PDF." label="Corps du message" required=true}}
+
+
+
+ {{:button type="submit" label="Envoyer" name="send" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
diff --git a/src/modules/receipt/snippets/my_services.html b/src/modules/receipt/snippets/my_services.html
new file mode 100644
index 0000000..6727ac3
--- /dev/null
+++ b/src/modules/receipt/snippets/my_services.html
@@ -0,0 +1,21 @@
+Reçus de paiements
+
+{{:assign var="codes." value="4%"}}
+{{:assign var="codes." value="7%"}}
+
+
+{{#transactions user=$logged_user.id order="date DESC" credit_codes=$codes}}
+
+ {{$date|date_short}}
+ {{$label}}
+ {{$credit|money_currency}}
+
+ {{if $pdf_enabled}}
+ {{:linkbutton href="%s?me=%d&print=pdf"|args:$module.url:$id shape="download" label="Télécharger le reçu (PDF)"}}
+ {{else}}
+ {{:linkbutton href="%s?me=%d"|args:$module.url:$id target="_dialog" shape="document" label="Voir le reçu"}}
+ {{/if}}
+
+
+{{/transactions}}
+
\ No newline at end of file
diff --git a/src/modules/receipt/snippets/transaction_details.html b/src/modules/receipt/snippets/transaction_details.html
new file mode 100644
index 0000000..aa3dc34
--- /dev/null
+++ b/src/modules/receipt/snippets/transaction_details.html
@@ -0,0 +1,22 @@
+{{if $show === null}}
+ {{#foreach from=$transaction_lines item="line"}}
+ {{if $line.account_code|regexp_match:'/^7|^4/' && $line.credit}}
+ {{:assign show=true}}
+ {{:break}}
+ {{/if}}
+ {{/foreach}}
+{{/if}}
+
+{{if $show}}
+ {{$module.label}}
+
+ {{:linkbutton href="%s?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Prévisualiser" shape="eye"}}
+ {{if $pdf_enabled}}
+ {{:linkbutton href="%s?id=%d&print=pdf"|args:$module.url:$transaction.id label="Télécharger en PDF" shape="download"}}
+ {{/if}}
+ {{:linkbutton href="%s?id=%d&print=yes"|args:$module.url:$transaction.id target="_blank" label="Imprimer" shape="print"}}
+ {{if $pdf_enabled}}
+ {{:linkbutton href="%ssend.html?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Envoyer" shape="mail"}}
+ {{/if}}
+
+{{/if}}
\ No newline at end of file
diff --git a/src/modules/receipt_donation/_upgrade.tpl b/src/modules/receipt_donation/_upgrade.tpl
new file mode 100644
index 0000000..3fd9ba6
--- /dev/null
+++ b/src/modules/receipt_donation/_upgrade.tpl
@@ -0,0 +1,6 @@
+{{#foreach from=$module.config.accounts|explode:','|map:'trim' item="a"}}
+ {{:assign var="module.config.accounts.%s"|args:$a value=$a}}
+{{/foreach}}
+{{:save key="config"
+ accounts=$module.config.accounts|arrayval
+}}
diff --git a/src/modules/receipt_donation/config.html b/src/modules/receipt_donation/config.html
new file mode 100644
index 0000000..fdc6de0
--- /dev/null
+++ b/src/modules/receipt_donation/config.html
@@ -0,0 +1,50 @@
+{{if $module.config.accounts|gettype === 'string'}}
+ {{:include file="./_upgrade.tpl" keep="module.config.accounts"}}
+{{/if}}
+
+{{#form on="save"}}
+ {{:save key="config"
+ accounts=$_POST.accounts|arrayval
+ email_subject=$_POST.email_subject|trim
+ email_body=$_POST.email_body|trim
+ }}
+ {{:redirect to="?ok=1"}}
+{{/form}}
+
+{{:admin_header title="Configuration du reçu de don"}}
+
+{{:form_errors}}
+
+{{if $_GET.ok}}
+ Configuration enregistrée.
+{{/if}}
+
+
+
+
+ Configuration du modèle de reçu
+
+ {{:assign var="default_account.754" value="754 — Ressources liées à la générosité du public"}}
+ {{:input required=false name="accounts" multiple=true target="!acc/charts/accounts/selector.php?targets=6&key=code" type="list" label="Comptes éligibles aux reçus de don" source=$module.config default=$default_account}}
+
+ Le reçu de don sera proposé, en dessous de la fiche de l'écriture , pour chaque compte sélectionné et ses sous-compts.
+ Laisser vide pour que le reçu de don soit proposé quel que soit le compte.
+
+
+
+
+
+ Message par défaut lors de l'envoi du reçu par e-mail
+
+ {{:input name="email_subject" type="text" label="Sujet du message" required=true source=$module.config default="Votre reçu de don"}}
+ {{:input name="email_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config default="Bonjour !\n\nVeuillez trouver ci-joint votre reçu de don au format PDF."}}
+
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/receipt_donation/icon.svg b/src/modules/receipt_donation/icon.svg
new file mode 100644
index 0000000..6634792
--- /dev/null
+++ b/src/modules/receipt_donation/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/modules/receipt_donation/index.html b/src/modules/receipt_donation/index.html
new file mode 100644
index 0000000..b6996f4
--- /dev/null
+++ b/src/modules/receipt_donation/index.html
@@ -0,0 +1,50 @@
+{{if !$_GET.id}}
+
+ {{:admin_header title="Reçu de don" current="acc"}}
+
+ Aucun numéro d'écriture n'a été fourni.
+
+
+
+ Créer un reçu de don
+
+ {{:input type="number" name="id" label="Numéro d'écriture" required=true}}
+
+
+
+
+ {{:button type="submit" name="save" label="Voir le reçu (PDF)" shape="right" class="main"}}
+
+
+
+ {{:admin_footer}}
+
+{{else}}
+ {{#transactions id=$_GET.id}}
+ {{:assign var="name" value=$_GET.name|or:$users_names}}
+ {{:include file="/receipt/_header.html" page_size="A5" title="Reçu de don %d - %s"|args:$id:$name}}
+
+ Reçu de don
+
+ Référence n°{{$id}} — {{$date|date_short}}
+
+ L'association « {{$config.nom_asso}} » atteste avoir reçu de la part de :
+
+ {{$name}}
+
+ un don d'un montant de :
+
+ {{$credit|raw|money_currency:false}}
+ le {{$date|date_short}}
+
+
+ Ce reçu n'est pas un reçu fiscal et ne donne pas droit à une réduction d'impôt au bénéfice des dispositions des articles 200, 238 bis et 885-0 V bis A du code général des impôts.
+
+
+ {{:signature}}
+
+ {{:include file="/receipt/_footer.html"}}
+ {{else}}
+ {{:error message="Le numéro d'écriture fourni n'existe pas."}}
+ {{/transactions}}
+{{/if}}
\ No newline at end of file
diff --git a/src/modules/receipt_donation/module.ini b/src/modules/receipt_donation/module.ini
new file mode 100644
index 0000000..4c0ddcd
--- /dev/null
+++ b/src/modules/receipt_donation/module.ini
@@ -0,0 +1,6 @@
+name="Reçu de don"
+description="Reçu de don simple, sans valeur fiscale"
+author="Paheko"
+author_url="https://paheko.cloud/"
+restrict_section="accounting"
+restrict_level="read"
diff --git a/src/modules/receipt_donation/send.html b/src/modules/receipt_donation/send.html
new file mode 100644
index 0000000..e686bb9
--- /dev/null
+++ b/src/modules/receipt_donation/send.html
@@ -0,0 +1 @@
+{{:include file="/receipt/send.html"}}
diff --git a/src/modules/receipt_donation/snippets/transaction_details.html b/src/modules/receipt_donation/snippets/transaction_details.html
new file mode 100644
index 0000000..15058cf
--- /dev/null
+++ b/src/modules/receipt_donation/snippets/transaction_details.html
@@ -0,0 +1,36 @@
+{{if $module.config.accounts === null}}
+ {{:assign var="module.config.accounts.756" value="756"}}
+{{elseif $module.config.accounts|gettype === 'string'}}
+ {{:include file="../_upgrade.tpl" keep="module.config.accounts"}}
+{{/if}}
+
+{{#foreach from=$transaction_lines item="line"}}
+ {{if !$line.credit}}
+ {{:continue}}
+ {{/if}}
+
+ {{#foreach from=$module.config.accounts key="code"}}
+ {{if $line.account_code|strpos:$code === 0}}
+ {{:assign show=true}}
+ {{:break}}
+ {{/if}}
+ {{/foreach}}
+
+ {{if $show}}
+ {{:break}}
+ {{/if}}
+{{/foreach}}
+
+{{if $show}}
+ {{$module.label}}
+
+ {{:linkbutton href="%s?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Prévisualiser" shape="eye"}}
+ {{if $pdf_enabled}}
+ {{:linkbutton href="%s?id=%d&print=pdf"|args:$module.url:$transaction.id label="Télécharger en PDF" shape="download"}}
+ {{/if}}
+ {{:linkbutton href="%s?id=%d&print=yes"|args:$module.url:$transaction.id target="_blank" label="Imprimer" shape="print"}}
+ {{if $pdf_enabled}}
+ {{:linkbutton href="%ssend.html?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Envoyer" shape="mail"}}
+ {{/if}}
+
+{{/if}}
diff --git a/src/modules/recus_fiscaux/_config_default.tpl b/src/modules/recus_fiscaux/_config_default.tpl
new file mode 100644
index 0000000..1684aae
--- /dev/null
+++ b/src/modules/recus_fiscaux/_config_default.tpl
@@ -0,0 +1,56 @@
+{{* Mise à jour depuis version 2022 vers 2023 *}}
+{{if $module.config.comptes_don_nature && !$module.config.comptes_don_abandon_frais}}
+ {{:save key="config"
+ validate_schema="./config.schema.json"
+ comptes_don_abandon_frais=$module.config.comptes_don_nature
+ comptes_don_nature=null
+ numero_asso=$module.config.numero_asso
+ champ_entreprise_numero=$module.config.champ_entreprise_numero
+ champ_entreprise_forme=$module.config.champ_entreprise_forme
+ }}
+ {{:assign var="module.config.comptes_don_abandon_frais" value=$module.config.comptes_don_nature}}
+ {{:assign var="module.config.comptes_don_nature" value=null}}
+{{/if}}
+
+{{if !$module.config}}
+ {{* Valeurs par défaut *}}
+ {{:assign var="module.config"
+ objet_asso=""
+ type_asso="defaut"
+ art200=false
+ art238=false
+ art978=false
+ email_subject="Votre reçu de don"
+ email_body="Bonjour !\n\nVeuillez trouver ci-joint votre reçu de don au format PDF."
+ }}
+ {{:assign var="module.config.comptes_don.754" value="754 — Ressources liées à la générosité du public"}}
+ {{:assign var="module.config.comptes_don_abandon_frais.75412" value="75412 — Abandons de frais par les bénévoles"}}
+ {{:assign var="module.config.comptes_especes.530" value="530 — Caisse"}}
+ {{:assign var="module.config.comptes_cheques.5112" value="5112 — Chèques à encaisser"}}
+ {{:assign var="module.config.champs_adresse"
+ adresse="adresse"
+ code_postal="code_postal"
+ ville="ville"
+ }}
+{{/if}}
+
+{{:assign var="types_asso.defaut"
+ label="Organisme d'intérêt général ou reconnu d'utilité publique"
+ help="66 % du montant versé, dans la limite de 20 % du revenu imposable."
+ case="7UF"
+}}
+{{:assign var="types_asso.personnes"
+ label="Association fournissant gratuitement une aide alimentaire ou des soins médicaux à des personnes en difficultés ou favorisant leur logement"
+ help="75% du montant versé pour un don d'un montant inférieur ou égal à 1000 €.\nLa fraction au-delà de 1000 € ouvre droit à une réduction d'impôt de 66 % du montant donné."
+ case="7UD"
+}}
+{{:assign var="types_asso.cultuelle"
+ label="Association cultuelle ou de bienfaisance et établissement public reconnus d’Alsace-Moselle"
+ help="75 % du montant versé, dans la limite de 562 €. La fraction au-delà de 562 € ouvre droit à une réduction d'impôt de 66 %."
+ case="7UJ"
+}}
+{{:assign var="types_asso.syndicat"
+ label="Syndicat"
+ help="66% des cotisations versées, dans la limite de 1% du revenu brut imposable."
+ case="7AC"
+}}
diff --git a/src/modules/recus_fiscaux/_recu.html b/src/modules/recus_fiscaux/_recu.html
new file mode 100644
index 0000000..9ce203c
--- /dev/null
+++ b/src/modules/recus_fiscaux/_recu.html
@@ -0,0 +1,215 @@
+{{#restrict block=true section="accounting" level="write"}}
+{{/restrict}}
+
+{{if !$_POST}}
+ {{:error message="Aucune donnée fournie"}}
+{{/if}}
+
+{{if null === $r}}
+ {{:assign var="r" value=$_POST}}
+ {{:assign var="r.montant" value=$r.montant|money_int}}
+ {{:assign var="r.montant_nature" value=$r.montant_nature|money_int}}
+ {{:assign var="r.montant_numeraire" value=$r.montant_numeraire|money_int}}
+ {{:assign var="r.date" value=$r.date|parse_date}}
+ {{:assign total="%d+%d"|math:$r.montant_numeraire:$r.montant_nature}}
+{{/if}}
+
+{{if $r.annees}}
+ {{:assign var="r.periode_annee" value=$r.annees}}
+{{/if}}
+
+{{:include file="./_config_default.tpl" keep="module.config,types_asso"}}
+
+{{:assign var="type" from="types_asso.%s"|args:$module.config.type_asso}}
+
+{{if !$r.entreprise}}
+
+ {{if $module.config.type_asso === 'syndicat'}}
+
Merci pour votre cotisation !
+
Elle vous permet de déduire de votre impôt sur le revenu {{$type.help}} Sauf si vous êtes aux frais réels, dans ce cas, déclarez-la dans vos frais.
+
Le crédit d'impôt vous sera remboursé en cas de non-imposition ou d'imposition inférieure au montant du crédit.
+ {{else}}
+
Merci pour votre soutien !
+
Il vous permet de déduire de votre impôt sur le revenu {{$type.help}}
+ {{/if}}
+
+
Comment déclarer ?
+
+
Vous devez utiliser la déclaration complémentaire 2042-RICI . Lors de la déclaration en ligne, cochez à l’étape 3 la case « Réductions et crédits d’impôts » située dans la rubrique « Charges » .
+
Inscrivez la somme de {{$r.montant|raw|money_currency:true}} dans la case
+ {{$type.case}}
+
+
Conservez ce reçu au moins 3 ans. N'envoyez ce reçu au service des impôts que si vous déclarez vos impôts avec un formulaire papier. Aucun envoi n'est nécessaire pour les déclarations sur Internet.
+
+{{/if}}
+
+
+ {{if $module.config.type_asso === 'syndicat'}}
+ Reçu des cotisations syndicales versées au titre de l'article 199 quater C du code général des impôts
+ {{elseif $r.entreprise}}
+ Reçu des dons et versements effectués par les entreprises au titre de l’article 238 bis du code général des impôts
+ {{else}}
+ Reçu des dons et versements effectués par les particuliers au titre des articles 200 et 978 du code général des impôts
+ {{/if}}
+
+
+Numéro d'ordre du reçu : {{if $preview}}(Brouillon){{else}}N°%ID%{{/if}}
+
+Bénéficiaire des versements
+
+
+
+ Nom :
+ {{$config.org_name}}
+
+
+ Adresse :
+ {{$config.org_address|escape|replace:"\n":" — "}}
+
+
+ Type :
+ {{$type.label}}
+
+ {{if $module.config.type_asso !== 'syndicat'}}
+
+ Numéro SIREN ou RNA :
+ {{$module.config.numero_asso}}
+
+
+ Objet :
+ {{$module.config.objet_asso|escape|replace:"\n":" — "}}
+
+
+ {{/if}}
+
+
+Donateur
+
+
+
+ Nom :
+ {{$r.nom}}
+
+
+ Adresse :
+ {{$r.adresse|escape|replace:"\n":" — "}}
+
+ {{if $r.entreprise}}
+
+ Forme juridique :
+ {{$r.entreprise_forme}}
+
+
+ Numéro SIREN :
+ {{$r.entreprise_numero}}
+
+ {{/if}}
+
+
+{{if $r.entreprise}}
+
+ L’organisme bénéficiaire reconnaît avoir reçu, au titre de la réduction d’impôt prévue à l’article 238 bis du code général des impôts, des dons en nature pour une valeur en euros égale à :
+
+ {{$r.montant_nature|money_raw|spell_out_number:'fr_FR':'euros'}}
+ (***{{$r.montant_nature|raw|money_currency:false}} ***).
+
+
+ Description exhaustive des biens et prestations reçus et acceptés (nature et quantité) et détail des salariés mis à disposition :
+
+ {{$r.description_nature|escape|nl2br|or:"—Néant—"}}
+
+
+ L’organisme bénéficiaire reconnaît avoir reçu, au titre de la réduction d’impôt prévue à l’article 238 bis du code général des impôts, des versements pour une valeur totale égale à :
+
+ {{$r.montant_numeraire|money_raw|spell_out_number:'fr_FR':'euros'}}
+ (***{{$r.montant_numeraire|raw|money_currency:false}} ***).
+
+
+ {{if $r.montant_numeraire}}
+ Mode de versement :
+
+ {{if $r.moyens_especes}}
+ Remise d’espèces
+ {{/if}}
+ {{if $r.moyens_cheques}}
+ Chèque
+ {{/if}}
+ {{if $r.moyens_autres}}
+ Virement, prélèvement, carte bancaire, ou autre
+ {{/if}}
+
+ {{/if}}
+
+ Montant total des dons et versements reçus par l’organisme :
+
+ {{$total|money_raw|spell_out_number:'fr_FR':'euros'}}
+ (***{{$total|raw|money_currency:false}} ***).
+
+
+ Date ou période au cours de laquelle les dons et versements ont été effectués : {{if $r.periode_annee}}cumul {{$r.periode_annee}}{{else}}{{$r.periode_date|date_short}}{{/if}}
+
+{{else}}
+
+ {{if $module.config.type_asso === 'syndicat'}}
+ Le bénéficiaire reconnaît avoir reçu des cotisations ouvrant droit à réduction d’impôt d’un montant de :
+ {{$r.montant|money_raw|spell_out_number:'fr_FR':'euros'}}
+ (***{{$r.montant|raw|money_currency:false}} ***).
+
+
+ Date du versement : {{if $r.periode_annee}}cumul {{$r.periode_annee}}{{else}}{{$r.periode_date|date_short}}{{/if}}
+ {{else}}
+ Le bénéficiaire reconnaît avoir reçu des dons et versements ouvrant droit à réduction d’impôt d’un montant de :
+ {{$r.montant|money_raw|spell_out_number:'fr_FR':'euros'}}
+ (***{{$r.montant|raw|money_currency:false}} ***).
+
+
+ Date du versement ou du don : {{if $r.periode_annee}}cumul {{$r.periode_annee}}{{else}}{{$r.periode_date|date_short}}{{/if}}
+
+ Le bénéficiaire certifie sur l’honneur que les dons et versements qu’il reçoit ouvrent droit à la réduction d’impôt prévue à l’article :
+ {{if $module.config.art200}}[X] 200 du CGI{{/if}}
+ {{if $module.config.art978}}[X] 978 du CGI{{/if}}
+
+ {{/if}}
+
+
+ {{if $module.config.type_asso !== 'syndicat'}}
+
+ Forme du don :
+
+
+ {{/if}}
+
+ Nature :
+
+
+ {{if $r.numeraire}}Numéraire {{/if}}
+ {{if $r.nature}}Don en nature {{/if}}
+ {{if $r.abandon_frais}}Frais engagés par les bénévoles, dont ils renoncent expressément au remboursement {{/if}}
+
+
+
+ {{if $r.numeraire}}
+
+ Mode de versement :
+
+
+ {{if $r.moyens_especes}}
+ Remise d’espèces
+ {{/if}}
+ {{if $r.moyens_cheques}}
+ Chèque
+ {{/if}}
+ {{if $r.moyens_autres}}
+ Virement, prélèvement, carte bancaire, ou autre
+ {{/if}}
+
+
+
+ {{/if}}
+
+
+{{/if}}
+
+Date du reçu : {{$r.date|date_short}}
+
+{{* La signature n'est pas stockée dans le reçu *}}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/annuler.html b/src/modules/recus_fiscaux/annuler.html
new file mode 100644
index 0000000..75a3794
--- /dev/null
+++ b/src/modules/recus_fiscaux/annuler.html
@@ -0,0 +1,42 @@
+{{#restrict block=true section="accounting" level="write"}}
+{{/restrict}}
+
+{{if !$_GET.id}}
+ {{:error message="Aucun numéro de reçu fourni."}}
+{{/if}}
+
+{{#load id=$_GET.id}}
+ {{:assign .="recu"}}
+{{else}}
+ {{:error message="Le numéro de reçu fourni n'a pas été trouvé"}}
+{{/load}}
+
+{{#form on="annuler"}}
+ {{:save
+ validate_schema="./recu.schema.json"
+ id=$recu.id
+ annule=true
+ }}
+ {{:redirect to="voir.html?id=%d"|args:$recu.id}}
+{{/form}}
+
+{{:admin_header title="Annuler un reçu fiscal" current="acc"}}
+
+{{:form_errors}}
+
+
+
+ Annuler un reçu
+ Annuler le reçu n°{{$recu.id}} ?
+
+
+
+ Note : un reçu annulé ne peut pas être restauré, il faudra créer un nouveau reçu.
+
+
+
+ {{:button type="submit" name="annuler" shape="delete" label="Annuler ce reçu" class="main"}}
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/config.html b/src/modules/recus_fiscaux/config.html
new file mode 100644
index 0000000..1123d9c
--- /dev/null
+++ b/src/modules/recus_fiscaux/config.html
@@ -0,0 +1,197 @@
+{{:admin_header title="Configuration des reçus fiscaux"}}
+
+{{:include file="./_config_default.tpl" keep="module.config,types_asso"}}
+
+{{#form on="save"}}
+ {{if $_POST.type_asso !== 'syndicat'}}
+ {{if !$_POST.objet_asso|trim}}
+ {{:error message="L'objet de l'association n'a pas été renseigné."}}
+ {{elseif !$_POST.numero_asso}}
+ {{:error message="Le numéro SIREN ou RNA n'a pas été renseigné."}}
+ {{/if}}
+ {{/if}}
+
+ {{if !$_POST.comptes_don|count}}
+ {{:error message="Aucun compte de don éligible aux reçus n'a été renseigné."}}
+ {{elseif !$_POST.comptes_especes|count}}
+ {{:error message="Aucun compte d'espèces n'a été renseigné."}}
+ {{elseif !$_POST.comptes_cheques|count}}
+ {{:error message="Aucun compte de chèques n'a été renseigné."}}
+ {{elseif !$_POST.art200|boolval && !$_POST.art238bis|boolval && !$_POST.art978|boolval}}
+ {{:error message="Merci de cocher au moins un des articles donnant éligibilité aux réductions d'impôt."}}
+ {{elseif !$_POST.champs_adresse|count}}
+ {{:error message="Aucun champ d'adresse n'a été coché."}}
+ {{/if}}
+
+ {{:save key="config"
+ validate_schema="./config.schema.json"
+ numero_asso=$_POST.numero_asso|strval|trim
+ objet_asso=$_POST.objet_asso|strval|trim
+ type_asso=$_POST.type_asso|strval|trim
+ comptes_don=$_POST.comptes_don|arrayval
+ comptes_don_nature=$_POST.comptes_don_nature|arrayval|or:null
+ comptes_don_abandon_frais=$_POST.comptes_don_abandon_frais|arrayval|or:null
+ comptes_especes=$_POST.comptes_especes|arrayval
+ comptes_cheques=$_POST.comptes_cheques|arrayval
+ art200=$_POST.art200|boolval
+ art238bis=$_POST.art238bis|boolval
+ art978=$_POST.art978|boolval
+ champs_adresse=$_POST.champs_adresse|arrayval
+ champ_entreprise_numero=$_POST.champ_entreprise_numero|strval|trim|or:null
+ champ_entreprise_forme=$_POST.champ_entreprise_forme|strval|trim|or:null
+ email_subject=$_POST.email_subject|trim
+ email_body=$_POST.email_body|trim
+ }}
+ {{:redirect to="?ok=1"}}
+{{/form}}
+
+{{if !$dialog}}
+
+ {{:linkbutton shape="left" href="./" label="Gestion des reçus"}}
+
+{{/if}}
+
+{{if $_GET.msg == 'MISSING'}}
+
+ Pour pouvoir générer des reçus, il faut renseigner le numéro SIREN ou RNA, l'objet de l'association et cocher un des articles de loi ouvrant l'éligibilité à la réduction d'impôt.
+
+{{elseif $_GET.msg == 'MISSING_NUMBER'}}
+
+ Merci de bien vouloir renseigner le numéro SIREN ou RNA de l'association.
+
+{{/if}}
+
+{{:form_errors}}
+
+{{if $_GET.ok}}
+ Configuration enregistrée.
+{{/if}}
+
+
+
+Toute modification ne s'appliquera qu'aux reçus créés, pas aux reçus existants, qui ne peuvent être modifiés pour des raisons légales.
+
+
+ Informations sur l'association
+
+ Type d'organisme (obligatoire)
+ {{#foreach from=$types_asso key="type"}}
+ {{:input type="radio-btn" required=true name="type_asso" value=$type label=$label help="Réduction de %s"|args:$help source=$module.config}}
+ {{/foreach}}
+
+ Note : les réductions ne sont données qu'à titre indicatif. Il est possible que ces taux aient changé, ou soient différents pour les entreprises ou l'impôt sur la fortune.
+
+
+
+ Réductions d'impôt éligibles (obligatoire)
+
+ {{:input type="checkbox" name="art200" value="1" source=$module.config label="Article 200" help="Réduction d'impôt pour les particuliers"}}
+ {{:input type="checkbox" name="art238bis" value="1" source=$module.config label="Article 238 bis" help="Pour les entreprises"}}
+ {{:input type="checkbox" name="art978" value="1" source=$module.config label="Article 978" help="Pour les personnes soumises à l'impôt sur la fortune immobilière"}}
+
+ {{:input required=true name="numero_asso" type="text" label="Numéro SIREN ou RNA de l'association" source=$module.config}}
+
+ {{:input required=true name="objet_asso" type="textarea" label="Objet de l'association" source=$module.config cols="70" rows="4" help="Inscrire ici l'objet de l'association, conformément aux statuts."}}
+
+
+
+
+ Détection automatique selon les comptes utilisés
+
+ {{:input type="list" required=true name="comptes_don" label="Comptes éligibles aux reçus" source=$module.config target="!acc/charts/accounts/selector.php?targets=6&key=code" multiple=true}}
+
+ Pour chaque compte indiqué dans ce champ, le reçu de don sera proposé (en dessous de la fiche de l'écriture).
+
+
+
+
+ {{:input type="list" required=false name="comptes_don_abandon_frais" label="Comptes pour les abandons de frais des bénévoles" source=$module.config target="!acc/charts/accounts/selector.php?targets=6&key=code" multiple=true}}
+
+ Par exemple 75412 . Utilisé pour cocher automatiquement la bonne case dans le reçu.
+
+
+ {{:input type="list" required=false name="comptes_don_nature" label="Comptes pour les dons en nature" source=$module.config target="!acc/charts/accounts/selector.php?targets=6&key=code" multiple=true}}
+
+ Utilisé pour cocher automatiquement la bonne case dans le reçu.
+
+
+
+
+ {{:input required=true name="comptes_especes" type="list" multiple=true label="Comptes de caisse" source=$module.config target="!acc/charts/accounts/selector.php?targets=2&key=code"}}
+
+ Par exemple 530 . Utilisé pour cocher automatiquement la bonne case dans le reçu.
+
+ {{:input required=true name="comptes_cheques" type="list" multiple=true label="Comptes pour les chèques" source=$module.config target="!acc/charts/accounts/selector.php?targets=3&key=code"}}
+
+ Par exemple 5112 . Utilisé pour cocher automatiquement la bonne case dans le reçu.
+
+
+
+
+
+ Configuration des champs
+
+ Champs à utiliser pour l'adresse du reçu :
+ {{#select name, label FROM config_users_fields WHERE type NOT IN ('file', 'password', 'virtual', 'multiple', 'checkbox', 'date', 'datetime') ORDER BY sort_order}}
+ {{:assign var="fields.%s"|args:$name value=$label}}
+ {{:assign var="checked" from="module.config.champs_adresse.%s"|args:$name}}
+ {{:input type="checkbox" name="champs_adresse[%s]"|args:$name value=$name label=$label source=$module.config default=$checked}}
+ {{/select}}
+
+
+
+
+ Gestion des reçus pour entreprises
+ Depuis 2023, les entreprises doivent faire l'objet d'un reçu différent. Si l'entreprise a fait un don en nature, le reçu doit être renseigné manuellement.
+ Si vous indiquez ci-dessous les champs SIREN et forme juridique de l'entreprise, les fiches membres disposant de ces informations seront traitées conformément au nouveau reçu.
+ Dans le cas contraire toutes les fiches membres seront traitées comme étant des particuliers.
+
+
+ {{:input type="select" name="champ_entreprise_numero" default_empty="— Aucun —" label="Champ du numéro SIREN de l'entreprise" required=false options=$fields source=$module.config}}
+ {{:input type="select" name="champ_entreprise_forme" default_empty="— Aucun —" label="Champ de la forme juridique de l'entreprise" required=false options=$fields source=$module.config}}
+
+
+
+
+ Message par défaut lors de l'envoi du reçu par e-mail
+
+ {{:input name="email_subject" type="text" label="Sujet du message" required=true source=$module.config}}
+ {{:input name="email_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config}}
+
+
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/config.schema.json b/src/modules/recus_fiscaux/config.schema.json
new file mode 100644
index 0000000..e37ebe2
--- /dev/null
+++ b/src/modules/recus_fiscaux/config.schema.json
@@ -0,0 +1,62 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "numero_asso": {
+ "description": "Numéro SIREN ou RNA de l'association",
+ "type": ["string", "null"]
+ },
+ "objet_asso": {
+ "description": "Objet de l'association",
+ "type": "string"
+ },
+ "type_asso": {
+ "description": "Type d'organisme",
+ "type": "string",
+ "enum": ["defaut", "personnes" , "cultuelle", "syndicat"]
+ },
+ "art200": {
+ "type": "boolean"
+ },
+ "art238bis": {
+ "type": "boolean"
+ },
+ "art978": {
+ "type": "boolean"
+ },
+ "comptes_don": {
+ "type": "object"
+ },
+ "comptes_don_nature": {
+ "type": ["object", "null"]
+ },
+ "comptes_don_abandon_frais": {
+ "type": ["object", "null"]
+ },
+ "comptes_especes": {
+ "type": "object"
+ },
+ "comptes_cheques": {
+ "type": "object"
+ },
+ "champs_adresse": {
+ "type": "object"
+ },
+ "champ_entreprise_numero": {
+ "type": ["string", "null"]
+ },
+ "champ_entreprise_forme": {
+ "type": ["string", "null"]
+ },
+ "email_subject": {
+ "type": "string"
+ },
+ "email_body": {
+ "type": "string"
+ }
+ },
+ "required": [ "numero_asso", "objet_asso", "type_asso", "art200", "art238bis", "art978", "comptes_don",
+ "comptes_don_abandon_frais", "comptes_don_nature", "comptes_especes", "comptes_cheques", "champs_adresse",
+ "champ_entreprise_numero", "champ_entreprise_forme",
+ "email_subject", "email_body" ]
+}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/envoyer.html b/src/modules/recus_fiscaux/envoyer.html
new file mode 100644
index 0000000..f0de1be
--- /dev/null
+++ b/src/modules/recus_fiscaux/envoyer.html
@@ -0,0 +1,53 @@
+{{#restrict section="accounting" level="write" block=true}}{{/restrict}}
+
+{{:include file="./_config_default.tpl" keep="module.config"}}
+
+{{#load id=$_GET.id assign="recu"}}
+{{else}}
+ {{:error message="Ce reçu n'existe pas"}}
+{{/load}}
+
+{{if $recu.linked_user}}
+ {{#users id=$recu.linked_user}}
+ {{:assign email=$_email}}
+ {{/users}}
+{{/if}}
+
+{{:admin_header title="Envoyer un reçu"}}
+
+{{#form on="send"}}
+ {{:mail
+ to=$_POST.email_to|trim
+ subject=$_POST.email_subject|trim
+ body=$_POST.email_body|trim
+ attach_from="./recu.html?id=%d&print=pdf"|args:$recu.id
+ }}
+ {{:redirect to="envoyer.html?id=%d&sent=1"|args:$recu.id}}
+{{/form}}
+
+{{if $_GET.sent}}
+ Le reçu a bien été envoyée par e-mail.
+{{/if}}
+
+{{:form_errors}}
+
+
+
+ Envoyer un reçu fiscal
+
+ Numéro du reçu
+ {{$recu.id}}
+ Donateur
+ {{$recu.nom}}
+ {{:input type="email" name="email_to" required=true default=$email label="Adresse du destinataire"}}
+ {{:input name="email_subject" type="text" source=$module.config label="Sujet du message" required=true}}
+ {{:input name="email_body" type="textarea" cols="70" rows=7 source=$module.config label="Corps du message" required=true}}
+
+
+
+ {{:button type="submit" label="Envoyer" name="send" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
diff --git a/src/modules/recus_fiscaux/generer.html b/src/modules/recus_fiscaux/generer.html
new file mode 100644
index 0000000..e0ae0fd
--- /dev/null
+++ b/src/modules/recus_fiscaux/generer.html
@@ -0,0 +1,256 @@
+{{#restrict block=true section="accounting" level="write"}}
+{{/restrict}}
+
+{{if $module.config.type_asso !== 'syndicat'}}
+ {{if !$module.config.objet_asso}}
+ {{:redirect force="config.html?msg=MISSING"}}
+ {{elseif !$module.config.numero_asso}}
+ {{:redirect force="config.html?msg=MISSING_NUMBER"}}
+ {{/if}}
+{{/if}}
+
+{{:include file="./_config_default.tpl" keep="module.config"}}
+
+{{#form on="create"}}
+ {{#foreach from=$_POST.new key="line" item="row"}}
+ {{:assign row=$row|json_decode}}
+ {{:assign skip=false}}
+
+ {{if $row.id_transaction|trim|strlen}}
+ {{:assign id_transaction=$row.id_transaction|explode:','|map:intval}}
+ {{:assign in_transaction="value"|sql_where:'IN':$id_transaction}}
+ {{#load each="linked_transactions" where="$$.linked_transactions IS NOT NULL AND $$.annule = 0 AND %s"|args:$in_transaction}}
+ {{:assign var="skipped." value="- ligne %d : le reçu n°%d a déjà été créé pour l'écriture #%d"|args:$line:$id:$value}}
+ {{:assign skip=true}}
+ {{/load}}
+ {{/if}}
+
+ {{if $skip}}
+ {{:continue}}
+ {{/if}}
+
+ {{:assign var="row.date" value=$now}}
+
+ {{:include file="./_recu.html" capture="recu" r=$row}}
+ {{:save
+ validate_schema="./recu.schema.json"
+ assign_new_id="new_id"
+ nom=$row.nom
+ date=$now|date_format:'%Y-%m-%d'
+ montant=$row.montant
+ linked_user=$row.id_user
+ linked_transactions=$row.id_transaction|explode:','|map:intval
+ annule=false
+ id_year=$_GET.id_year|intval
+ recu=$recu
+ }}
+ {{/foreach}}
+
+ {{if $skipped}}
+ {{:assign skipped=$skipped|implode:"\n"}}
+ {{:error message="Des reçus n'ont pas pu être créés car leurs écritures étaient déjà utilisées dans d'autres reçus :\n%s\nPour créer de nouveaux reçus liés à ces écritures, il faut déjà annuler les reçus existants."|args:$skipped}}
+ {{/if}}
+
+ {{:redirect to=$module.url}}
+{{/form}}
+
+{{:admin_header title="Générer des reçus" current="acc"}}
+
+
+{{if !$dialog}}
+
+ {{:linkbutton href="./" label="Retour à la liste des reçus" shape="left"}}
+
+{{/if}}
+
+{{:form_errors}}
+
+{{if !$_GET.id_year}}
+ {{#years order="end_date DESC"}}
+ {{:assign var="years.%d"|args:$id value=$label}}
+ {{else}}
+ {{:error message="Aucun exercice comptable n'est disponible."}}
+ {{/years}}
+
+
+
+ Générer tous les reçus d'un exercice
+
+ {{:input type="select" name="id_year" label="Exercice" required=true label="Exercice" options=$years}}
+
+
+
+
+ {{:button type="submit" shape="right" label="Prévisualiser" class="main"}}
+
+
+{{else}}
+ {{* Récupération des comptes et soldes *}}
+ {{:assign var="codes_don" value=$module.config.comptes_don|keys|map:strval}}
+ {{:assign var="codes_don_abandon_frais" value=$module.config.comptes_don_abandon_frais|keys|map:strval}}
+ {{:assign var="codes_don_nature" value=$module.config.comptes_don_nature|keys|map:strval}}
+ {{:assign var="codes_especes" value=$module.config.comptes_especes|keys|map:strval}}
+ {{:assign var="codes_cheques" value=$module.config.comptes_cheques|keys|map:strval}}
+ {{:assign var="champs_adresse" value=$module.config.champs_adresse|sql_user_fields:"u":" — "}}
+ {{:assign var="champs_nom" value=$config.user_fields.name|sql_user_fields:"u"}}
+ {{:assign var="champs_numero" value=$config.user_fields.number|sql_user_fields:"u"}}
+ {{:assign var="champ_entreprise_numero" value=$module.config.champ_entreprise_numero|sql_user_fields:"u"}}
+ {{:assign var="champ_entreprise_forme" value=$module.config.champ_entreprise_forme|sql_user_fields:"u"}}
+ {{:assign var="total" value=0}}
+ {{:assign var="count" value=0}}
+
+ {{#select
+ *,
+ GROUP_CONCAT(tid, ',') AS id_transaction,
+ SUM(total_numeraire) AS total_numeraire,
+ SUM(total_abandon_frais) AS total_abandon_frais,
+ SUM(total_nature) AS total_nature,
+ SUM(total_especes) AS total_especes,
+ SUM(total_cheques) AS total_cheques,
+ SUM(total_numeraire) - SUM(total_especes) - SUM(total_cheques) AS total_autres,
+ entreprise_forme IS NOT NULL AND entreprise_numero IS NOT NULL AS entreprise
+ FROM (
+ SELECT
+ strftime('%Y', t.date) AS periode_annee,
+ u.id AS id_user,
+ !champs_numero AS numero,
+ t.id AS tid,
+ SUM(l1.credit) AS montant,
+ a1.code AS account,
+ !champs_nom AS nom,
+ !champs_adresse AS adresse,
+ !champ_entreprise_forme AS entreprise_forme,
+ !champ_entreprise_numero AS entreprise_numero,
+ -- Paiements en espèces?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_especes
+ WHERE l2.credit = 0 AND l2.id_transaction = t.id LIMIT 1) AS total_especes,
+ -- Paiements en chèques?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_cheques
+ WHERE l2.credit = 0 AND l2.id_transaction = t.id LIMIT 1) AS total_cheques,
+ -- Dons en abandon de frais ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_abandon_frais
+ WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_abandon_frais,
+ -- Dons en nature ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_nature
+ WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_nature,
+ -- Dons en numeraire ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don
+ WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_numeraire
+
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_users tu ON tu.id_transaction = t.id
+ INNER JOIN users u ON tu.id_user = u.id
+ INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.credit != 0
+ INNER JOIN acc_accounts a1 ON a1.id = l1.id_account AND (a1.!codes_don_nature OR a1.!codes_don OR a1.!codes_don_abandon_frais)
+
+ WHERE t.id_year = {$_GET.id_year|intval}
+
+ GROUP BY tu.id_user
+ )
+ GROUP BY id_user
+ ORDER BY nom COLLATE U_NOCASE;
+
+ !champs_nom=$champs_nom
+ !champs_adresse=$champs_adresse
+ !champs_numero=$champs_numero
+ !champ_entreprise_forme=$champ_entreprise_forme
+ !champ_entreprise_numero=$champ_entreprise_numero
+ !codes_don_nature="code"|sql_where:"IN":$codes_don_nature
+ !codes_don_abandon_frais="code"|sql_where:"IN":$codes_don_abandon_frais
+ !codes_don="code"|sql_where:"IN":$codes_don
+ !codes_especes="code"|sql_where:"IN":$codes_especes
+ !codes_cheques="code"|sql_where:"IN":$codes_cheques
+ assign="rows."
+ }}
+ {{/select}}
+
+ {{if !$rows}}
+ Aucune écriture correspondant aux comptes configurés n'a été trouvée.
+ {{else}}
+
+
+
+
+
+
+ Num.
+ Nom du membre
+ Adresse
+ Espèces
+ Chèque
+ Autres
+ Abandon de frais
+ En nature
+ Montant des dons
+
+
+
+
+ {{#foreach from=$rows key="line" item="row"}}
+ {{if $entreprise && $total_nature|or:$total_abandon_frais}}
+ {{:assign locked=true}}
+ {{:assign has_locked=true}}
+ {{else}}
+ {{:assign locked=false}}
+ {{/if}}
+
+
+ {{:assign line="%d+1"|math:$line}}
+ {{if !$locked}}
+ {{:input type="checkbox" name="new[%d]"|args:$line value=$row|json_encode checked=true}}
+ {{else}}
+ {{:icon shape="alert" title="Saisie manuelle obligatoire"}}
+ {{/if}}
+
+
+ {{:link href="!users/details.php?id=%d"|args:$id_user label=$numero}}
+
+ {{$nom}}
+ {{$adresse}}
+ {{if $total_especes}}{{:icon shape="check"}}{{/if}}
+ {{if $total_cheques}}{{:icon shape="check"}}{{/if}}
+ {{if $total_autres}}{{:icon shape="check"}}{{/if}}
+ {{if $total_abandon_frais}}{{:icon shape="check"}}{{/if}}
+ {{if $total_nature}}{{:icon shape="check"}}{{/if}}
+ {{$montant|money}}
+
+ {{:linkbutton shape="plus" href="nouveau.html?id_user=%d&type=user"|args:$id_user label="Saisie manuelle du reçu"}}
+ {{:linkbutton shape="menu" href="!acc/transactions/user.php?id=%d&year=%d"|args:$id_user:$_GET.id_year label="Liste des écritures" target="_dialog"}}
+
+
+ {{:assign var="total" value="%d+%d"|math:$total:$montant}}
+ {{:assign var="count" value="%d+1"|math:$count}}
+ {{/foreach}}
+
+
+
+
+ Total des dons
+ {{$total|money}}
+
+
+
+
+
+
+ Note : un reçu créé ne peut plus être modifié, mais seulement annulé.
+ {{if $has_locked}}
+
+ Les lignes ayant le symbole {{:icon shape="alert" title="Saisie manuelle obligatoire"}} nécessitent une saisie manuelle obligatoire (entreprises ayant effectué un don en nature).
+ {{/if}}
+
+
+
+ {{:button type="submit" name="create" shape="right" label="Créer ces reçus" class="main"}}
+
+
+
+ {{/if}}
+{{/if}}
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/icon.svg b/src/modules/recus_fiscaux/icon.svg
new file mode 100644
index 0000000..5d5bd3f
--- /dev/null
+++ b/src/modules/recus_fiscaux/icon.svg
@@ -0,0 +1 @@
+
diff --git a/src/modules/recus_fiscaux/index.html b/src/modules/recus_fiscaux/index.html
new file mode 100644
index 0000000..dee2e5a
--- /dev/null
+++ b/src/modules/recus_fiscaux/index.html
@@ -0,0 +1,115 @@
+{{#restrict block=true section="accounting" level="read"}}
+{{/restrict}}
+
+{{:include file="./_config_default.tpl" keep="module.config"}}
+
+{{:admin_header title="Reçus fiscaux" current="acc"}}
+
+
+ {{#restrict section="accounting" level="write"}}
+
+ {{#restrict section="accounting" level="admin"}}
+ {{:linkbutton href="config.html" label="Configuration" shape="settings"}}
+ {{/restrict}}
+ {{:linkbutton href="generer.html" label="Générer des reçus" shape="check"}}
+ {{:linkbutton href="nouveau.html" label="Nouveau reçu" shape="plus"}}
+
+ {{/restrict}}
+
+
+
+
+
+
+{{if $_GET.q}}
+ {{if $_GET.q|regexp_match:'/^\d+$/'}}
+ {{:assign where="id = :id_search" id=$_GET.q|intval}}
+ {{elseif $_GET.q|parse_date}}
+ {{:assign where="$$.date = :date" date=$_GET.q|parse_date}}
+ {{else}}
+ {{:assign nom2=$_GET.q|trim|regexp_replace:'/[!%_]/':'!$0'}}
+ {{:assign where="$$.nom LIKE :nom ESCAPE '!'" nom="%%%s%%"|args:$nom2}}
+ {{/if}}
+{{elseif $_GET.year}}
+ {{:assign where="SUBSTR($$.date, 1, 4) = :year"}}
+{{/if}}
+
+{{#list
+ select="id AS 'Numéro'; $$.nom AS 'Nom du bénéficiaire'; $$.date AS 'Date d''émission'; $$.montant AS 'Montant';
+ CASE WHEN $$.annule = 1 THEN 'Annulé' ELSE '' END AS 'Annulé ?'"
+ where=$where
+ order=1 desc=true
+ :year=$_GET.year
+ :date=$date
+ :id_search=$id
+ :nom=$nom
+}}
+
+
+ {{:link href="recu.html?id=%d"|args:$id label=$id target="_dialog"}}
+
+
+ {{if $linked_user}}
+ {{:link href="!users/details.php?id=%d"|args:$linked_user label=$nom}}
+ {{else}}
+ {{$nom}}
+ {{/if}}
+
+
+ {{$date|date_short}}
+
+
+ {{$montant|raw|money_currency}}
+
+
+ {{if $annule}}Annulé {{/if}}
+
+
+ {{if !$annule}}
+ {{:linkbutton shape="delete" label="Annuler" href="annuler.html?id=%d"|args:$id target="_dialog"}}
+ {{/if}}
+ {{:linkbutton shape="eye" label="Ouvrir" href="recu.html?id=%d"|args:$id target="_dialog"}}
+ {{:linkbutton shape="mail" label="Envoyer" href="envoyer.html?id=%d"|args:$id target="_dialog"}}
+
+
+{{else}}
+
+ {{if $_GET.q}}
+ Aucun reçu n'a été trouvé pour la recherche "{{$_GET.q}}".
+ {{elseif $_GET.year}}
+ Aucun reçu n'a été trouvé pour l'année sélectionnée.
+ {{else}}
+ Aucun reçu n'a encore été créé.
+ {{/if}}
+
+{{/list}}
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/module.ini b/src/modules/recus_fiscaux/module.ini
new file mode 100644
index 0000000..e3e0680
--- /dev/null
+++ b/src/modules/recus_fiscaux/module.ini
@@ -0,0 +1,7 @@
+name="Reçus fiscaux"
+description="Permet de générer des reçus fiscaux. Conforme aux exigences fiscales de 2022."
+author="Paheko"
+author_url="https://paheko.cloud/"
+home_button=true
+restrict_section="accounting"
+restrict_level="read"
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/nouveau.html b/src/modules/recus_fiscaux/nouveau.html
new file mode 100644
index 0000000..6485fd8
--- /dev/null
+++ b/src/modules/recus_fiscaux/nouveau.html
@@ -0,0 +1,405 @@
+{{#restrict block=true section="accounting" level="write"}}
+{{/restrict}}
+
+{{if $module.config.type_asso !== 'syndicat'}}
+ {{if !$module.config.objet_asso}}
+ {{:redirect force="config.html?msg=MISSING"}}
+ {{elseif !$module.config.numero_asso}}
+ {{:redirect force="config.html?msg=MISSING_NUMBER"}}
+ {{/if}}
+{{/if}}
+
+{{:include file="./_config_default.tpl" keep="module.config"}}
+
+{{if $_GET.user}}
+ {{#foreach from=$_GET.user key="id" item="user"}}
+ {{:assign id_user=$id|intval}}
+ {{/foreach}}
+{{elseif $_GET.id_user}}
+ {{:assign id_user=$_GET.id_user|intval}}
+{{/if}}
+
+{{:assign var="default.date" value=$now}}
+
+{{#load limit=1}}
+ {{:assign id_choice=false}}
+{{else}}
+ {{:assign id_choice=true}}
+{{/load}}
+
+{{#form on="create"}}
+ {{if !$_POST.date|trim|parse_date}}
+ {{:error message="Date d'émission invalide ou vide."}}
+ {{elseif !$_POST.nom|trim}}
+ {{:error message="Le nom du donateur ne peut être laissé vide."}}
+ {{elseif !$_POST.adresse|trim}}
+ {{:error message="L'adresse du donateur ne peut être laissée vide."}}
+ {{/if}}
+
+ {{if $_POST.entreprise}}
+ {{if !$_POST.montant_nature|intval && !$_POST.montant_numeraire|intval}}
+ {{:error message="Aucun montant n'a été saisi."}}
+ {{elseif $_POST.montant_numeraire && !$_POST.moyens_especes && !$_POST.moyens_cheques && !$_POST.moyens_autres}}
+ {{:error message="Aucun moyen de paiement n'a été coché."}}
+ {{elseif $_POST.montant_nature && !$_POST.description_nature|trim}}
+ {{:error message="La description des dons en nature doit être renseignée."}}
+ {{elseif !$_POST.entreprise_forme|trim}}
+ {{:error message="La forme juridique de l'entreprise doit être renseignée."}}
+ {{elseif !$_POST.entreprise_numero|trim}}
+ {{:error message="Le numéro SIREN de l'entreprise doit être renseigné."}}
+ {{/if}}
+
+ {{:assign montant_nature=$_POST.montant_nature|money_int
+ montant_numeraire=$_POST.montant_numeraire|money_int}}
+ {{:assign total="%d+%d"|math:$montant_numeraire:$montant_nature}}
+ {{else}}
+ {{if !$_POST.numeraire && !$_POST.nature && !$_POST.abandon_frais}}
+ {{:error message="Le type de don n'a pas été sélectionné."}}
+ {{elseif $_POST.numeraire && !$_POST.moyens_especes && !$_POST.moyens_cheques && !$_POST.moyens_autres}}
+ {{:error message="Aucun moyen de paiement n'a été coché."}}
+ {{elseif !$_POST.montant|trim || $_POST.montant < 0}}
+ {{:error message="Le montant du don ne peut être laissé vide."}}
+ {{/if}}
+
+ {{:assign total=$_POST.montant|money_int}}
+ {{/if}}
+
+ {{:assign id_transaction=null}}
+
+ {{if $_POST.id_transaction|trim|strlen}}
+ {{:assign id_transaction=$_POST.id_transaction|explode:','|map:intval}}
+ {{:assign in_transaction="value"|sql_where:'IN':$id_transaction}}
+ {{#load each="linked_transactions" where="$$.linked_transactions IS NOT NULL AND $$.annule = 0 AND %s"|args:$in_transaction}}
+ {{:error message="L'écriture #%d est déjà utilisée dans le reçu n°%d. Il faut déjà annuler ce reçu pour pouvoir le re-créer."|args:$value:$id}}
+ {{/load}}
+ {{/if}}
+
+ {{if $id_choice && $_POST.number|intval > 0}}
+ {{:assign number=$_POST.number|intval}}
+ {{else}}
+ {{:assign number=null}}
+ {{/if}}
+
+ {{:include file="./_recu.html" capture="recu"}}
+ {{:save
+ validate_schema="./recu.schema.json"
+ id=$number
+ assign_new_id="new_id"
+ nom=$_POST.nom|trim
+ date=$_POST.date|parse_date
+ montant=$total
+ linked_user=$_POST.id_user|intval|or:null
+ linked_transactions=$id_transaction
+ id_year=$_POST.id_year
+ annule=false
+ entreprise=$_POST.entreprise|boolval
+ recu=$recu
+ }}
+
+ {{if !$dialog}}
+ {{:redirect to="voir.html?id=%d"|args:$new_id}}
+ {{else}}
+ {{:redirect to="recu.html?id=%d"|args:$new_id}}
+ {{/if}}
+{{/form}}
+
+{{:admin_header title="Créer un nouveau reçu fiscal" current="acc"}}
+
+{{:assign var="champs_adresse" value=$module.config.champs_adresse|sql_user_fields:"u":" — "}}
+{{:assign var="champs_nom" value=$config.user_fields.name|sql_user_fields:"u"}}
+
+{{:assign var="codes_don" value=$module.config.comptes_don|keys|map:strval}}
+{{:assign var="codes_don_abandon_frais" value=$module.config.comptes_don_abandon_frais|keys|map:strval}}
+{{:assign var="codes_don_nature" value=$module.config.comptes_don_nature|keys|map:strval}}
+{{:assign var="codes_especes" value=$module.config.comptes_especes|keys|map:strval}}
+{{:assign var="codes_cheques" value=$module.config.comptes_cheques|keys|map:strval}}
+
+{{if $id_user}}
+ {{#select *, !champs_adresse AS adresse, !champs_nom AS nom FROM users u WHERE id = {$id_user|intval};
+ !champs_adresse=$champs_adresse
+ !champs_nom=$champs_nom
+ }}
+ {{:assign var="default.address" value=$adresse|trim:" —"}}
+ {{:assign var="default.name" value=$nom}}
+ {{:assign var="default.entreprise_numero" from=$module.config.champ_entreprise_numero}}
+ {{:assign var="default.entreprise_forme" from=$module.config.champ_entreprise_forme}}
+ {{else}}
+ {{:error message="Ce membre n'existe pas."}}
+ {{/select}}
+
+ {{:assign var="default.id_user" value=$id_user}}
+
+ {{* Récupération des comptes et soldes *}}
+ {{#select
+ *,
+ COUNT(DISTINCT id) AS nombre,
+ GROUP_CONCAT(DISTINCT id) AS id_transaction,
+ SUM(total) AS total,
+ SUM(total_especes) AS total_especes,
+ SUM(total_cheques) AS total_cheques,
+ SUM(total_abandon_frais) AS total_abandon_frais,
+ SUM(total_nature) AS total_nature,
+ SUM(total_numeraire) AS total_numeraire,
+ year,
+ strftime('%d/%m/%Y', date) AS date
+ FROM (
+ SELECT
+ t.id AS id,
+ t.date,
+ SUM(l1.credit) AS total,
+ strftime('%Y', t.date) AS year,
+ -- Paiements en espèces?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_especes
+ WHERE l2.credit = 0 AND l2.id_transaction = t.id LIMIT 1) AS total_especes,
+ -- Paiements en chèques?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_cheques
+ WHERE l2.credit = 0 AND l2.id_transaction = t.id LIMIT 1) AS total_cheques,
+ -- Dons en abandon de frais ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_abandon_frais
+ WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_abandon_frais,
+ -- Dons en nature ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_nature
+ WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_nature,
+ -- Dons en numeraire ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don
+ WHERE l2.debit = 0 AND l2.id_transaction = t.id) AS total_numeraire
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_users tu ON tu.id_transaction = t.id AND tu.id_user = {$id_user}
+ INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit = 0
+ INNER JOIN acc_accounts a1 ON a1.id = l1.id_account AND (a1.!codes_don_nature OR a1.!codes_don OR a1.!codes_don_abandon_frais)
+ GROUP BY t.id
+ ) GROUP BY year ORDER BY year;
+ !codes_don_nature="code"|sql_where:"IN":$codes_don_nature
+ !codes_don_abandon_frais="code"|sql_where:"IN":$codes_don_abandon_frais
+ !codes_don="code"|sql_where:"IN":$codes_don
+ !codes_especes="code"|sql_where:"IN":$codes_especes
+ !codes_cheques="code"|sql_where:"IN":$codes_cheques
+ }}
+ {{:assign .="user_years.%d"|args:$year}}
+ {{/select}}
+
+ {{#foreach from=$user_years key="year" item="data"}}
+ {{:assign total_money=$data.total|money_currency}}
+ {{:assign var="user_select.%d"|args:$year value="%d (%s)"|args:$year:$total_money}}
+ {{/foreach}}
+
+
+{{elseif $_GET.id_transaction}}
+ {{#select
+ u.id AS id_user,
+ t.id AS id_transactions,
+ SUM(l1.credit) AS total,
+ t.date AS date,
+ !champs_nom AS name,
+ !champs_adresse AS address,
+ -- Paiements en espèces?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_especes
+ WHERE l2.credit = 0 AND l2.id_transaction = id_transaction LIMIT 1) AS total_especes,
+ -- Paiements en chèques?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_cheques
+ WHERE l2.credit = 0 AND l2.id_transaction = id_transaction LIMIT 1) AS total_cheques,
+ -- Dons en abandon de frais ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_abandon_frais
+ WHERE l2.debit = 0 AND l2.id_transaction = id_transaction) AS total_abandon_frais,
+ -- Dons en nature ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don_nature
+ WHERE l2.debit = 0 AND l2.id_transaction = id_transaction) AS total_nature,
+ -- Dons en numeraire ?
+ (SELECT SUM(l2.credit + l2.debit) FROM acc_transactions_lines l2
+ INNER JOIN acc_accounts a2 ON a2.id = l2.id_account AND a2.!codes_don
+ WHERE l2.debit = 0 AND l2.id_transaction = id_transaction) AS total_numeraire
+ FROM acc_transactions t
+ INNER JOIN acc_transactions_users tu ON tu.id_transaction = t.id
+ INNER JOIN users u ON u.id = tu.id_user
+ INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit = 0
+ INNER JOIN acc_accounts a1 ON a1.id = l1.id_account AND (a1.!codes_don_nature OR a1.!codes_don OR a1.!codes_don_abandon_frais)
+ WHERE t.id = {$_GET.id_transaction|intval}
+ ;
+ !champs_nom=$champs_nom
+ !champs_adresse=$champs_adresse
+ !codes_don_nature="code"|sql_where:"IN":$codes_don_nature
+ !codes_don_abandon_frais="code"|sql_where:"IN":$codes_don_abandon_frais
+ !codes_don="code"|sql_where:"IN":$codes_don
+ !codes_especes="code"|sql_where:"IN":$codes_especes
+ !codes_cheques="code"|sql_where:"IN":$codes_cheques
+
+ assign="default"
+ }}
+ {{else}}
+ {{:error message="Numéro d'écriture inconnu"}}
+ {{/select}}
+
+ {{if $default.total_numeraire}}
+ {{:assign var="default.numeraire" value=$default.total_numeraire|boolval}}
+ {{:assign var="default.autre" value=$default.total_nature|boolval}}
+ {{/if}}
+{{/if}}
+
+{{if !$dialog}}
+
+ {{:linkbutton href="./" label="Retour à la liste des reçus" shape="left"}}
+
+{{/if}}
+
+{{:form_errors}}
+
+{{if !$_GET.type}}
+
+
+ Nouveau reçu
+
+ {{:input type="radio-btn" name="type" value="user" label="Créer un reçu pour un membre"}}
+ {{:input type="radio-btn" name="type" value="transaction" label="Créer un reçu à partir d'une écriture"}}
+ {{:input type="radio-btn" name="type" value="vierge" label="Créer un reçu vierge"}}
+
+
+
+ Reçu pour un membre
+
+ {{:input type="list" multiple=false name="user" label="Sélectionner un membre" target="!users/selector.php" required=true}}
+
+
+
+ Reçu pour une écriture
+
+ {{:input type="number" name="id_transaction" label="Indiquer le numéro de l'écriture" min=0 required=true}}
+
+
+
+
+ {{if $dialog}}
+
+ {{/if}}
+ {{:button type="submit" shape="right" label="Continuer" class="main"}}
+
+
+
+
+
+{{else}}
+
+ {{if $id_choice}}
+
+ Numéro du premier reçu
+ Si vous avez déjà créé des reçus pour cette année, modifiez ce numéro pour avoir une numérotation qui se suit. Le numéro des reçus suivants ne pourra pas être modifié.
+
+ {{:input type="number" step=1 name="number" label="Numéro du reçu" required=true default=1}}
+
+
+ {{/if}}
+
+
+ Bénéficiaire
+
+ {{:input type="text" name="nom" label="Nom du bénéficiaire" required=true default=$default.name}}
+ {{:input type="textarea" cols="50" rows="3" name="adresse" label="Adresse du bénéficiaire" required=true default=$default.address}}
+ {{if $module.config.art238bis && $module.config.type_asso !== 'syndicat'}}
+ {{if $default.entreprise_forme && $default.entreprise_numero}}
+ {{:assign is_entreprise=1}}
+ {{/if}}
+ {{:input type="checkbox" name="entreprise" value=1 label="Le bénéficiaire est une entreprise" default=$is_entreprise}}
+ {{/if}}
+
+ {{if $module.config.type_asso !== 'syndicat'}}
+
+ {{:input type="text" required=true name="entreprise_forme" label="Forme juridique" source=$default}}
+ {{:input type="text" required=true name="entreprise_numero" label="Numéro SIREN" source=$default}}
+
+ {{/if}}
+
+
+
+ Période des reçus
+
+ {{if $user_select}}
+ {{:input type="select" name="annees" label="Pour quelle année le reçu doit-il être généré ?" required=true options=$user_select}}
+ {{elseif $default.date}}
+ {{:input type="date" name="periode_date" required=true label="Date de versement du don" default=$default.date}}
+ {{else}}
+ {{:input type="year" name="periode_annee" required=true label="Année de versement des dons"}}
+ {{/if}}
+ {{:input type="date" name="date" required=true label="Date du reçu" default=$default.date}}
+
+
+
+
+ Versements
+
+ {{:input type="money" name="montant" required=true label="Montant des versements" default=$default.total}}
+
+
+ {{if $module.config.type_asso === 'syndicat'}}
+
+ {{else}}
+
+ Type de don
+ {{:input type="checkbox" name="numeraire" value="1" label="Don en numéraire (en euros)" default=1}}
+ {{:input type="checkbox" name="abandon_frais" value="1" label="Frais engagés par les bénévoles, dont ils renoncent expressément au remboursement" default=$default.abandon_frais}}
+ {{:input type="checkbox" name="nature" value="1" label="Don en nature" help="Par exemple don de matériel, prêt de local, prestation de service…" default=$defaut.nature}}
+
+ {{/if}}
+
+
+
+ Dons et versements effectués par l’entreprise
+
+ {{:input type="money" name="montant_nature" required=true label="Montant des dons en nature" default=$default.total_nature|or:0 help="Inscrire ici le chiffre zéro si aucun don en nature n'a été effectué."}}
+ {{:input type="textarea" rows=5 cols=70 name="description_nature" required=true label="Description exhaustive des dons en nature" help="Décrire ici la nature et quantité des biens et prestations reçus et acceptés, et le détail des salariés mis à disposition"}}
+ {{:input type="money" name="montant_numeraire" required=true label="Montant des dons en numéraire" default=$default.total_numeraire|or:0 help="Inscrire ici le chiffre zéro si aucun don numéraire n'a été effectué."}}
+
+
+
+
+ Moyens de paiement
+ {{if $module.config.type_asso !== 'syndicat'}}
+
+ Pour les dons en numéraire (en euros).
+
+ {{/if}}
+
+ {{:input type="checkbox" name="moyens_especes" value=1 label="Espèces" default=$default.especes}}
+ {{:input type="checkbox" name="moyens_cheques" value=1 label="Chèques" default=$default.cheques}}
+ {{:input type="checkbox" name="moyens_autres" value=1 label="Virement, prélèvement, carte bancaire, ou autre" default=$default.autres}}
+
+
+
+
+ Note : un reçu créé ne peut plus être modifié, mais seulement annulé.
+
+
+
+
+
+ {{:button type="button" name="preview" shape="eye" label="Prévisualiser"}}
+ {{:button type="submit" name="create" shape="right" label="Créer ce reçu" class="main"}}
+
+
+
+
+{{/if}}
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/nouveau_form.js b/src/modules/recus_fiscaux/nouveau_form.js
new file mode 100644
index 0000000..3fd7cb4
--- /dev/null
+++ b/src/modules/recus_fiscaux/nouveau_form.js
@@ -0,0 +1,77 @@
+$('#f_numeraire_1, #f_nature_1').forEach((i) => {
+ i.onchange = selectType;
+});
+
+var numeraire = $('#f_numeraire_1');
+
+function selectType() {
+ g.toggle('.type-numeraire', numeraire.checked, false);
+}
+
+selectType();
+
+var e = $('#f_entreprise_1');
+
+function selectEntreprise() {
+ g.toggle('.entreprise', e ? e.checked : false);
+ g.toggle('.particulier', e ? !e.checked : true);
+
+}
+
+if (e) {
+ e.onchange = selectEntreprise;
+}
+
+selectEntreprise();
+
+var y = $('#f_annees');
+
+if (y) {
+ function selectYear() {
+ $('#f_nature_1, #f_numeraire_1, #f_moyens_especes_1, #f_moyens_cheques_1, #f_moyens_autres_1').forEach((e) => e.checked = false);
+
+ let year = y.value;
+ let d = user_years[year];
+ $('#f_montant').value = g.formatMoney(d.total);
+
+ $('#f_moyens_especes_1').checked = d.total_especes > 0;
+ $('#f_moyens_cheques_1').checked = d.total_cheques > 0;
+ $('#f_moyens_autres_1').checked = d.total_numeraire > 0 && (d.total_numeraire > d.total_especes + d.total_cheques);
+ $('#f_montant').form.id_transaction.value = d.id_transaction;
+ $('#f_numeraire_1').checked = d.total_numeraire > 0;
+
+ var nature = $('#f_nature_1');
+
+ if (!nature) {
+ return;
+ }
+
+ nature.checked = d.total_nature > 0;
+ $('#f_abandon_frais_1').checked = d.total_abandon_frais > 0;
+ $('#f_montant_numeraire').value = g.formatMoney(d.total_numeraire);
+ $('#f_montant_nature').value = g.formatMoney(d.total_nature + d.total_abandon_frais);
+
+ selectType();
+ }
+
+ y.onchange = selectYear;
+ selectYear();
+}
+else if ($('#f_periode_date').value == '') {
+ g.toggle('.periode-date', false);
+}
+else {
+ g.toggle('.periode-annee', false);
+}
+
+let p = $('[name=preview]')[0];
+
+p.addEventListener('click', (e) => {
+ let form = e.target.form;
+ form.action = "previsualiser.html";
+ form.target = "dialog";
+ g.openFrameDialog('about:blank', {height: 'auto'});
+ form.submit();
+ form.action = "";
+ form.target = "";
+});
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/previsualiser.html b/src/modules/recus_fiscaux/previsualiser.html
new file mode 100644
index 0000000..bc57c42
--- /dev/null
+++ b/src/modules/recus_fiscaux/previsualiser.html
@@ -0,0 +1,32 @@
+{{#restrict block=true section="accounting" level="write"}}
+{{/restrict}}
+
+{{if !$_POST}}
+ {{:error message="Aucune donnée fournie"}}
+{{/if}}
+
+{{:assign var="preview" value=1}}
+
+{{:include file="/receipt/_header.html"
+ page_size="A4"
+ title="Reçu fiscal %06d - %s"|args:$id:$r.nom
+ hide_buttons=true
+}}
+
+
+
+{{:include file="./_recu.html"}}
+
+
+ {{if $config.files.signature}}
+
+ {{elseif $config.files.logo}}
+
+ {{/if}}
+
+
+{{:include file="/receipt/_footer.html"}}
+
+
diff --git a/src/modules/recus_fiscaux/recap.html b/src/modules/recus_fiscaux/recap.html
new file mode 100644
index 0000000..6e9f21f
--- /dev/null
+++ b/src/modules/recus_fiscaux/recap.html
@@ -0,0 +1,36 @@
+{{#restrict block=true section="accounting" level="read"}}
+{{/restrict}}
+
+{{:include file="./_config_default.tpl"}}
+
+{{:admin_header title="Reçus fiscaux — Récapitulatif pour déclaration" current="acc"}}
+
+
+
+
+
+
+
Depuis 2022, l'administration fiscale demande aux associations délivrant des reçus fiscaux de procéder à une déclaration annuelle :
+
+ du montant cumulé des dons et versements perçus ayant donné lieu à l’émission d’un reçu fiscal ;
+ du nombre de reçus délivrés.
+
+
{{:linkbutton shape="right" target="_blank" label="Déclarer les dons auprès de l'administration" href="https://www.demarches-simplifiees.fr/commencer/declaration-des-dons"}}
+
+
+{{#list select="SUBSTR($$.date, 1, 4) AS 'Année'; SUM($$.montant) AS 'Montant cumulé'; COUNT(id) AS 'Nombre de reçus'" where="$$.annule = 0" order=1}}
+
+ {{$col1}}
+ {{$col2|raw|money_currency}}
+ {{$col3}}
+
+{{else}}
+
+ Il n'y a aucun reçu enregistré.
+
+{{/list}}
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/recu.html b/src/modules/recus_fiscaux/recu.html
new file mode 100644
index 0000000..9643fa5
--- /dev/null
+++ b/src/modules/recus_fiscaux/recu.html
@@ -0,0 +1,34 @@
+{{#restrict block=true section="accounting" level="write"}}
+{{/restrict}}
+
+{{if !$_GET.id}}
+ {{:error message="Aucun numéro de reçu n'a été fourni"}}
+{{/if}}
+
+{{#load id=$_GET.id}}
+ {{:include file="/receipt/_header.html"
+ page_size="A4"
+ title="Reçu fiscal %06d - %s"|args:$id:$nom
+ }}
+
+ {{if $annule}}
+
+ {{/if}}
+
+ {{:assign var="recu" value=$recu|replace:'%ID%':$id}}
+ {{$recu|raw}}
+
+
+ {{if $config.files.signature}}
+
+ {{elseif $config.files.logo}}
+
+ {{/if}}
+
+
+ {{:include file="/receipt/_footer.html"}}
+{{else}}
+ {{:error message="Le numéro de reçu fourni n'a pas été trouvé"}}
+{{/load}}
diff --git a/src/modules/recus_fiscaux/recu.schema.json b/src/modules/recus_fiscaux/recu.schema.json
new file mode 100644
index 0000000..0900aab
--- /dev/null
+++ b/src/modules/recus_fiscaux/recu.schema.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "annule": {
+ "description": "Annulé",
+ "type": "boolean"
+ },
+ "entreprise": {
+ "description": "Entreprise",
+ "type": "boolean"
+ },
+ "date": {
+ "description": "Date d'émission",
+ "type": "string",
+ "format": "date"
+ },
+ "nom": {
+ "description": "Nom du bénéficiaire",
+ "type": "string"
+ },
+ "montant": {
+ "description": "Montant",
+ "type": "integer",
+ "minimum": 0
+ },
+ "linked_user": {
+ "type": ["null", "integer"]
+ },
+ "linked_transactions": {
+ "type": ["null", "array"],
+ "items": {
+ "type": "integer"
+ }
+ },
+ "recu": {
+ "type": "string"
+ },
+ "id_year": {
+ "type": ["null", "integer"]
+ }
+ },
+ "required": [ "annule", "date", "nom", "montant", "recu", "linked_user", "linked_transactions", "id_year"]
+}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/snippets/transaction_details.html b/src/modules/recus_fiscaux/snippets/transaction_details.html
new file mode 100644
index 0000000..56eba6d
--- /dev/null
+++ b/src/modules/recus_fiscaux/snippets/transaction_details.html
@@ -0,0 +1 @@
+{{*FIXME TODO*}}
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/snippets/user_details.html b/src/modules/recus_fiscaux/snippets/user_details.html
new file mode 100644
index 0000000..b12f3ac
--- /dev/null
+++ b/src/modules/recus_fiscaux/snippets/user_details.html
@@ -0,0 +1,21 @@
+{{$module.label}}
+
+{{#restrict section="accounting" level="write"}}
+
+ {{:linkbutton shape="plus" label="Créer un reçu fiscal" href="%snouveau.html?type=user&id_user=%d"|args:$module.url:$user.id target="_dialog"}}
+
+{{/restrict}}
+
+
+{{#load linked_user=$user.id}}
+
+ {{:link href="%srecu.html?id=%d"|args:$module.url:$id target="_dialog" label=$id}}
+ {{$date|date_short}}
+ {{$montant|raw|money_currency_html}}
+
+ {{:linkbutton shape="eye" label="Ouvrir" href="%srecu.html?id=%d"|args:$module.url:$id target="_dialog"}}
+ {{:linkbutton shape="mail" label="Envoyer" href="%senvoyer.html?id=%d"|args:$module.url:$id target="_dialog"}}
+
+
+{{/load}}
+
\ No newline at end of file
diff --git a/src/modules/recus_fiscaux/voir.html b/src/modules/recus_fiscaux/voir.html
new file mode 100644
index 0000000..a84282f
--- /dev/null
+++ b/src/modules/recus_fiscaux/voir.html
@@ -0,0 +1,12 @@
+{{#restrict block=true section="accounting" level="write"}}
+{{/restrict}}
+
+{{:admin_header title="Reçu fiscal" current="acc" hide_title=true}}
+
+
+ {{:linkbutton shape="left" label="Retour à la liste des reçus" href="./"}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/signup/config.html b/src/modules/signup/config.html
new file mode 100644
index 0000000..e6671ec
--- /dev/null
+++ b/src/modules/signup/config.html
@@ -0,0 +1,79 @@
+{{#form on="save"}}
+ {{:save key="config"
+ validate_schema="./config.schema.json"
+ header=$_POST.header|trim|or:null
+ fields=$_POST.fields|arrayval|or:null
+ email_waiting_subject=$_POST.email_waiting_subject|trim
+ email_waiting_body=$_POST.email_waiting_body|trim
+ email_confirmed_subject=$_POST.email_confirmed_subject|trim
+ email_confirmed_body=$_POST.email_confirmed_body|trim
+ }}
+ {{:redirect to="?ok=1"}}
+{{/form}}
+
+{{:admin_header title="Configuration du formulaire d'inscription"}}
+
+{{if $_GET.ok}}
+ Configuration enregistrée.
+{{/if}}
+
+{{:form_errors}}
+
+
+
+
+ Informations générales
+
+ {{:input type="textarea" cols="70" rows="15" name="header" required=false source=$module.config label="Informations d'inscription" default="Vous voulez rejoindre notre association ?\n\nRenseignez les champs ci-dessous avec vos informations.\n\nUne fois votre fiche renseignée, nous validerons votre inscription dès que possible."}}
+ Syntaxe Markdown acceptée.
+ {{:linkbutton shape="help" label="Aide de la syntaxe Markdown" href="!static/doc/markdown.html"}}
+
+
+
+
+
+ Champs que le membre pourra pré-remplir
+
+ Chacun des champs cochés sera visible dans la fiche d'inscription, et le membre pourra le renseigner.
+
+
+ {{#select * FROM config_users_fields WHERE type NOT IN ('file', 'password', 'virtual') AND system & (1 << 3) = 0 ORDER BY sort_order}}
+ {{if $module.config.fields|has:$name}}
+ {{:assign var="checked" value=$name}}
+ {{else}}
+ {{:assign var="checked" value=null}}
+ {{/if}}
+ {{:input type="checkbox" name="fields[]" value=$name label=$label default=$checked}}
+ {{/select}}
+
+
+
+
+ Message envoyé lors de l'enregistrement de la pré-inscription
+
+ Ce message sera envoyé au membre quand sa pré-inscription est enregistrée et est en attente de validation par un⋅e gestionnaire.
+
+
+ {{:input name="email_waiting_subject" type="text" label="Sujet du message" required=true source=$module.config default="Votre demande d'inscription a été enregistrée"}}
+ {{:input name="email_waiting_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config default="Bonjour !\n\nNous avons bien enregistré votre demande d'inscription.\n\nVous recevrez un message quand elle aura été validée par une administratice ou un administrateur."}}
+
+
+
+
+ Message envoyé lors de la validation de l'inscription
+
+ Ce message sera envoyé au membre quand un⋅e gestionnaire aura validé son inscription.
+
+
+ {{:input name="email_confirmed_subject" type="text" label="Sujet du message" required=true source=$module.config default="Bienvenue !"}}
+ {{:input name="email_confirmed_body" type="textarea" label="Corps du message" cols="70" rows="7" required=true source=$module.config default="Bonjour !\n\nVotre inscription est validée."}}
+
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/signup/config.schema.json b/src/modules/signup/config.schema.json
new file mode 100644
index 0000000..1ac6dd4
--- /dev/null
+++ b/src/modules/signup/config.schema.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "header": {
+ "description": "Informations",
+ "type": ["string", "null"]
+ },
+ "fields": {
+ "description": "Champs",
+ "type": ["null", "array"],
+ "items": {
+ "type": "string"
+ }
+ },
+ "email_waiting_subject": {
+ "type": "string"
+ },
+ "email_waiting_body": {
+ "type": "string"
+ },
+ "email_confirmed_subject": {
+ "type": "string"
+ },
+ "email_confirmed_body": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "header",
+ "fields",
+ "email_waiting_subject",
+ "email_waiting_body",
+ "email_confirmed_subject",
+ "email_confirmed_body"
+ ]
+}
\ No newline at end of file
diff --git a/src/modules/signup/icon.svg b/src/modules/signup/icon.svg
new file mode 100644
index 0000000..0d5f4a0
--- /dev/null
+++ b/src/modules/signup/icon.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/modules/signup/ignore b/src/modules/signup/ignore
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/signup/index.html b/src/modules/signup/index.html
new file mode 100644
index 0000000..7b904ea
--- /dev/null
+++ b/src/modules/signup/index.html
@@ -0,0 +1,5 @@
+{{if !$logged_user}}
+ {{:include file="./signup.html"}}
+{{else}}
+ {{:include file="./list.html"}}
+{{/if}}
diff --git a/src/modules/signup/module.ini b/src/modules/signup/module.ini
new file mode 100644
index 0000000..2338b06
--- /dev/null
+++ b/src/modules/signup/module.ini
@@ -0,0 +1,6 @@
+name="Inscriptions (alpha)"
+description="(En test) Permet aux visiteurs de s'inscrire à l'association depuis le site web.\nLes inscriptions sont ensuite validées par une personne en charge de la gestion des membres."
+author="Paheko"
+author_url="https://paheko.cloud/"
+home_button=true
+menu=false
diff --git a/src/modules/signup/signup.html b/src/modules/signup/signup.html
new file mode 100644
index 0000000..c63cb19
--- /dev/null
+++ b/src/modules/signup/signup.html
@@ -0,0 +1,33 @@
+{{#form on="save"}}
+{{/form}}
+
+{{:assign var="custom_css." value="/content.css"}}
+{{:admin_header title="Inscription" custom_css=$custom_css layout="public"}}
+
+{{if $module.config.header}}
+ {{$module.config.header|raw|markdown}}
+{{/if}}
+
+{{:form_errors}}
+
+
+
+ Merci de renseigner vos informations
+
+ {{#select * FROM config_users_fields WHERE !fields_list ORDER BY sort_order;
+ !fields_list="name"|sql_where:$module.config.fields}}
+ {{:edit_user_field name=$name}}
+ {{/select}}
+
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+ {{:linkbutton shape="left" label="Retourner sur notre site" href=$site_url}}
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/transactions_templates/delete.html b/src/modules/transactions_templates/delete.html
new file mode 100644
index 0000000..5b2702f
--- /dev/null
+++ b/src/modules/transactions_templates/delete.html
@@ -0,0 +1,17 @@
+{{#restrict section="accounting" level="write" block=true}}{{/restrict}}
+
+{{#load assign="tpl" id=$_GET.id|intval}}
+{{else}}
+ {{:error message="Aucun modèle trouvé"}}
+{{/load}}
+
+{{#form on="delete"}}
+ {{:delete id=$tpl.id}}
+ {{:http redirect="./"}}
+{{/form}}
+
+{{:admin_header title="Supprimer modèle"}}
+
+{{:delete_form legend="Supprimer un modèle" warning="Supprimer le modèle '%s' ?"|args:$tpl.name}}
+
+{{:admin_footer}}
diff --git a/src/modules/transactions_templates/edit.html b/src/modules/transactions_templates/edit.html
new file mode 100644
index 0000000..a126d52
--- /dev/null
+++ b/src/modules/transactions_templates/edit.html
@@ -0,0 +1,151 @@
+{{#restrict section="accounting" level="write" block=true}}{{/restrict}}
+{{:admin_header title="Modèle d'écriture"}}
+
+{{:assign var="types" 1="Recette" 2="Dépense" 3="Virement" 4="Dette" 5="Créance" 0="Avancé"}}
+
+{{if $_GET.id}}
+ {{#load assign="tpl" id=$_GET.id|intval}}{{/load}}
+{{/if}}
+
+{{if !$tpl}}
+ {{* 2 empty lines by default *}}
+ {{:assign var="tpl.ll.0" l=""}}
+ {{:assign var="tpl.ll.1" l=""}}
+{{/if}}
+
+{{if $_POST.save}}
+ {{if $_POST.t == 0}}
+ {{:assign var="lines" value=$_POST.ll|array_transpose}}
+ {{/if}}
+ {{:save id=$tpl.id
+ validate_schema="./template.schema.json"
+ name=$_POST.name|strval|trim
+ t=$_POST.t|intval
+ l=$_POST.l|strval|trim|or:null
+ dt=$_POST.dt|strval|trim|or:null
+ r=$_POST.r|strval|trim|or:null
+ a00=$_POST.a00|money_int
+ pr=$_POST.pr|strval|or:null
+ p=$_POST.p|intval|or:null
+ ar=$_POST.ar|strval|trim|or:null
+ ae=$_POST.ae|strval|trim|or:null
+ ab=$_POST.ab|strval|trim|or:null
+ at=$_POST.at|strval|trim|or:null
+ a3=$_POST.a3|strval|trim|or:null
+ ll=$lines|or:null
+ }}
+ {{:http redirect="./"}}
+{{/if}}
+
+{{#select id, CASE WHEN code IS NOT NULL THEN code || ' — ' || label ELSE label END AS label FROM acc_projects ORDER BY code, label}}
+ {{:assign var="projects.%d"|args:$id value=$label}}
+{{/select}}
+
+
+
+ {{if !$tpl}}Nouveau modèle{{else}}Modifier le modèle{{/if}}
+
+ {{:input type="text" required=true name="name" label="Nom du modèle" source=$tpl}}
+ {{:input type="select" name="t" label="Type" options=$types required=true source=$tpl}}
+ {{:input type="text" name="l" label="Libellé" source=$tpl}}
+ {{:input type="date" name="dt" label="Date" source=$tpl}}
+ {{:input type="text" name="r" label="Numéro de pièce comptable" source=$tpl}}
+
+
+ {{:input type="money" name="a00" label="Montant" source=$tpl}}
+ {{:input type="text" name="pr" label="Référence de paiement" source=$tpl}}
+ {{if $projects}}
+ {{:input type="select" name="p" label="Projet" options=$projects source=$tpl default_empty="— Aucun —"}}
+ {{/if}}
+
+
+ {{:input type="text" name="ar" label="Code du compte de recette" source=$tpl}}
+
+
+ {{:input type="text" name="ae" label="Code du compte de dépense" source=$tpl}}
+
+
+ {{:input type="text" name="ab" label="Code du compte de caisse ou de banque" source=$tpl}}
+
+
+ {{:input type="text" name="at" label="Code du compte de caisse ou de banque destinataire" source=$tpl}}
+
+
+ {{:input type="text" name="a3" label="Code du compte de tiers" source=$tpl}}
+
+
+
+
+ Lignes de l'écriture
+
+
+
+ Code du compte
+ Débit
+ Crédit
+ Libellé ligne
+ Réf. ligne
+
+
+
+
+ {{#foreach from=$tpl.ll key="k" item="line"}}
+
+
+ {{:input type="text" name="ll[a][]" default=$line.a}}
+
+ {{:input type="text" name="ll[d][]" default=$line.d size=5}}
+ {{:input type="text" name="ll[c][]" default=$line.c size=5}}
+ {{:input type="text" name="ll[l][]" default=$line.l}}
+ {{:input type="text" name="ll[r][]" default=$line.r size=10}}
+ {{:button label="Enlever" title="Enlever la ligne" shape="minus" class="line_del"}}
+
+ {{/foreach}}
+
+
+ {{:button label="Ajouter une ligne" shape="plus" class="line_add"}}
+
+
+
+ {{:button type="submit" shape="right" label="Enregistrer" name="save" class="main"}}
+
+
+
+
+
+{{:admin_footer}}
diff --git a/src/modules/transactions_templates/icon.svg b/src/modules/transactions_templates/icon.svg
new file mode 100644
index 0000000..835e489
--- /dev/null
+++ b/src/modules/transactions_templates/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/modules/transactions_templates/index.html b/src/modules/transactions_templates/index.html
new file mode 100644
index 0000000..32ab707
--- /dev/null
+++ b/src/modules/transactions_templates/index.html
@@ -0,0 +1,31 @@
+{{#restrict section="accounting" level="write" block=true}}{{/restrict}}
+{{:admin_header title="Modèles d'écritures"}}
+
+
+
+ {{:linkbutton href="edit.html" label="Nouveau modèle" shape="plus"}}
+
+
+
+{{#list
+ select="$$.name AS 'Nom'"
+ order=1 desc=false
+}}
+ {{:assign var="qs" t=$t l=$l dt=$dt r=$r a00=$a00 pr=$pr p=$p ar=$ar ae=$ae ab=$ab at=$at a3=$a3 ll=$ll}}
+ {{:assign qs=$qs|http_build_query}}
+ {{:assign url="!acc/transactions/new.php?%s"|args:$qs}}
+
+ {{:link href=$url label=$name}}
+
+ {{:linkbutton href=$url label="Saisir cette écriture" shape="plus"}}
+ {{:linkbutton href="edit.html?id=%d"|args:$id label="Modifier" shape="edit"}}
+ {{:linkbutton target="_dialog" href="delete.html?id=%d"|args:$id label="Supprimer" shape="delete"}}
+
+
+{{else}}
+
+ Aucun modèle d'écriture.
+
+{{/list}}
+
+{{:admin_footer}}
diff --git a/src/modules/transactions_templates/module.ini b/src/modules/transactions_templates/module.ini
new file mode 100644
index 0000000..2b7c0f0
--- /dev/null
+++ b/src/modules/transactions_templates/module.ini
@@ -0,0 +1,7 @@
+name="Modèles d'écriture"
+description="Pour créer des modèles d'écritures comptables, qui permettront ensuite de créer des écritures rapidement."
+author="Paheko"
+author_url="https://paheko.cloud/"
+home_button=true
+restrict_section="accounting"
+restrict_level="write"
diff --git a/src/modules/transactions_templates/snippets/transaction_new.html b/src/modules/transactions_templates/snippets/transaction_new.html
new file mode 100644
index 0000000..a7ea9c7
--- /dev/null
+++ b/src/modules/transactions_templates/snippets/transaction_new.html
@@ -0,0 +1,33 @@
+{{if !$_POST}}
+
+
+
+ Saisir à partir d'un modèle :
+
+
+
+
+ Sélectionner un modèle
+ {{#load order="$$.name" assign="row"}}
+ {{#foreach from=$row item="value" key="key"}}
+ {{if $key === 'id'}}
+ {{:assign var="qs.tplid" value=$value}}
+ {{elseif $key !== 'name'}}
+ {{:assign var="qs.%s"|args:$key value=$value}}
+ {{/if}}
+ {{/foreach}}
+ {{:assign qs=$qs|http_build_query}}
+
+
+ {{$name}}
+
+
+ {{/load}}
+
+
+
+ {{:linkbutton shape="edit" href=$module.url label="Modifier les modèles"}}
+
+
+
+{{/if}}
\ No newline at end of file
diff --git a/src/modules/transactions_templates/template.schema.json b/src/modules/transactions_templates/template.schema.json
new file mode 100644
index 0000000..cd949d2
--- /dev/null
+++ b/src/modules/transactions_templates/template.schema.json
@@ -0,0 +1,89 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "Libellé du modèle",
+ "type": "string"
+ },
+ "t": {
+ "description": "Type de l'écriture",
+ "type": "integer"
+ },
+ "l": {
+ "description": "Libellé de l'écriture",
+ "type": ["string", "null"]
+ },
+ "dt": {
+ "description": "Date de l'écriture",
+ "type": ["string", "null"]
+ },
+ "r": {
+ "description": "Référence de l'écriture",
+ "type": ["string", "null"]
+ },
+ "a00": {
+ "description": "Montant de l'écriture (simplifiée)",
+ "type": ["null", "integer"]
+ },
+ "pr": {
+ "description": "Référence de paiement",
+ "type": ["string", "null"]
+ },
+ "p": {
+ "description": "ID du projet analytique",
+ "type": ["integer", "null"]
+ },
+ "ar": {
+ "description": "Code du compte de recette (produit)",
+ "type": ["string", "null"]
+ },
+ "ae": {
+ "description": "Code du compte de dépense (charge)",
+ "type": ["string", "null"]
+ },
+ "ab": {
+ "description": "Code du compte de banque ou caisse (pour écritures de recette, dépense ou virement)",
+ "type": ["string", "null"]
+ },
+ "at": {
+ "description": "Code du compte de banque ou de caisse destinataire du virement",
+ "type": ["string", "null"]
+ },
+ "a3": {
+ "description": "Code du compte de tiers",
+ "type": ["string", "null"]
+ },
+ "ll": {
+ "description": "Lignes de l'écriture avancée",
+ "type": ["array", "null"],
+ "items": {
+ "type": "object",
+ "properties": {
+ "a": {
+ "description": "Code du compte",
+ "type": ["string", "null"]
+ },
+ "d": {
+ "description": "Débit",
+ "type": ["string", "null"]
+ },
+ "c": {
+ "description": "Crédit",
+ "type": ["string", "null"]
+ },
+ "l": {
+ "description": "Libellé ligne",
+ "type": ["string", "null"]
+ },
+ "r": {
+ "description": "Référence ligne",
+ "type": ["string", "null"]
+ }
+ }
+ },
+ "required": [ "a", "d", "c", "l", "r" ]
+ }
+ },
+ "required": [ "name", "t", "l", "dt", "r", "a00", "p", "ar", "ae", "ab", "at", "a3", "ll"]
+}
\ No newline at end of file
diff --git a/src/modules/web/404.html b/src/modules/web/404.html
new file mode 100644
index 0000000..05a11f7
--- /dev/null
+++ b/src/modules/web/404.html
@@ -0,0 +1,8 @@
+{{:http code=404}}
+{{:include file="./_head.html" title="Page non trouvée"}}
+
+
+ Cette page n'existe pas.
+
+
+{{:include file="./_foot.html"}}
diff --git a/src/modules/web/_breadcrumbs.html b/src/modules/web/_breadcrumbs.html
new file mode 100644
index 0000000..7c3ec71
--- /dev/null
+++ b/src/modules/web/_breadcrumbs.html
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/modules/web/_foot.html b/src/modules/web/_foot.html
new file mode 100644
index 0000000..2bf22d1
--- /dev/null
+++ b/src/modules/web/_foot.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Propulsé par Paheko — logiciel libre de gestion d'association
+ Ce site n'utilise aucun pisteur, conformément au RGPD — Mentions légales
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/web/_head.html b/src/modules/web/_head.html
new file mode 100644
index 0000000..9ecbb8f
--- /dev/null
+++ b/src/modules/web/_head.html
@@ -0,0 +1,117 @@
+
+
+
+
+
{{if $title}}{{$title}} — {{/if}}{{$config.org_name}}
+
+
+
+
+
+
+
+
+
+
+ {{if $config.files.logo}}
+
+ {{/if}}
+
+
+
+
+
+
+
+ {{#restrict section="web" level="write"}}
+
+ {{if $page.id}}
+ {{:linkbutton href="!web/edit.php?id=%d"|args:$page.id shape="edit" label="Modifier cette page"}}
+ {{:linkbutton href="!web/?id=%d"|args:$page.id shape="eye" label="Gérer cette page"}}
+ {{else}}
+ {{:linkbutton href="!web/" shape="edit" label="Gérer le site web"}}
+ {{/if}}
+
+ {{/restrict}}
+
+
+
+
+
+
+
+
+ {{if $config.org_address || $config.org_phone || $config.org_email}}
+
+ {{if $config.files.logo}}
+ {{$config.org_name}}
+ {{/if}}
+ {{if $config.org_address}}
+
+ {{* Lien permettant d'ouvrir l'adresse dans l'app de cartographie du téléphone, ou OpenStreetMap sinon *}}
+
+
+ {{$config.org_address|escape|nl2br}}
+
+
+ {{/if}}
+ {{if $config.org_phone}}
+ {{$config.org_phone|raw|protect_contact:'tel'}}
+ {{/if}}
+ {{if $config.org_email}}
+ {{$config.org_email|raw|protect_contact}}
+ {{/if}}
+
+ {{/if}}
+
+
+
+
+{{#module name="bookings"}}
+
+ Réserver un créneau
+
+{{/module}}
+
+
+
+
+
+
+
+
+
+ {{#categories parent=null order="title"}}
+ {{ $title }}
+ {{/categories}}
+
+
+
+ {{#module name="openings"}}
+
+ {{:include file="/openings/_all.html"}}
+
+ {{/module}}
+
+
+
+
+
diff --git a/src/modules/web/article.html b/src/modules/web/article.html
new file mode 100644
index 0000000..7626a81
--- /dev/null
+++ b/src/modules/web/article.html
@@ -0,0 +1,16 @@
+{{:include file="./_head.html" title=$page.title}}
+
+{{:include file="./_breadcrumbs.html" parent=$page.path}}
+
+
+ {{$page.title}}
+
+ Publié le {{$page.published|date_long:true}}
+
+ {{$page.html|raw}}
+
+ {{:include file="./gallery.html" parent=$page.path}}
+ {{:include file="./documents.html" parent=$page.path}}
+
+
+{{:include file="./_foot.html"}}
\ No newline at end of file
diff --git a/src/modules/web/atom.xml b/src/modules/web/atom.xml
new file mode 100644
index 0000000..7d10728
--- /dev/null
+++ b/src/modules/web/atom.xml
@@ -0,0 +1,35 @@
+{{:http type="application/atom+xml"}}
+
+
+
+
+ {{$config.org_name|xml_escape}}
+
+
+ {{#articles order="published DESC" limit=1}}
+ {{$published|atom_date}}
+ {{/articles}}
+
+
+ {{$config.org_name|xml_escape}}
+
+
+ {{$root_url|xml_escape}}
+ Paheko
+
+ {{#articles order="published DESC" limit=20}}
+
+ {{$title|xml_escape}}
+
+ {{$url|xml_escape}}
+ {{$published|atom_date}}
+ {{$config.org_name|xml_escape}}
+
+
+
+
+ {{/articles}}
+
+
\ No newline at end of file
diff --git a/src/modules/web/category.html b/src/modules/web/category.html
new file mode 100644
index 0000000..83e3278
--- /dev/null
+++ b/src/modules/web/category.html
@@ -0,0 +1,34 @@
+{{:include file="./_head.html" title=$page.title}}
+
+{{:include file="./_breadcrumbs.html" parent=$page.path}}
+
+{{$page.title}}
+
+
+ {{#categories parent=$page.path order="title"}}
+
+ {{/categories}}
+
+
+{{if $page.html || $page.has_attachments}}
+
+ {{$page.html|raw}}
+
+ {{:include file="./gallery.html" parent=$page.path}}
+ {{:include file="./documents.html" parent=$page.path}}
+
+{{/if}}
+
+
+ {{#articles parent=$page.path order="published DESC"}}
+
+
+ {{$published|date_long}}
+ {{$html|raw|strip_tags|truncate:200}}
+
+ {{/articles}}
+
+
+{{:include file="./_foot.html"}}
\ No newline at end of file
diff --git a/src/modules/web/config.html b/src/modules/web/config.html
new file mode 100644
index 0000000..2ed5572
--- /dev/null
+++ b/src/modules/web/config.html
@@ -0,0 +1,139 @@
+{{:admin_header title="Configuration du site web"}}
+
+{{#form on="save"}}
+ {{:save key="config"
+ color1=$_POST.color1|trim|or:null
+ color2=$_POST.color2|trim|or:null
+ layout=$_POST.layout|trim|or:null
+ background=$_POST.background|trim|or:null
+ }}
+ {{:redirect to="?ok=1"}}
+{{/form}}
+
+{{if $_GET.ok}}
+ Configuration enregistrée.
+{{/if}}
+
+{{:form_errors}}
+
+
+
+{{:assign var="layouts"
+ 2col="Classique — 2 colonnes"
+ 1col="Classique — 1 colonne"
+ wide="Large — 2 colonnes"
+}}
+
+{{:assign var="backgrounds"
+ gradient="Dégradé entre les deux couleurs"
+ white_gradient="Dégradé couleur primaire — blanc"
+ white="Blanc"
+ gray="Blanc et gris"
+ dark="Sombre"
+}}
+
+
+
+{{if $config.site_disabled}}
+Le site est actuellement désactivé.
+{{else}}
+
+
+
+{{/if}}
+
+
+ Configuration du site web
+
+ {{:input type="select" name="layout" label="Disposition sur grand écran" required=true default="2col" options=$layouts help="Sur petit écran (mobile), la disposition reste sur une colonne" source=$module.config}}
+ {{:input type="color" name="color1" default=$config.color1 label="Couleur primaire" required=false source=$module.config}}
+ {{:input type="color" name="color2" default=$config.color2 label="Couleur secondaire" required=false source=$module.config}}
+ {{:input type="select" name="background" options=$backgrounds label="Couleur de fond" required=true source=$module.config}}
+
+
+
+
+ {{:button type="submit" name="save" label="Enregistrer" shape="right" class="main"}}
+
+
+
+
+
+
+{{:admin_footer}}
\ No newline at end of file
diff --git a/src/modules/web/content.css b/src/modules/web/content.css
new file mode 100644
index 0000000..bb29f34
--- /dev/null
+++ b/src/modules/web/content.css
@@ -0,0 +1,534 @@
+/**
+ * Ce fichier contient les styles CSS qui s'appliquent au contenu des articles et catégorie,
+ * que ce soit sur le site public ou dans la prévisualisation de l'administration.
+ *
+ * Généralement il n'est pas nécessaire de le modifier.
+ */
+
+.protected-contact::before {
+ content: attr(data-a) "\0040" attr(data-b) "." attr(data-c);
+}
+
+.web-content {
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+
+ word-break: break-word;
+
+ -ms-hyphens: auto;
+ -moz-hyphens: auto;
+ -webkit-hyphens: auto;
+ hyphens: auto;
+}
+
+.web-content figure {
+ margin: 0;
+ padding: 0;
+}
+
+.web-content p, .web-content h1, .web-content h2, .web-content h3, .web-content h4, .web-content h5, .web-content h6,
+.web-content ul, .web-content ol, .web-content table, .web-content blockquote, .web-content pre {
+ margin-bottom: 1rem;
+}
+
+.web-content ul, .web-content ol, .web-content dd {
+ margin-left: 2em;
+}
+
+.web-content ul {
+ list-style-type: disc;
+}
+
+.web-content ol {
+ list-style-type: decimal;
+}
+
+.web-content blockquote::before {
+ content: "”";
+ color: rgba(0, 0, 0, 0.5);
+ display: block;
+ position: absolute;
+ font-style: italic;
+ font-size: 3rem;
+ line-height: 2rem;
+ margin-left: -3rem;
+}
+
+.web-content blockquote {
+ font-size: 1.1em;
+ padding-left: 3rem;
+}
+
+.web-content a.footnote-ref {
+ vertical-align: super;
+ font-size: small;
+}
+
+.web-content a.footnote-ref, .web-content .footnotes dt a {
+ color: blue;
+}
+
+.web-content a.footnote-ref::before, .web-content .footnotes dt a::before {
+ content: "[";
+}
+
+.web-content a.footnote-ref::after, .web-content .footnotes dt a::after {
+ content: "]";
+}
+
+.web-content dl.footnotes {
+ display: grid;
+ grid-template-columns: .1fr .9fr;
+ border-top: 2px solid rgba(0, 0, 0, 0.5);
+ margin-top: 1rem;
+ padding-top: 1rem;
+ color: #333;
+}
+
+.web-content hr {
+ border: none;
+ border-top: 2px solid rgba(0, 0, 0, 0.5);
+ margin: 1rem 0;
+}
+
+.web-content code, .web-content pre {
+ background: rgba(100, 100, 100, 0.2);
+ padding: .2rem;
+}
+
+.web-content pre code {
+ background: unset;
+ padding: unset;
+}
+
+.web-content pre, .web-content table {
+ overflow-x: auto;
+ max-width: 100%;
+}
+
+.web-content dl.footnotes dd {
+ margin: 0;
+ margin-bottom: 1rem;
+}
+
+.web-content dl.footnotes dd p {
+ margin: 0;
+ margin-bottom: 1rem;
+}
+
+.web-content table {
+ border-collapse: collapse;
+}
+
+.web-content table th, .web-content table td {
+ border: 1px solid #999;
+ padding: .5rem;
+ text-align: center;
+}
+
+.web-content table thead {
+ background: #ddd;
+ font-weight: bold;
+ border-bottom: 5px solid #999;
+}
+
+.web-content table tbody tr:nth-child(even) {
+ background: #eee;
+}
+
+.web-content mark {
+ background: #ffb;
+ color: #000;
+ padding: 1px 3px;
+ display: inline-block;
+ box-shadow: 0px 0px 3px 1px #990;
+ border-radius: .2em;
+}
+
+.web-content kbd {
+ background: #ccc;
+ color: #000;
+ padding: 2px 4px;
+ display: inline-block;
+ box-shadow: 0px 2px 3px 1px #999;
+ border: 1px solid #fff;
+ border-radius: .2em;
+}
+
+.web-content samp {
+ background: #333;
+ color: #fff;
+ padding: 2px 4px;
+ display: inline-block;
+ border-radius: .2em;
+}
+
+.web-content del {
+ color: darkred;
+ text-decoration: line-through;
+}
+
+.web-content ins {
+ color: darkgreen;
+ text-decoration: underline overline;
+ text-decoration-color: green;
+}
+
+.web-content var {
+ color: darkblue;
+ background: #ddd;
+ border-radius: .2em;
+ padding: 2px 4px;
+ display: inline-block;
+}
+
+.web-content aside.toc {
+ float: right;
+ margin-left: 2em;
+ max-width: 20em;
+}
+
+.web-content .toc {
+ margin: 1rem 0;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ background: rgba(0, 0, 0, 0.1);
+ padding: .3rem;
+ display: block;
+ width: fit-content;
+}
+
+.web-content .toc ol {
+ list-style: none;
+ counter-reset: item;
+ margin: .5rem 0 .5rem .5rem;
+}
+
+.web-content .toc ol li:before {
+ content: counters(item, ".") " ";
+ counter-increment: item;
+ color: #666;
+}
+
+
+.web-content figure.file {
+ display: inline-block;
+ margin: .8em;
+}
+
+.web-content figure.file > a {
+ background: #ddd;
+ display: block;
+ border-radius: .5rem;
+ overflow: hidden;
+ text-align: center;
+ transition: background-color .2s, color .2s;
+ text-decoration: none;
+}
+
+.web-content figure.file img {
+ border-radius: .3rem;
+ margin-top: .5rem;
+}
+
+.web-content figure.file small {
+ display: block;
+ background: no-repeat .5em center;
+ background-image: url('data:image/svg+xml;utf8, ');
+ padding-left: 32px;
+ font-size: .9em;
+}
+
+.web-content figure.file figcaption {
+ display: block;
+ text-decoration: none;
+ padding: .5rem;
+ font-style: normal;
+ color: darkblue;
+}
+
+.web-content figure.file a b {
+ font-size: 1.2em;
+ text-decoration: underline;
+}
+
+.web-content figure.file a:hover {
+ background-color: #ccc;
+}
+
+.web-content figure.file a:hover figcaption {
+ color: darkred;
+}
+
+.web-content iframe {
+ margin: 1rem auto;
+ display: block;
+}
+
+.web-content figure.image, .web-content figure.video {
+ text-align: center;
+}
+
+/** Video preview (youtube) */
+.web-content figure.video a {
+ position: relative;
+ display: inline-flex;
+}
+
+.web-content figure.video a img {
+ transition: opacity .2s;
+ background: #000;
+}
+
+.web-content figure.video a::after {
+ position: absolute;
+ content: " ";
+ background: no-repeat center center;
+ background-size: 40%;
+ background-image: url('data:image/svg+xml;utf8, ');
+ display: block;
+ margin: 0 auto;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+}
+
+.web-content figure.video a:hover img {
+ opacity: 0.7;
+}
+
+.web-content video {
+ margin: .8em auto;
+ display: block;
+}
+
+.web-content figure.image figcaption {
+ font-style: italic;
+ color: #666;
+ margin-top: 2pt;
+}
+
+.web-content figure.image.img-center {
+ max-width: 90%;
+ margin: 0 auto 1rem auto;
+}
+
+.web-content figure.image.img-left {
+ max-width: 250px;
+ float: left;
+ margin: 0 8pt 4pt 0;
+ clear: left;
+}
+
+.web-content figure.image.img-right {
+ max-width: 250px;
+ float: right;
+ margin: 0 0 4pt 8pt;
+ clear: right;
+}
+
+.web-content figure.image.img-left figcaption, .web-content figure.image.img-right figcaption {
+ font-size: .8em;
+}
+
+.web-content a.internal-image {
+ cursor: zoom-in;
+}
+
+.web-content img, .web-content object {
+ max-width: 100%;
+}
+
+.web-content .gallery, .web-content .slideshow {
+ margin: 1em;
+}
+
+.web-content .gallery .images {
+ display: flex;
+ flex-wrap: wrap;
+ grid-gap: 10px;
+ justify-content: center;
+}
+
+.web-content .gallery figure {
+ flex: 1 1 auto;
+ max-height: 180px;
+}
+
+.web-content .gallery img {
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+ vertical-align: middle;
+}
+
+.web-content .gallery .images::after {
+ content: "";
+ flex-grow: 1;
+ width: 1px;
+ display: block;
+}
+
+.web-content .slideshow {
+ position: relative;
+ width: 100%;
+ margin: 1em auto;
+}
+
+.web-content .slideshow .images {
+ display: block;
+ position: relative;
+ overflow: hidden;
+ margin: 0 auto;
+ height: 450px;
+ width: 100%;
+ white-space: nowrap;
+}
+
+.web-content .slideshow .index {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.web-content .slideshow button {
+ border: none;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, .2);
+ color: #000;
+ font-weight: bold;
+ font-size: 12pt;
+ min-width: 3ch;
+ height: 3ch;
+ margin: .2em;
+ cursor: pointer;
+ text-align: center;
+}
+
+.web-content .slideshow button:hover {
+ color: darkred;
+ background: rgba(255, 255, 255, .2);
+ box-shadow: 0px 0px 5px #000;
+}
+
+.web-content .slideshow button.current {
+ background: rgba(255, 165, 0, .3);
+ box-shadow: 0px 0px 5px #999;
+}
+
+.web-content .slideshow .nav {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 450px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.web-content .slideshow figure, .web-content .slideshow a {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+}
+
+.web-content .slideshow img {
+ max-width: 100%;
+ max-height: 95%;
+}
+
+.imageBrowser {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 9999;
+ background: url("data:image/svg+xml;base64,PCEtLSBCeSBTYW0gSGVyYmVydCAoQHNoZXJiKSwgZm9yIGV2ZXJ5b25lLiBNb3JlIEAgaHR0cDovL2dvby5nbC83QUp6YkwgLS0+Cjxzdmcgd2lkdGg9IjM4IiBoZWlnaHQ9IjM4IiB2aWV3Qm94PSIwIDAgMzggMzgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjZmZmIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSAxKSIgc3Ryb2tlLXdpZHRoPSIyIj4KICAgICAgICAgICAgPGNpcmNsZSBzdHJva2Utb3BhY2l0eT0iLjUiIGN4PSIxOCIgY3k9IjE4IiByPSIxOCIvPgogICAgICAgICAgICA8cGF0aCBkPSJNMzYgMThjMC05Ljk0LTguMDYtMTgtMTgtMTgiPgogICAgICAgICAgICAgICAgPGFuaW1hdGVUcmFuc2Zvcm0gYXR0cmlidXRlTmFtZT0idHJhbnNmb3JtIiB0eXBlPSJyb3RhdGUiIGZyb209IjAgMTggMTgiIHRvPSIzNjAgMTggMTgiIGR1cj0iMXMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIi8+CiAgICAgICAgICAgIDwvcGF0aD4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==") no-repeat center center;
+ background-color: rgb(0, 0, 0);
+ background-color: rgba(0, 0, 0, 0.75);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ transition: opacity .5s;
+ cursor: zoom-out;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.imageBrowser figure {
+ max-height: 90%;
+ max-width: 90%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: opacity .5s;
+ margin: 0;
+}
+
+.imageBrowser figure img {
+ max-width: 100%;
+ max-height: 100%;
+ border: .5em solid #000;
+ border-radius: .5em;
+ cursor: pointer;
+ background: #fff;
+ /* For transparent images */
+ background-image:
+ linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc),
+ linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc);
+ background-size: 10px 10px;
+ background-position: 0 0, 5px 5px;
+}
+
+.imageBrowser figure.loading {
+ opacity: 0;
+}
+
+.imageBrowser figure.loading img {
+ border: none;
+}
+
+.web-grid {
+ /* Default grid template: just auto sized columns */;
+ --grid-template: none / repeat(auto-fit, minmax(100px, 1fr));
+ grid-gap: 1rem;
+ display: grid;
+ margin-bottom: 1rem;
+}
+
+.web-grid-debug {
+ grid-gap: 5px;
+ min-height: 10em;
+ width: 20em;
+ grid-template-rows: 6em;
+ grid-template: var(--grid-template);
+}
+
+.web-grid-debug .web-block {
+ background: #999;
+ overflow: hidden;
+ padding: 5px;
+ color: #333;
+}
+
+@media handheld, screen and (max-width: 980px) {
+ .imageBrowser figure {
+ max-width: 100%;
+ max-height: 100%;
+ }
+}
+
+@media screen and (min-width: 800px) {
+ .web-grid {
+ /* Get template from variable, which is defined style attribute */
+ grid-template: var(--grid-template);
+ }
+}
\ No newline at end of file
diff --git a/src/modules/web/default.css b/src/modules/web/default.css
new file mode 100644
index 0000000..acd57e2
--- /dev/null
+++ b/src/modules/web/default.css
@@ -0,0 +1,976 @@
+/**
+ * Ce fichier contient les styles CSS qui s'appliquent au site public.
+ */
+
+/* CSS RESET */
+body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
+ margin: 0;
+ padding: 0;
+}
+h1 { font-size: 2em; }
+h2 { font-size: 1.5em; }
+h3 { font-size: 1.2em; }
+h4 { font-size: 1em; }
+h5 { font-size: 0.9em; }
+h6 { font-size: 0.8em; }
+ul, ol { list-style-type: none; }
+article, aside, figure, footer, header, hgroup, menu, nav, section { display: block; }
+
+:root {
+ --bg-color: #fff;
+ --text-color: #000;
+ --link-color: #009;
+ --visited-link-color: #669;
+ --hover-link-color: darkred;
+ --gray-text-color: #666;
+}
+
+/* Modifications du style pour les grands écrans */
+@media screen and (min-width: 981px) {
+ /* DISPOSITION GÉNÉRALE DE LA PAGE */
+ main {
+ max-width: 950px;
+ margin: 0 auto;
+ display: grid;
+ }
+
+ .layout-1col main {
+ grid-gap: 1em;
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "header"
+ "nav"
+ "content"
+ }
+
+ .layout-2col main {
+ grid-gap: 1em;
+ grid-template-columns: 1.4fr 0.6fr;
+ grid-template-areas:
+ "header header"
+ "content nav"
+ }
+
+ .layout-wide main {
+ max-width: 1100px;
+ grid-gap: 1em;
+ grid-template-columns: 18em 1fr;
+ grid-template-rows: auto 1fr;
+ grid-template-areas:
+ "header content"
+ "nav content";
+ align-items: flex-start;
+ padding: 0 1em;
+ }
+
+ .layout-wide header.main {
+ grid-area: header;
+ display: flex;
+ grid-gap: 1em;
+ flex-direction: column;
+ }
+
+ .layout-wide section.page {
+ background: var(--bg-color);
+ border-radius: 0 0 .5em .5em;
+ padding: 1em;
+ }
+
+ .layout-1col section.page {
+ background: var(--bg-color);
+ border-radius: .5em;
+ padding: 1em;
+ }
+
+ header.main h1 a img {
+ max-height: 300px;
+ }
+
+ .layout-2col header.main, .layout-1col header.main {
+ grid-area: header;
+ display: grid;
+ grid-gap: 1em;
+ grid-template-columns: 1.4fr 0.6fr;
+ }
+
+ nav.main { grid-area: nav; }
+ section.page { grid-area: content; }
+
+ header.main h1, header.main .contacts, nav.main {
+ background: var(--bg-color);
+ padding: 1em;
+ border-radius: .5em
+ }
+
+ .layout-2col header.main .contacts {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ header.main h1 {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ padding: 0;
+ }
+
+ .layout-wide header.main h1, .layout-1col header.main h1 {
+ font-size: 1.2em;
+ }
+
+ .layout-2col section.page article {
+ margin-bottom: 1em;
+ padding: 1em;
+ background: var(--bg-color);
+ clear: both;
+ border-radius: .5em;
+ }
+
+ .layout-wide .breadcrumbs {
+ margin: 1em 0;
+ text-align: left;
+ }
+
+ .layout-wide section.articles article, .layout-1col section.articles article {
+ margin: 1em 0;
+ padding-bottom: 1em;
+ border-bottom: 2px solid var(--gray-text-color);
+ }
+
+ .layout-wide section.page > h1 {
+ text-align: left;
+ }
+
+ .layout-1col nav.main {
+ padding: 0;
+ background: none;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .layout-1col nav.main .booking-btn {
+ margin: 0;
+ }
+
+ .layout-1col .search-widget p {
+ max-width: 20em;
+ margin: .5rem;
+ }
+
+ .layout-1col nav.main .booking-btn a {
+ border-radius: .5em;
+ padding: .2rem .5rem;
+ }
+
+ .layout-1col nav.main .subcategories {
+ margin: .5rem;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: .5rem;
+ }
+
+ .layout-1col nav.main .subcategories li a, .layout-1col nav.main .subcategories li:first-child a {
+ border: none;
+ }
+
+ .layout-1col nav.main .subcategories li a {
+ background: hsl(var(--first-color), 50%, 95%);
+ border-radius: .5rem;
+ }
+
+ .layout-1col nav.main .subcategories li a:hover {
+ background: var(--bg-color);
+ }
+
+ .layout-1col nav.main .opening-hours {
+ display: none;
+ }
+}
+
+
+/* CORPS */
+body {
+ font-size: 100.01%;
+ font-family: "Trebuchet MS", Helvetica, Sans-serif;
+ background: #eee;
+ background: hsl(var(--first-color), 50%, 90%);
+ background: linear-gradient(200deg, hsl(var(--first-color), 50%, 90%) 5%, hsl(var(--second-color), 30%, 90%) 30%);
+ min-height: 100vh;
+ color: var(--text-color);
+}
+
+body.bg-white {
+ background: #fff;
+ --bg-color: hsl(var(--first-color), 50%, 95%);
+}
+
+body.bg-dark {
+ background: linear-gradient(-200deg, #333 10%, #000 30%, #333 70%);
+ --bg-color: hsl(var(--first-color), 50%, 10%);
+ --text-color: #fff;
+ --link-color: #99f;
+ --visited-link-color: #c9c;
+ --hover-link-color: #c99;
+ --gray-text-color: #999;
+}
+
+body.bg-dark header.main h1 a span {
+ color: var(--text-color);
+}
+
+body.bg-dark header.main h1 a:hover span {
+ color: var(--hover-link-color);
+}
+
+body.bg-white_gradient {
+ background: linear-gradient(-200deg, hsl(var(--first-color), 50%, 90%) 5%, #fff 30%);
+}
+
+body.bg-gray {
+ background: #fff;
+ --bg-color: #eee;
+}
+
+a {
+ color: var(--link-color);
+}
+
+a:visited {
+ color: var(--visited-link-color);
+}
+
+a:hover {
+ color: var(--hover-link-color);
+}
+
+/* ENTÊTE AVEC LOGO ET CONTACTS */
+header.main h1 {
+ padding: 0;
+ background: none;
+ border-radius: 0;
+}
+
+header.main h1 a {
+ display: flex;
+ padding: 1rem;
+ background: var(--bg-color);
+ border-bottom-left-radius: .5rem;
+ border-bottom-right-radius: .5rem;
+ text-decoration: none;
+ height: calc(100% - 2rem);
+ align-items: center;
+ justify-content: center;
+}
+
+header.main h1 a img {
+ max-width: 100%;
+}
+
+header.main h1 a span {
+ font-size: 1.5em;
+ font-weight: normal;
+ color: hsl(var(--second-color), 30%, 30%);
+ display: block;
+}
+
+header.main h1 a:hover span {
+ color: hsl(var(--second-color), 50%, 50%);
+}
+
+header.main h1 a:hover img {
+ opacity: 0.8;
+}
+
+header.main.home h1 a span {
+ font-size: 2em;
+}
+
+header.main .contacts {
+ font-size: 1.3em;
+ display: flex;
+ justify-content: flex-end;
+ flex-direction: column;
+}
+
+header.main .contacts .map {
+ color: inherit;
+ text-decoration: none;
+}
+
+header.main .contacts .map svg {
+ fill: hsl(var(--second-color), 60%, 40%);
+ background: hsl(var(--first-color), 50%, 90%);
+ padding: .25em;
+ width: 1.2em;
+ height: 1.2em;
+ border-radius: 50%;
+ float: right;
+ transition: fill .2s, background .2s;
+}
+
+header.main .contacts .map:hover svg {
+ fill: hsl(var(--second-color), 50%, 50%);
+ background: hsl(var(--first-color), 70%, 95%);
+}
+
+/* NAVIGATION EN HAUT DE LA PAGE (ACCUEIL/CONNEXION) */
+header.nav {
+ background: #ddd;
+ border-bottom: 1px solid var(--gray-text-color);
+}
+
+header.nav ul {
+ display: flex;
+ justify-content: center;
+ padding-top: .2em;
+}
+
+header.nav li a {
+ padding: .1em .5em;
+ color: #666;
+ text-decoration: none;
+ font-size: .9em;
+ margin: 0 1em;
+}
+
+header.nav li.current a {
+ background: var(--bg-color);
+ border-radius: .3em .3em 0 0;
+}
+
+header.nav li a:hover {
+ color: var(--text-color);
+}
+
+/* LISTE DES CATÉGORIES RACINES */
+section.page .subcategories {
+ text-align: center;
+ margin: 2em 0;
+}
+
+.subcategories li {
+ font-size: 1.2em;
+ margin: .8em 0;
+ padding: 0;
+}
+
+.subcategories li a {
+ margin: 0;
+ padding: .2em .5em;
+ color: var(--link-color);
+ text-decoration: underline;
+ background: #ddd;
+ background: hsl(var(--first-color), 60%, 80%);
+ border-radius: .5rem;
+ display: inline-block;
+ transition: background-color .2s, color .2s;
+}
+
+nav.main .subcategories {
+ margin: 0 -1em 1em -1em;
+}
+
+nav.main .subcategories li {
+ margin: 0;
+}
+
+nav.main .subcategories li a {
+ padding: .5em 1em;
+ display: block;
+ border-radius: 0;
+ border-bottom: 2px solid hsl(var(--first-color), 50%, 85%);
+ background: none;
+}
+
+nav.main .subcategories li:first-child a {
+ border-top: 2px solid hsl(var(--first-color), 50%, 85%);
+}
+
+section.page .subcategories li a {
+ background: var(--bg-color);
+}
+
+section.page .subcategories li a:hover, nav.main .subcategories li a:hover {
+ color: var(--hover-link-color);
+ background-color: #eee;
+ background-color: hsl(var(--second-color), 50%, 90%);
+}
+
+/* Formulaire de recherche */
+::target-text {
+ background: #ff0;
+ padding: .2em;
+}
+
+.search-widget p {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ margin-bottom: 1em;
+ height: 2em;
+}
+
+.search-widget input {
+ font-size: 1.2em;
+ padding: .2em .5em;
+ border: 1px solid #999;
+ border-radius: .3rem;
+ line-height: 1rem;
+ width: 100%;
+ height: 100%;
+}
+
+.search-widget input[type=submit] {
+ color: #fff;
+ border-radius: 0 .5rem .5rem 0;
+ border-left: none;
+ cursor: pointer;
+ text-indent: -70em;
+ overflow: hidden;
+ background: #ccc no-repeat center center;
+ width: 1.5em;
+ height: 100%;
+ margin-left: -.4rem;
+ background-image: url('data:image/svg+xml;utf8, ');
+}
+
+/* PIED DE PAGE */
+footer.main {
+ color: var(--grey-text-color);
+ margin: 1em;
+ text-align: center;
+}
+
+footer.main a {
+ text-decoration: none;
+ font-weight: bold;
+}
+
+footer.main a:hover {
+ text-decoration: underline;
+}
+
+footer.main a#paheko {
+ padding-left: 36px;
+ background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAWBAMAAABTd4N3AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAADBQTFRFBUqmR3BMAwl4KZDEBD2dATCVBy+TJU6mC2y1BCWU/v//m7jbUXC3c53NvtPp3ur1QHGW/AAAAAh0Uk5T/gD6+WqzLO5BK691AAABR0lEQVQY02MQhAKRcIbyECDNAOMrMXR0dLTABcSCJnAABTjLYAIOmkB+swUDO1QglXNG8yogWHcgECwgwjCT23jXqvX/V61hY3BJFHPVnFnAd6N/1QOGU+tZGCJncirNPHmu7wfvqscW3avaGTKBApUPTp1e27HqRUfvqgMMohNqzux4W7VnNcevFx28qxoYRCZVnr59ftbn/x2vXnT0r2dgEFSaW8MzV+dAb8erv7d/rWFnEHTS3HOaW/vl645XQIfcYGUQFFVg+LOvau9q41+/d99gYGEQFNNk2LTmVv1i41UvOGfOnAZ0adLMOTdfn3psu+oFw8yZIKeLTZqj9GDX5b5VDzo4ZzqC/JKkpDTztYUV0GEdrRDfBikpXQBa8qJhgiFEQCxy5kyGu3cvW7DCQkwkknMCR7OxsSMiTFNCmY1NEoEMABmgiBzVXBbdAAAAAElFTkSuQmCC") no-repeat left center;
+ min-height: 22px;
+ display: inline-block;
+}
+
+/* CHEMIN VERS L'ARTICLE (BREADCRUMBS), affiche les catégories parentes */
+.breadcrumbs {
+ margin-bottom: 1em;
+ text-align: center;
+}
+
+.breadcrumbs ul {
+ margin: 0;
+}
+
+.breadcrumbs ul li {
+ display: inline-block;
+}
+
+.breadcrumbs ul li::before {
+ content: "»";
+ color: var(--gray-text-color);
+ margin: .5em;
+}
+
+.breadcrumbs ul li:nth-child(1)::before {
+ display: none;
+}
+
+.breadcrumbs a {
+ color: var(--gray-text-color);
+}
+
+
+/* MESSAGES ALERTE ET ERREUR (par exemple : page non trouvée) */
+.error {
+ border-bottom: .2em solid #c00;
+ border-radius: .5em;
+ background: #fcc;
+ padding: .5em;
+ margin-bottom: 1em;
+ font-size: 1.2em;
+ color: #900;
+}
+
+.alert {
+ border-bottom: .2em solid #cc0;
+ border-radius: .5em;
+ background: #ffc;
+ padding: .5em;
+ margin-bottom: 1em;
+ font-size: 1.2em;
+ color: #660;
+}
+
+/* AFFICHAGE D'UN ARTICLE */
+section.articles article h3, section.articles article h1 {
+ margin-bottom: .3em;
+}
+
+section.articles article::after, article.single::after {
+ content: "";
+ display: block;
+ height: 0px;
+ clear: both;
+}
+
+section.articles article h1 a {
+ color: var(--text-color);
+ text-decoration: none;
+ font-weight: normal;
+}
+
+section.articles article h3 a {
+ color: var(--link-color);
+ font-weight: normal;
+}
+
+section.articles article h3 a:visited {
+ color: var(--visited-link-color);
+}
+
+section.articles article h5 {
+ color: #666;
+ font-weight: normal;
+ font-size: .8em;
+ margin-bottom: .3em;
+}
+
+section.page > h1 {
+ text-align: center;
+ margin-bottom: 1em;
+}
+
+/* CONTENU DE L'ARTICLE */
+article > h4 {
+ margin-bottom: 1em;
+ color: var(--gray-text-color);
+ font-weight: normal;
+}
+
+article ul, article ol, article blockquote {
+ margin-left: 2em;
+}
+
+article ul {
+ list-style-type: disc;
+}
+
+article ol {
+ list-style-type: decimal;
+}
+
+article dl dd {
+ margin: .5em 0 .5em 2em;
+}
+
+article img {
+ max-width: 100%;
+}
+
+article figure {
+ text-align: center;
+}
+
+article figcaption {
+ font-style: italic;
+ color: #666;
+ margin-top: 2pt;
+ font-size: .8em;
+}
+
+article > h1 {
+ margin-bottom: 1rem;
+}
+
+/* GALERIE D'IMAGES EN DESSOUS DE L'ARTICLE */
+section.gallery, section.documents {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: stretch;
+ gap: 1rem;
+ margin: 1.5rem 0;
+}
+
+section.gallery figure, section.documents figure {
+ max-width: 12rem;
+ margin: 0;
+ padding: 0;
+}
+
+section.gallery figure a, section.documents figure a {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ flex-direction: column;
+ height: 100%;
+}
+
+section.gallery figure a img, section.documents figure a img {
+ box-shadow: 0 0 5px 1px #999;
+ border-radius: .25em;
+ max-height: 150px;
+ background: #fff;
+ margin: .5rem;
+}
+
+section.documents figure a {
+ text-decoration: none;
+ border-radius: .5em;
+ background: hsl(var(--first-color), 50%, 90%);
+}
+
+section.documents figure a figcaption {
+ background: no-repeat bottom right;
+ margin: .5rem;
+ border-radius: .5em;
+ display: block;
+ font-size: 1em;
+ font-style: normal;
+ color: #666;
+ transition: background-color .2s, color .2s, box-shadow .2s;
+}
+
+section.documents figure a:hover {
+ background-color: #eee;
+ color: #333;
+ box-shadow: 0px 0px 5px hsl(var(--first-color), 50%, 50%);
+}
+
+section.documents aside a:hover figcaption b {
+ color: darkred;
+}
+
+section.documents figcaption b {
+ display: block;
+ font-size: 1.1em;
+ text-decoration: underline;
+ color: darkblue;
+}
+
+section.documents figcaption span {
+ font-size: .8em;
+}
+
+/* FORMULAIRES */
+fieldset {
+ border: 2px solid var(--gray-text-color);
+ border-radius: .5em;
+ padding: 1em;
+ margin: 1em;
+}
+
+fieldset legend {
+ padding: 0 1em;
+}
+
+fieldset input, fieldset textarea, fieldset select {
+ padding: .5rem;
+ border: 1px solid #999;
+ border-radius: .3rem;
+ font-size: 1.2em;
+}
+
+fieldset input[type=submit] {
+ background: #999;
+ color: #fff;
+ cursor: pointer;
+}
+
+input:focus, textarea:focus, select:focus, button:focus {
+ outline: none;
+ box-shadow: 0px 0px 5px 2px orange;
+}
+
+fieldset dl dd {
+ margin: .5em 1em;
+}
+
+aside.admin {
+ float: right;
+ text-align: right;
+ position: fixed;
+ right: 1rem;
+ top: .5rem;
+}
+
+aside.admin a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ background: #700;
+ color: white;
+ border-radius: 1rem;
+ padding: .2rem .8rem;
+ position: relative;
+ border: 2px solid #eee;
+}
+
+aside.admin a[data-icon] span {
+ font-size: 1rem;
+ margin-left: .5rem;
+}
+
+aside.admin a[data-icon]::before {
+ content: attr(data-icon);
+ font-size: 2rem;
+ line-height: .5em;
+ display: inline-block;
+ text-align: center;
+}
+
+aside.admin a[data-icon]:hover span {
+ text-decoration: underline;
+}
+
+
+/** Encart module réservations */
+.booking-btn {
+ padding: 0;
+ margin: -1em;
+ margin-bottom: 1em;
+}
+
+.booking-btn a {
+ display: flex;
+ background: hsl(var(--second-color), 35%, 25%);
+ border-radius: .5rem .5rem 0 0;
+ color: #fff;
+ font-size: 1.3em;
+ padding: .5rem;
+ justify-content: center;
+ align-items: center;
+ transition: box-shadow .25s, color .25s, background .25s;
+}
+
+.booking-btn a b {
+ font-weight: normal;
+}
+
+.booking-btn a span {
+ background: no-repeat .5rem center;
+ background-image: url('data:image/svg+xml;utf8, ');
+ width: 2em;
+ height: 2em;
+ display: block;
+}
+
+.booking-btn a:hover {
+ box-shadow: 0px 0px 5px 2px orange;
+ color: #fcc;
+ background: #966;
+}
+
+/** Encart module horaires d'ouverture */
+.opening-next h3 {
+ text-align: center;
+ font-size: 1.3em;
+}
+
+section.page article.opening-next {
+ background: var(--bg-color);
+ background: hsl(var(--second-color), 30%, 35%);
+ border-radius: .3em;
+ color: #fff;
+ box-shadow: 0px 0px 5px #000;
+ margin-bottom: .5em;
+ padding: .5rem;
+}
+
+section.page article.opening-hours, article.opening-hours {
+ display: flex;
+ justify-content: space-between;
+ background: none;
+ padding: 0;
+ gap: .5rem;
+}
+
+article.opening-hours table th, article.opening-hours table td {
+ padding: .2rem .4rem;
+}
+
+article.opening-hours table {
+ width: 100%;
+}
+
+section.page article.opening-hours table {
+ text-align: left;
+ white-space: nowrap;
+}
+
+article.opening-hours .list {
+ background: hsl(var(--first-color), 50%, 92%);
+ padding: .5rem;
+ border-radius: .5em;
+ color: #000;
+}
+
+article.opening-hours em {
+ display: block;
+}
+
+article.opening-hours ul {
+ margin-left: 1.5rem;
+}
+
+article.opening-hours h4 {
+ color: #666;
+}
+
+nav.main article.opening-hours {
+ flex-wrap: wrap;
+ font-size: .9em;
+}
+
+nav.main article.opening-hours div {
+ width: 100%;
+ text-align: left;
+}
+
+nav.main article.opening-hours h4 {
+ text-align: left;
+}
+
+nav.main article.opening-hours table tr {
+ display: flex;
+ flex-direction: column;
+}
+
+/* Modifications du style pour les petits écrans */
+@media handheld, screen and (max-width: 980px) {
+ body {
+ padding: 0;
+ }
+
+ main, header.main {
+ display: block;
+ }
+
+ header.nav {
+ font-size: .9em;
+ margin: 0;
+ }
+
+ header.main h1 a, header.main .contacts {
+ border-radius: 0;
+ margin: .2em 0;
+ padding: 0;
+ }
+
+ header.main {
+ background: var(--bg-color);
+ padding: 1em;
+ }
+
+
+ header.main .contacts * {
+ text-align: center;
+ font-size: .8em;
+ }
+
+ header.main h1 a img {
+ max-height: 200px;
+ max-width: 100%;
+ }
+
+ .search-widget p {
+ display: block;
+ text-align: center;
+ }
+
+ .search-widget input {
+ font-size: 1em;
+ }
+
+ nav.main ul, .subcategories {
+ text-align: center;
+ }
+
+ nav.main ul li, .subcategories li {
+ font-size: 1.2em;
+ display: inline-block;
+ margin: .3em;
+ }
+ nav.main ul li a, .subcategories li a {
+ background: var(--bg-color);
+ }
+
+ .breadcrumbs {
+ display: none;
+ }
+
+ nav.main {
+ font-size: 1em;
+ background: none;
+ }
+
+ section.page article {
+ border-radius: 0;
+ }
+
+ section.page h1 { font-size: 1.5em; }
+ section.page h2 { font-size: 1.3em; }
+ section.page h3 { font-size: 1.2em; }
+ section.page h4 { font-size: 1em; }
+ section.page h5 { font-size: .9em; }
+ section.page h6 { font-size: .8em; }
+
+ footer.main {
+ font-size: .8em;
+ }
+
+ .booking-btn {
+ margin: 0;
+ }
+
+ .booking-btn a {
+ border-radius: 0;
+ }
+
+ .search-widget {
+ margin: .5em;
+ }
+
+ nav.main .subcategories {
+ margin: 1em 0;
+ }
+
+ nav.main .subcategories li a {
+ border: none !important;
+ background: rgba(255, 255, 255, 0.7);
+ margin: .2em;
+ border-radius: .5em;
+ }
+
+ .search-widget input {
+ width: 80%;
+ }
+
+ nav.main .opening-hours {
+ display: none;
+ }
+
+ section.page article.opening-next {
+ border-radius: 0;
+ }
+
+ section.page article.opening-hours, article.opening-hours {
+ flex-direction: column;
+ margin: .8em 0;
+ }
+
+ .articles article, article.single {
+ margin: .5rem 0;
+ background: var(--bg-color);
+ padding: .5em;
+ }
+}
diff --git a/src/modules/web/documents.html b/src/modules/web/documents.html
new file mode 100644
index 0000000..c6b491a
--- /dev/null
+++ b/src/modules/web/documents.html
@@ -0,0 +1,15 @@
+
diff --git a/src/modules/web/email.html b/src/modules/web/email.html
new file mode 100644
index 0000000..1d2a5c1
--- /dev/null
+++ b/src/modules/web/email.html
@@ -0,0 +1,76 @@
+{{* Ce squelette est utilisé pour l'envoi d'un e-mail au format HTML *}}
+
+
+
+
+
+
+
+
+
+{{* Le contenu du mail est dans la variable $html, ne pas supprimer sinon le message sera vide ! *}}
+{{$html|raw}}
+
+{{* D'autres variables sont disponibles, permettant de personnaliser le message :
+ - $recipient contient l'adresse email du destinataire
+ - $data contient les données disponibles pour le message (par exemple $data.nom contiendra le nom du membre dans un message collectif)
+ - $context contient le contexte du message (0 = changement de mot de passe, 1 = message privé entre membres, 2 = message collectif)
+ - $from contient l'expéditeur (si NULL, l'expéditeur sera l'association)
+*}}
+
+
+
+
+
+{{* Le lien de désinscription sera ajouté automatiquement en bas du message, il n'est pas possible de le modifier ou le supprimer. *}}
+
+
diff --git a/src/modules/web/gallery.html b/src/modules/web/gallery.html
new file mode 100644
index 0000000..3fbe1b5
--- /dev/null
+++ b/src/modules/web/gallery.html
@@ -0,0 +1,7 @@
+
+{{#images order="name" parent=$parent except_in_text=true}}
+
+
+
+{{/images}}
+
diff --git a/src/modules/web/icon.svg b/src/modules/web/icon.svg
new file mode 100644
index 0000000..d611933
--- /dev/null
+++ b/src/modules/web/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/modules/web/index.html b/src/modules/web/index.html
new file mode 100644
index 0000000..4078e05
--- /dev/null
+++ b/src/modules/web/index.html
@@ -0,0 +1,33 @@
+{{:include file="./_head.html" home=true}}
+
+{{#module name="openings"}}
+
+ {{:include file="/openings/_next_opening.html"}}
+
+
+ {{:include file="/openings/_all.html"}}
+
+{{/module}}
+
+
+{{#articles order="published DESC" limit=1}}
+
+
+
+ {{ $published|relative_date }}
+ {{ $html|raw }}
+
+
+{{/articles}}
+
+{{#articles order="published DESC" begin=1 limit=9}}
+
+
+
+ {{ $published|relative_date }}
+ {{ $html|raw|strip_tags|truncate:200 }}
+
+
+{{/articles}}
+
+{{:include file="./_foot.html"}}
\ No newline at end of file
diff --git a/src/modules/web/module.ini b/src/modules/web/module.ini
new file mode 100644
index 0000000..93c336f
--- /dev/null
+++ b/src/modules/web/module.ini
@@ -0,0 +1,5 @@
+name="Site web - Thème par défaut"
+description="Thème à deux colonnes simple (2022)"
+author="Paheko"
+author_url="https://paheko.cloud/"
+web=true
diff --git a/src/modules/web/print.css b/src/modules/web/print.css
new file mode 100644
index 0000000..c1dadea
--- /dev/null
+++ b/src/modules/web/print.css
@@ -0,0 +1,19 @@
+body {
+ font-family: Georgia, Times New Roman, serif;
+}
+
+header.main, footer.main, nav.main, header.nav, .breadcrumbs {
+ display: none;
+}
+
+article.single > h1 {
+ font-size: 1.7em;
+}
+
+article.single > h4 {
+ color: #666;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-family: Verdana, Arial, sans-serif;
+}
\ No newline at end of file
diff --git a/src/modules/web/robots.txt b/src/modules/web/robots.txt
new file mode 100644
index 0000000..f0f875e
--- /dev/null
+++ b/src/modules/web/robots.txt
@@ -0,0 +1,7 @@
+User-agent: *
+Disallow: /admin/
+
+Sitemap: {{$root_url}}sitemap.xml
+
+User-agent: GPTBot
+Disallow: /
diff --git a/src/modules/web/search.html b/src/modules/web/search.html
new file mode 100644
index 0000000..4370898
--- /dev/null
+++ b/src/modules/web/search.html
@@ -0,0 +1,25 @@
+{{:include file="./_head.html" title="Recherche"}}
+
+
+ {{#pages search=$_GET.search future=false count=true}}
+ {{if !$count}}
+ Aucun résultat trouvé.
+ {{else}}
+ {{if $count == 1}}1 résultat trouvé{{else}}{{$count}} résultats trouvés{{/if}}.
+
+ {{#pages search=$_GET.search future=false}}
+
+
+ {{$published|date_long}}
+ {{$snippet|raw}}
+
+ {{else}}
+
+ Aucun résultat trouvé.
+
+ {{/pages}}
+ {{/if}}
+ {{/pages}}
+
+
+{{:include file="./_foot.html"}}
\ No newline at end of file
diff --git a/src/modules/web/sitemap.xml b/src/modules/web/sitemap.xml
new file mode 100644
index 0000000..47662e3
--- /dev/null
+++ b/src/modules/web/sitemap.xml
@@ -0,0 +1,16 @@
+
+
+{{#pages limit=1 order="modified DESC"}}
+
+ {{$root_url}}
+ {{$modified|atom_date}}
+
+{{/pages}}
+
+{{#pages limit=10000}}
+
+ {{$url}}
+ {{$modified|atom_date}}
+
+{{/pages}}
+
diff --git a/src/pubkey.asc b/src/pubkey.asc
new file mode 100644
index 0000000..d183bd0
--- /dev/null
+++ b/src/pubkey.asc
@@ -0,0 +1,51 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBGPbkSYBEADJ6b2bY8Uva2JSdt/fsjhY0ZD/BEeD9ersNK1OOcZVyqI9Z+hR
+J2BIvbEyCiPxmG2+A7/KVCOJrrgt2dmw1soXS4ePepYoiq36Oqjp/Nn2kVMOF6e1
+XeS+O/KVIfLMLZOFItlGFsMOdZRLMzjbI3aUTsKPuIpdJ6A5NEodBZvfGHdXzV03
+sGEw2T9uPi3AqR+ioKxyDmTvcAWiI9NsZXDhN0mRg/IQoAs9pkHhkbUV1BmxNOFi
+uuaDbptH3jdYhiQb1E7BJJU5CNhr9Zv1F7PD0Gr4Q5OgoDnk/MZKv4MVsl+zP444
+bHbuiAzfWkm7QbaM+NF+NCcb7ujULJH/Ujgbascatc5dNDF51cA4BDZekjeOUI7Z
+DIsg1UtkB8d3VRKlw+J0Lt9ZyH7zwKB7Jzk6Gbn1/YSBnVWq0SZmomqiUVeBWBv7
+gogFsbUkD35mafdBVdkRV4Yce9nrmDwog+5d7jriOKbYQ0MmSwcBeHbHnEGt72kx
+Eov8YlzssdqNDTLZUFix5I0LZHAaNT8LmjvkVuyz18J8EDq+x150e4ThP4orhAkB
+c/Of3B8OmYlG0fgM3zeawbvE16gnQ1InH2AGNLxBghizjSgHRDOo5gzUDWjXlGN3
+/z2/7+yLSiUEUskz4gCVLuOFhXrCRyGcSpUyBA/nGlfJ/tC3HQTyqHDFrwARAQAB
+tBlQYWhla28gPGRldkBwYWhla28uY2xvdWQ+iQJOBBMBCgA4FiEE2gznv2g1PQbF
+cEHuJiyrIeahkikFAmPbkSYCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQ
+JiyrIeahkikITA/+Lt+0Qwu7qIrvJ7Pgt5EgC3QGGSwlNl9lGErSuOQ0jpNExwyB
+QhQRHA8lnidrFlRX5FMg6IeyEoyn7NXjY7dzA5lkeNn5mWePl4pkLPz7XXS7Pwqd
+gyr5pc7Y9BpTjPsp2izZr5sbBlzYdFDXaMo69DdZHLILTI0LK0qALfBDcqQn4e/Y
+/82m5nBqsmoGYup2HnTctBjgAamjhJMyGQ/JXDjBc6PV2O+Ew7n9kp8dwbtY4UpM
+Oy8Gs4dzk4Ra/t6O4Zo0MporLZ6zulyWRVgvU4dVbtJFNJEKa4DXMxUOklilFX//
+uu665gEAS+F6f/Cb6OFHM7IxdLp4ng6z4A+jVxj4vXDgq5CP1F/L8EUqtl2kcXdU
+IQuzba7s8pVDM8ujLwR/s8VbgG1w+xYbZtqkEqpoAIIezvXmx2gnnuCAakw+JaP9
+QSHmmD/5MW/GVXPw3L2+cgmI5axmkPP02A9BSlMcdV82Od5s2Nnat2AAkqPbjK3u
+/TTFRAumSTv51ezBTRldSxgYHEoWQBdvv7i+SnZniffYdvxTKklIhK2xuXY1Bzku
+5Dk9SR0oQ1DvQA+TFK5XFhaWl1iuuzoTD4TW6r5NLqEHRUdxHpe8QTAkQi4hhKyb
+7nY2IgjREqnrLvdfm9KSWjrV26Fe04vcRSYbzcvr6EdDwGmT7PNbOx9gt+a5Ag0E
+Y9uRJgEQAKNek9274WllUatGVThIjZvpYtpu5Q53TEZozYTRp4WLJDNtH5W7D0vK
+8icr9e7CUILIQKhL6peugJXLj1HXok2vuK0ITWLCseApuXyvdzX8+5Z3QC1gzC6z
+AjA0r7Kc6cPg+nUL7SHSKuTY1+LdadC4AYYTFN9hq+QcEBtx+dunTxdjjsyu4aJc
+h2g8565mRiccRY1LSgYpmM8Blypj+WjJ1KF7v+JNhvfiAoadPnVvUODMq1pdHW4E
+ucIP7Supw+qIVlRmaK+STDPEDU8diy7YMiC+dvxJLV1Yl91qLx9EK5RcvkcUUlvD
+w3ESotzEJSiRF1Rdv1bUAtIVi7ZJGYfqr2PFOnGLWJRWYxwFXpv9ADk1Onnu33T2
+tybsVUN+zVHdo3q7vDbSYXbaeeFg4voaJuR9kn3nDKS5Lidh2HQiUWphVz0vScRD
+a//nEYwwsu93aTPK/gW5BxAp/LLEjQgjXSH3DK3FNksy3gyf5zdg8WqzPqWocXGR
+P6UqChW1ujXQ8dNDRH8arqz5q0kgoFgsY90Hf4aIB0HmCdlsPK8BtTMQUS4oZLNA
+G6l/52PnOvkhodYzvWShS4e8Uo6GH8b/mmo0Z7qzlkpuBScYDx02PirHOqrto842
+xUvlk252n4ZCc5zNLa1Zv93afpt+1abihki/i4nPherUtGkoCby5ABEBAAGJAjYE
+GAEKACAWIQTaDOe/aDU9BsVwQe4mLKsh5qGSKQUCY9uRJgIbDAAKCRAmLKsh5qGS
+KZUPD/9CPZzOvWQCuJlPiDbENZRbLMudShseDlfddkDmAL/9mTprc7j0WWxnMVgC
+mObW6t/wiOP4ARw5/KCr3xrZ/O7aO7Fn98WxyTYdEr0gmE8m+nlalHuIDnfktp9O
+qyCZ2qjQaGKY0fJLUkyDCJRHa4jOST56LdpH/FxjAcJcP1MTJssy0LxgB2e+FUGy
+JdT3+4jUMBO1NBiM84LaV47tygEYdVbO0KP/uRHK3cKGLhGwMT/LdOLc2nmxfoty
+ZkM5nP0gxVizQOrXlDOEqRiZ/GyG8TZD91URzReZ8ssALbD+HQiuCllvodxqWiW8
+ZsWi0/6Ht5mb1t4m1+Wy+Gukdh/a6/n/W4/ajWIOpUxN71e0wd+jqEll424DrLD6
+t/M2rtz6BKHum2rSlIu9UOHDXmXKjEpz2XJP8kJsJc5AdOQS9gLn3aiNoyLgDAuf
+RlSHkj9nx7XmxNR7m7UKap7Glp9RrHo4PuCczQYbgAWfoeoESBq6bqGS/4vl5c8E
+FYB7uQ5YA71prEdDmuDsEq0tI7RkM68d4KqLM4Ag3OWVIDpDGhTtjizCe908o7zl
+uhpLMHCE963KKx+zL7ktThmpHz8V8/sqfbDylmSC2suaJhJCi+i5RakThDdw5DaS
+5yJfKGPwOjx8m2A6POwV4CGnhAcE9VXzlUCP2XU73JVcMJ3QuA==
+=4SMJ
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/src/pubkey_old.asc b/src/pubkey_old.asc
new file mode 100644
index 0000000..d367665
--- /dev/null
+++ b/src/pubkey_old.asc
@@ -0,0 +1,140 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBEzkiQIBEACyW7c1IJXTxln18VSEXZJITqU4GHhS0lqOVxKTqQGmd6BIscu/
+8NyDPdYpNhz3eQ090cCAk2dxxgj5rmLM21Wj8cwYCXLNOsZRGeSufyqgGYWdG5DY
+7rJyUFfjwy+xPqoeqcy/W9aIcH1X1k+DfAsgsqhwM97lk6tEg4Zn0eKmWGjcKzla
+pJ66Wdv+oRD6T01X7XJ/QQghxQmqupt1u1xbWEOXAaO6tSVEbpUDaODq0YsV3J+9
+owX8gJb2B7UkegXcIaEsuzXsZmlWIL1COyElcTtxh4n+Wtu1dImn0Hv2oprzJ9ub
+B3/zEmhNl2+dTjurfrQYt/f4LXuDB32AZlpB2SR8H2dvTwsd+RUuUMsv/nj4lAyl
+Vovny1ihilIxZcxYSMadXYvOR7GLmDi2LSsasVJ8fpOQDhYZam7AyOs4lZ0gT4AC
+rqVdQGvlEtWrkQGTGmyuMsyi4IUC4aMFJkxmS9f8Dy1Ogu0KN9nAak6PJEs483oQ
+o8ip59dTAo55b7OVPUEeX6g68cIiHptMa8pV9RY16sP1RStqKj3XU3+pErEwqUk3
+5HpXNOuO7ALFW29GEq/mjQ12FuTRawtPv6BBPtMRNFakx0V2iX78D5cDDUfze0k9
+iVkcM+ZtXnmA/wbQzyVev/isWrrLG2+/iatxd4bQPSOddh1GDPn+sQIxGwARAQAB
+tBpCb2h3YVogPGJvaHdhekBib2h3YXoubmV0PokCWAQTAQoAQgIbAwYLCQgHAwIG
+FQgCCQoLBBYCAwECHgECF4ACGQEWIQTEmSR5ui6KXULBggGSydpxuIjqNAUCYAgA
+HQUJFuXeGwAKCRCSydpxuIjqNGQTD/wJLpxo+vpOFrKEayvM8LPx6hMBQpbDJPed
+Imm8vSE/rviOFy7TLQluJSCAm7LmZvBpt/BjI7x/0XQQ6TLbcFgPxIGAcLKTfjNR
+CsYqEUYkQwCcNaeNWaIvtrU3j9N98kHqVWWK5Puvhb7Kn7WGxOpvZsww39u/fLV4
+qlsIGbij2osTEon6C9Wg8vOo66y1YEBE/YCrOAYfwbUL/gmIn+dre1LYRRPExmOb
+/iMpkiCAEb5FEaF7dM/c/+J1U1+7KxjTKMdSPhNYzp4DzPHH8QCv40SoxigU9Bx9
+u/HEElXO4iTnlvwFxRra61Aujfs8YtbtbOs0piOnVd2IxzaQpf86vY8/BFhVRJ/V
+mFjocXIIb3GEbXz87s4Mg36O67a1hJcw1vh4BYS72nU9x3C4A+sgXF1wI/4/Z5in
+NXSkILLSfye1dbO6q7Fz155StWSlbWDQLkZUM8Q0NUYYqj6iWkgxamOisQaXbXDG
+XAaSbi577a0H6TB6vKji7xtZesVuDrgke3vdaiiU4JNPVaHLAOHxqJ6xzL8b1yVh
+j/m9bU7dl0JIIlliJmGXLnCzlafoZDi6nSprRIoouLux7hksewo/Tc50hLRPXOXv
+r3jULYm4ycs99JVzKdwD3VmOHUQVcCUEUoeV/9YgglJelS++IeXn+Ff5HbzaTH6V
+NXzVKwRJ6ohGBBARAgAGBQJM5IoSAAoJEIcBgRWB1GUopvMAn3mTVSM68dWRoSdM
+h+c30PhNGhUTAJ9A/gcuObRY4PUiqy3YRqtC7s8OGokCQQQTAQIAKwIbAwUJCWYB
+gAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AFAkzkieYCGQEACgkQksnacbiI6jQs
+DA/+K6RXTJ4Ha7n+02su1wOw9NO5FhHcY8kX48PTvjn8ea/AiLLnc3d3NmYzj3dC
+hMogGBoEEBfVQ3SbQkW+Lw1OrsBV88wPanvBPepobUVC1Oi9KARMaET3LRZg0GOH
+IfWNJIi7IZyIyuBUUdaPjIENjBQTp2HEaLe4ZtHmSlIeZAjTEIlJJnKUFUQ9uNB4
+awmtOP7VPwDU9kLFxFBLQHSCnBJoe3U40F4pv9CBUSyCY+bU5APcYFSvG6UTkD+m
+KX6kCMKdADLf75N6DwoRdm+EX9rf3S9YJNNQalfNFBWZy1DG5m4oTmxl/uZAII7C
+VN5c68n82GH/99YXQbH42TeU7j5A0HCDsmDM8D836UKpiBZU0E4JRjPhMzoelsMv
+IWPnzsPNNrf43llOszT6vhSZ007XH7/p3XxznvsWoU8dzo4d9GFB6E1vDEpa6XFf
+ptoFRJzfBXFlSrrvVqtDccG2Lnu+87tR/F9XPoQlRPPG98Akx//6DGNgKVeJTfkG
+wSf0Qk6dIkF7bthbHw93hzeDsa0DuqBDIZY+5W8/4nqztzT0e4i0KbrhNNeHSpB3
+pVamb3645JG4ZcQ7Ak6ISC//8A8WyyCXvL5SNlgoSBzeCZyFQB2JFmpohtVFpdrs
+MXbipmpBFJ2ZrnM8iksCQPcBmddHMug1HLjXvjVeol74UrWJAkEEEwECACsCGwMG
+CwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAhkBBQJP6l5zBQkMa9bmAAoJEJLJ2nG4
+iOo0edoP/2V3W11XDV7L0j6cxuzu8DR3WZiOD7NY9rIVmwR05CYEUlfx2DwOq1BL
+ORri11YXfYaQN8Kd3wYlyvjfanGCmvnovM5fRGAEf/k/ORuIeJG8e79zAtyfhq8W
+pCMq6jqzI7eUPhuMlwC52Ysg1/g9Iklkdh7gOpTCwWLENlI9q9eVmxtCDgLNUQC1
+z9xa5urBf/2q5agUsTPUBt0RjXQSy1RbSGxTA7tQ5zB59MHTOSkWDX1cX1VHbBXB
+wKqxYhPrUDJdMBjDeSCfbh60xIh5gniNV24MQPe64QGnIe3eb2Qwo1Y0KrjMS83+
+Y8KyMEo58O7rsGfsaInQqtWc35EMrRrOTXNRjqoRZNBeymnb1+3aRxFc+NJwO5jb
+QRAXUD3H9bZG5Mj4C7T3NJXuhYyBqA+mDfoPzlBKgPr3vUFL6vRevjcmV49vnXvr
+SFFaLAGGtkZLDt8YsvWXVlmfJYJNtQIa8PB+MkV4KYMfHRicFFeR0ms2LMmBJZBX
+ClY2u2LHtgjU/z2eYe1fEgJVV3woRj5UvQlzge46/TJOjyWsgqm3U4qtn1e6fXQp
+LAv65WVHOE0iapre/VBr2pVBAESTnxk0ioh1r4JQAb8GNuU1M77/14FtB8DsGRq3
+3oIW4EYLHD5DjyOnzC8WUCZcauC7HyoJ9r/FxSfkGytGPA/4n+PkiQJBBBMBAgAr
+AhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAIZAQUCWwXfRQUJE8TwvwAKCRCS
+ydpxuIjqNMEYD/9OpGPJ310KzYmD2uN4OQYwQX0kTiflJk5LZU0e6ho88leCZQvV
+0e+lUM4wV4BqZkqVLi/+2BOQ8nhW4jw8phxFAp9AC2z0YoYa/MuLGu4Du9h7iy0D
+06u0dWyNFbj+h0a0mfwmhbz/fiDQvLK+HIzViLv6kdCI20b8s2OHiTi97ALJAjAx
+cWPydGJb/oii3TvFkE2RUPSCRE/ulsa/Ximuwo66Pi8TiDkEJiErnKmqyRGSgEgr
+DJDXpiil9KKFltqGGdbxAxkshi8mKuojoOKCkV4oQftycC9lp2kK3Vlix6zL276A
+pSI7P6sQt0eCXVAdjGb09F3uTfkynK090PlQTTjZJSGKpNuBC3vKYEHj9ZeUezpH
+uNgXnjEPt3H7BOqk978wTi02OUZpu3McNubxf4IbwrsxKXfM1KYT6uNRStIgalMc
+WDkzmpu6z/QY+fJxmMVrqbZMMRHAKu62PoOtdnkVjlEWpRHh0oDxQx+mu9MhnO94
+NIXfg6UzYjum7t+vh7vZPjra8/1sBEUGHuxRo4mvK2SDTYgxSra55u5nXefjOvNN
+0IVeI512kAWj+02zl74FIfQwv+zsAW8Cyg71ym1VNoDOsKuhJJOF/Ydnv2yrmpp0
+oszNAUWEElssW1EUq7PsFUKVfPxIC8z/rrf8H7HqlXmhgPjIzoo4QWGr/rQXQm9o
+d2FaIDxib2h3YXpAa2QyLm9yZz6JAlUEEwEKAD8CGwMGCwkIBwMCBhUIAgkKCwQW
+AgMBAh4BAheAFiEExJkkebouil1CwYIBksnacbiI6jQFAmAIACIFCRbl3hsACgkQ
+ksnacbiI6jT52g//c98FZhfgtDtyUTFqZDVnLqYnD7XTZ4S1zn9M4KfXQEAkuMD3
+WaZxyGSnwnnaIjdhbUYW6mQlmzsdvZ6Rm0NFK5bewfjpNNKDlL754vMONqMubKZC
+u7Wr184myhgwhV0noAb2qPpxcMUYAIP8P2j6LVafNTKmdlrLPymrPdpI++6iOp7S
+7lYvcehVmIewbgfavGjFOgRHiKTC1KtLRN5CXxuRx6q1KEKmWc1m4s84qwAePN8/
+nirq4fftPqmR4+MBrWFCTvuz9AkSg+frnzxx4juOkkohIF101lm0r4k4qAa73oZP
+MajuZElV718Vew+88J3FpSXEHIoXw6qywHtcxlqn8LJA6SqRkNR1Q3BtIAvwT2L9
+vCrQwo2DAXQ7ciZYWS9QCgWYRuCkxQOO5aBpRQXgE+W4FmFgSLjbR3tCcOAtplZ6
+O/FwXdK9q1q0lARmO4Y/Vr1uWkzt1pR5M79fWRou8Cgodc0ZEVEQTvCNWZTIwAuk
+VIMO1funQvitwTlGnSwJzHlb6ZaDcnh5Ri8q1ptjfX3hC53BurLWzCQ15GjK5p+R
+m24YL+6Fc0zUCQGwp+iHG7olXYOJ2oGvJdVCXzuCe+xEA5SE2YNieoVEw2uGh0VJ
+i53/xRFV8y/AYZvczfWqADDmOrM2Z0aUrfZxySel+EJIH6rnV0284ygsDNKIRgQQ
+EQIABgUCTOSKEgAKCRCHAYEVgdRlKBnXAJ92m4YJsfcTBAAvPaF4iZElYqHN2gCf
+bYf4UQcYBvRMyJoSTP2rTfDSUryJAj4EEwECACgFAkzkiXoCGwMFCQlmAYAGCwkI
+BwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEJLJ2nG4iOo0hY0P+gNlJb3nBePD/mN4
+6FdZwJKpcNwXbj+VVF67LHqYqIjZLaTy45L54f0ia8efHSdqUwG6AHiNUJFKA8Le
+Jgxw9/xcUgFhpkZQlq5jYW+d/QfpNyrDJHmy1YG319RjiWexLO+TZTqQaqnoJO0L
+REmmcExYqW0YGCJHQV63htiF9FLZhs0elX94XzePm22CHcsqJKuM4flnEwK1ZqiK
+q4ScKs4vXoaUBX7mXJdBUSvFLKJTpq6abaF/G9mCZXaCOF0lcet3XiByh7vvvthr
+5g0tiSyS8IKIPW+R6nmO3lqAwHvJt44YW5n+zJ0ZyUabtHM9qGJHAaB0DClzk6Z7
+5J7xOncmJ8Iasx8/u7wEigGrBCbXdadFmKZA+KeEa2xGJuy/HwalzK0VQGk6FMhc
+wWXsS41m6zYuSLGphtHKZltTVcHtCW9jIxMlPNybYGgZz5w/69ydUgFA4iyRsfXy
+yIrqrvI79ddjA/Fa81EPfqSIj0bMWCMecsehehoT1c0mJOza7edx5F2AZqYfHBn9
+PuGyjvKuS4aQjtw1KvB2iLsYd/HkGFypZDqb271ssu7rK+JojzirPktBikohzmge
+7PSJ84DKRIC97ki3T5v/UtU3815aQAqFFcWCdddVABT9DPYU35iQQ/wTrKKulHux
+m+E35BwqR+SD4muk4Q5D30MU8a/9iQI+BBMBAgAoAhsDBgsJCAcDAgYVCAIJCgsE
+FgIDAQIeAQIXgAUCT+pedwUJDGvW5gAKCRCSydpxuIjqNIPFD/97ZEk6+WqhvrUy
+MD2cqJfTCRXXl9ImodT6K1XZgOxsh6Co4qi0zyKHXCLbwXByJ4WZm3XhvBo+uhM4
+qYIBruLrFfGVuk0IlXhaAdSZirOowCniO2PCJ2B7deEfeFmeJelb+KZfmuYe47Ln
+jIjtDnVfHdPXo18JJu2OABkJzEcTvvyEkyH4hUpiIYRIvs8WtBzSQffZ48GL1DgY
+ea7r7x8CUNfS59bsxJVg/BBt6Un5s63w5JiQF2Dlu49K3nBaEma4jfmrowNO5zbM
+8mRMdKod23K1Y68EWu168tPj/bUbszV3TAca42DTarQZYnRn0xiqvC2I47PS2pIy
+e/spQH4FbjQouD/543A7++nmd16gkLZoly2/Tg0oTaaFKy1EubiPXPRWnBlF9bit
+J85uQNDmjid2Py2YCUGsHRUHBz7BtfZVHX6rnCN1BXNiyUEb1hgCvbaquYfCdjwV
+N/c/W/anDtgsarqyvdAgpuDySe8+JEJ2RDggZBGjk1xXKLsPyOJ1McILPQ8Uk/lh
+2rnGQ5hy9/RhZ7NvKi8yYban/6yemcYCw9YnK1odHpbeX/BTkQvWmzC2mOGUHN1b
+DSmgidNISBxk4ukGhfxj3L3ytTBQMT3ZCRrHo6lOH614RxV1Y9JID7s82szARm3q
+sKd1YWmk1fCdkt4b6JO06TologVz0IkCPgQTAQIAKAIbAwYLCQgHAwIGFQgCCQoL
+BBYCAwECHgECF4AFAlsF30cFCRPE8L8ACgkQksnacbiI6jR6Vg//QaZ8JXj5BNtI
+bp4IbqjGiKFQ0ofr4zKpyu+W5hDgMPoBhW3T1FVXYeBmoaWv1TsGjixwq0ucdBWn
+y6+NvL09jBvq5LpKp2+r2KI9vfLjHbLc/cSiZI+fN2/6PJTZMFqf2rWmRCEAW6Gj
+vwEVDHlHgMXVMuJiFX1bT8JPvYStZ2v0su2960rBYNA2NxVg0iYE1K8p3Xvugsop
+pXuX5jltZNh1I2jPGN9wcYGV69N6Pu8mkSzGm+uiRWrkYKT+w6V2W4jYbuJiNQ7Z
+b1lz6+lP07t3o+at3BaafxWjIhIX/rWvtedO32OFF4HO29sa2GWrVNdAa8ViITYW
+XpeVHeqmAHPjamEettmKoE4dG3K/5H6sBaF8hJRxff8x4yEbjo0t2jod5hG16+Wx
+VohU2JdAazI3rdFRQXgV+vnlRaYv5cb//tur4VnqyTHmpIjq7WRXVmdworCjMyZp
+I6xtFZKePQrYm2fAl3aBdTZ9FexV3G3we/gYfD0Gfg2LLYC0TU+ifai0JdPhmuWl
+b6IV1BNsnLpYnb+nRS+kY41blo+i/EuUaezL/rN85a4GIWz8YbLkhX/kmyAgks5t
+wL7HsLJV+b4SMJnPyX9Yq7wvQICqFwXHEqhwfCV8KCE5VY4RaPVk9ToB84KS4NIF
+hNtb8c6GTmpyVZeKp0bavh+ovlL+FhG5Ag0ETOSJAgEQAMQE81j1XU7qUWB7rk6b
+UI1f18I1vnFapWGtnagfrEhk5hcP+zO+zIyQx30Vj7gqv1cjHQYn6/BwhL0vlRV8
+iswZHfybIMGx/Zx4c5vkzJY9G88h/xg5GdE3nPAoh99bTXYNA1NcTWrQ3Nnads50
+khhHyHsopaZI7LDDo8X2f+BE5J9DdLFxQo7wtH8JdUCVNmuF4QB+soMnT7luEgjA
+Q0Vlud4mYnwdNrYFofu7BL0BlEO3lIGgB6NexBTife4BO7sxZQ9zqilSWh8I5DM9
+W6A8uCwM5FThrByHY4rNf/++5USkXpdd5YfgPuqxktdH5NBF0gKnBfKSqMbZuA6W
+NsxxzzTgdNstgd1yZACV5aR7T29/py8HyL9Uw/YyZTYGOgFiPbPstYRAEkvVWM14
+1FNqTWaIXn/PxnKWiFB6IElUcuz9a5z6Jg0RSwV/loEHaJ6idGkMTDwLJ6N+DvXF
+Hw7SoUALQnfdu+GU0SZANYzn00hYdzM2Z8E9NKU5fRMT9XERigubGEisn8p+912V
+Binnfurhi5kboL/WfYEkYPoKbVkvOV+7kHjARWIDmWSpGpptAVYCfekVqI6jnI6q
+THib/qA4xugt3TrFXftRpExJoemg4e7lL6JTLHF/J8BkzjbMef1PctrVI1PYiDQJ
+0Shcuyrs814BYSwrT+5CL6ETABEBAAGJAjwEGAEKACYCGwwWIQTEmSR5ui6KXULB
+ggGSydpxuIjqNAUCYAgAUgUJFuXeUAAKCRCSydpxuIjqNKWPEACvP9Q7zB4bgiAl
+y64sj/jM0OiEMUPhB9u8ZRxzgWw0PqpetKYtiPww3kTF+xk7HCUjt0e12h11p3sX
+OWwBfOstLohdb/XJx2hzpFA99SfxbdbDS+WIhHNeRSoTtun1RNGpnmBQAd07A8OM
+iUkgP4PJmT9Lx7AcsVR5khAYaCNvAZfrdQrR3Urq6ZIR5r4JQM45/pJQ4Oq19yEq
+d89hU3ej+iz2+19Uis4iHY2XpCcP6IZNPawalhlji/gWlKHbOgk454qIptcSvzCx
+/gdmifkV6/kzKYWOy691PFpRT40Q9ipRllyajTFpYWe/dNw9tAICjTT6WNOwBMjB
+rATutspRxiDDtdl41Kbbca7n8AW/aB4BISkvEZUnMBcxf+go88/fN8ZasyNEtwdT
+443UrNnrC3ZVBgqUNSAPfTgEv2XAMlWAeBJOpVsRoZBiXnPsUDYs024toQCXpN2a
+3Bn+fFL9BXPor2B0vd1YB/yhAvCWcJ2WhBzcZ01HCAanL1DBa8uvg4p2BwuHSUuW
+R5HaZNMj6WJSj0Uk06LFTLaOfCv6ROANhvk37NRx6MX2QI4vEkbycYMBGp14l/g0
+oNKtb3gzOgA4QElT3iu9za3JpYF19qCnS+yMtw7sMy4J33SRO2vJ8RLgbgZWZTqD
+LXuQsMKeyq2w2PXDtjLNmCUMNnTR/A==
+=6UFY
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/src/pubkey_signed.asc b/src/pubkey_signed.asc
new file mode 100644
index 0000000..40bba4e
--- /dev/null
+++ b/src/pubkey_signed.asc
@@ -0,0 +1,71 @@
+-----BEGIN PGP MESSAGE-----
+
+owF9V2vsNNVZBwoVJ9BStAhYIhWKNhOY3dmZnRmBNnO/7s59d2exkLnszs59Zmdn
+dmcsGCG0XIoQwEiVUIFg2pRXaANN+9JWWzCNRai2sSi9KGjRIFVSUxoKFv9Fbfzk
++XbOk+fDye/yPL/bTn/LCcCJt//Fs9Vnb3gROfHYaZQHlI2XrLpL3Nr3v3X7RRf/
+9FAsL07P13jtfM2mFJE+X2ad8ylFpeU3ywCQ6eKU4jUvMR2KJRlp7MGeg9utC0tm
+sIPWdbxxBksGotgVQ6y29VQeqqq/nHWVSCzBjQFIMCW2HtvRkXbIeBgkMUie0aq0
+3YY7OMj2w7pYmMhKW5VOEVWjsVrFJTTN4WQ2UbnxaggsViaoHvWIa2WiLFVO3KU8
+V0/UYGkokz72xJFrW7WsNWIZSGMSnbJFQC3bNS8Ei342GAE1z+5hi2i0aERWBhgV
+8qFjMqv1yXkkEtN6uWA200FmhJCoF2RNlImwSTx7NqSyw1TlIqBpXMYrd8IoDpxN
+pHtDFqMkyUbp6WZLLNshh2nMgN8iOqqGBZMn0GQpt8hkVqdgryEIAniC10Rkv54n
+GaZ77gSccuCU9j2siW1FEiA7Dr0jVNydjwZThkOHPolQzHKVxCvVFrElwIh1OLR3
+CYUHo5khp3tQGig7YtkJWL+XKUzqkzHv5UPIMal8Nq8G5jIrsiqyZytqTrUYEBYh
+V3t2wozQzF0H1CxIjBni+Csi32bMvghBNMDibaTKnqMPJpm596mV4Ak5y+8wODkA
+bNHiTtrXdVBNGUtZ2lx0QMWBshRId2rhSha3yazp+iEu4SxTgYchOlgh1kZDiu2G
+TCjAh9T1iMLVzEn5wTqcjPqVu/dadjgOc30o5gJM8lPlQIWbqI/NUDAYtUDD3mbm
+8SLlpyMA6mEIAzvFjGzWrpMeCemZ0qjcZrGljY73zdLuKBLK+XQtQTt6JOhWVwkM
+t92TBqmTFLCjUt2Zb1IXxkONN9Igofb/c28c+NAGcx2MdEmlqAlFhyTCRSwLh33e
+wuFQ0z0O8FmhkaJuK67cTRIlHJm9qQua3084ep+I1F7kOb2QaLKmKcqhyT1LCyFL
+cwhJ0mGiA/+nWbRICFR24EDfN1glblsJ08Idyob0SOd5c59OUyLl2a3ZqPogLqfs
+Yd9RgL7RDYHE0zwKtlxqLFBuEo7FVccWXY5NF7GDBT2JpslqmqPZfKWlSJkoWo8t
+Fiam7asACLstWvqYQ1ClFWt1CUf9covWHpX2TsAxC3dSjAkmWAqKqFhH8MqDilTW
+FONXeo6sIAeAcDhDc6qqs4J3mhIWcsvfUXFIulm8kSYdr0PSgokpf6zNYBVk91hO
+JCUe7L2dg9jlBFA7nK+RoE8Qw4V2YxVZFoNJWWyV5bhv0m5uzMLWRoKZt5O4qcTK
+LsIsJgdbTdIo5RYQdKTG8RgNWdIEufEaor2xygkTTDwESonk4bhHSDCeHWKkXTBh
+hdLakIMUnLWrXQon/iKwAVFves/FarycMRO8iZW9AdX4zAv54R48ON5yVyVsVRak
+KK76dpEd4DDPG5p0kyPZuRoB6KaQZQyETuYQP1to+5ECg36Yiah7yBJNG8AkQZnp
+xA9mOKwGaA1Pc3cHk2RSaV4sjxoAsizOIJvMtFp0uOop64iM5iF0BLaY61TQtlgE
+mvkyj9ZrJ2gPlpyk4kaGD83CGVJ90gAokxCmMSj0IdPqJGhxMrrgNu48HUZN0xcW
+g1jz8RadKhUrGHZwEMoVrltkokfIZiN3HoDlDiyGscFW+VZpg3VGyOY83s7gMbca
+IK1vmI7X++12zAbMns8sTJt66oEId6CLkuGABRyiMaSQ1Ul5ukoIGEPmaWq7O35m
+bcR42ZbOrmxQHR1Z7LLoHcsokbkiMdOdgM4xZtDKAB75W2KF0baoiLq8Ucblqgml
+hRIPhUWRwG0jD0RrrtD1iiybRdcG/QIH0eVIp4dhT497gIzJwRaT/bGvhWBuK5gp
+mHJjOUNQCdyARkjHsbgpsalA3Wep3QEMmtw6BHFcdw3iSj6wgUMcHaOZEfm+4QwV
+M3TKbIJTaVfG4DyWhjKHtaA03bTriCzcQMtnra0yk2pYBsIcYYHGFzXMbMo9WImz
+1MhcGTQtRmMZGw+iDnMmEQ0ewScps6GTEsNKORCsjBp+m/i2nbYMsB+xZrHrWcmM
+DG5oBO3Qs8mdOIuwpcQ762oLa5ya88pcMubOYc8typYgmWSoHvFxNLJgYNd59cye
+gv1MCIpRhbWMZzoLz12tuBBpC1dqDCLJRzkjm6gSBRtY0CN7Xm5m/aA1fYMBXAjK
+WWe/rxti5FqaDIVzlDqQJaQobKyH8cIURow84qZJ3Y3Cbo32QYjPq16r5oW/4A1A
+G9sVvZkPm3ih40fDyxBwd1v1aDVIwoILa4cYCGvEFamBkNFBWmsyTu2siW6bSLFU
+piTAj1MIhbVcbZNNETh9Ozc3R/sAbhdjXsA9KMuKwRKr+jQpG8r0HeYwgLVoK6jV
+dlfgCAwc7DZNYBTOkSXto/1UcYfL9ug363IHDl0vOnJbKEJybbPa2js+KWivQ0mK
+pUiSl8jYYQGeZGWSJueibrmMuoJcxiaoerbXV0imyPUGrXhT1m36vykvegxJyrRB
+/qwGyEtbYyCC1pa92s51upFSLWI8dro0PGXSBOamXjHpOggSJiMViMiscutj8WA+
+P+STWUgDmerNxztoH6kaQhp7FJLp7eiwXUIq5qoYlxP4/NBZTsBuB2HG4hmYp24q
+NCKTr5NdSahA1dFLuIp1l5edwVpS7KRjaMkQXCRWTQsdK0EpQNwhJn3J14YTS6rr
+bqAcQgpegZzNd4AUWCMQie0JpQ6nVDTBEcWdIdiuC1nnyIvVgaxBjSHII1/mlc3R
+wLMgJVAVH86zw7rYdcAymaC5NggPs6jX1e0iZVS2MqIlxHc8bi0ZYmgbvbFa4nVN
+Kh4DHhGxodO0LYJDNY/mOLCs59EAGgs7NPOGOyQbgvMO5Jsk2EDuGMqhOQK58VxU
+S/swxYarwT4A44pNUwRGmK3CjIEdNIG3u35MyUKTwVszFRvCVgVmkS3kmC17eCFp
+eCLVko+SgaqbRKjkIzeaFp0SMmSzBozUFJKYyA/YkedPDSzDbNktMT4tCWMrFIjW
+0H6vO15IztfFqmBNqhp7R/hDSJuiPs4CnENhjY46JDYst0fGmTVMzVaDnYgZyWSM
+B4hcKROEDEfqfCYyJcNvrF0c9fSKGOAF1qdAsymViUCzxHgkywewV7BkZ22yUujx
+GQ7V1dpjujQzabhuXGkj0REYoYabWBsm2KOMawJoJ61lXtur8QHPYHKsqfsZQvP5
+hvRZYrboU5vW4IWNjaSZP5FGekNefjlwOWJOJODNfZ+dMv9PGLjxpNHJJ5wInHDe
+We8++YmPXtgdv+SmD1BfuvZnKeOUk36aK04Afv6M/315DXj7Gx98lH0r/k/PH9ug
+3/rM185453s/YX3hbOWJ9Nar3/HImVf9SbKovg5AyHmXqc33f+8/XfuGiHroffY9
+F5782IWv9+e8ev2lymnvOu9ff/N3vnn6n37kb8+485Zb7p9f+aR+4kUP9RcNHn75
+K+2jV9/0/AUZgn3xS+2df/XpC5++m5Xe03/qh8eeqYnqhf2xd5966d3i333+qQvu
+jvQrz33xjx8/854P3Ptof9VnmWvf2D193Xc+f95Lp3/xbWdSLx37td/+3NmXG8de
+W99y7gC/49rv3faJm7Cbnvvuae+PHzn+0Puf++YrZ93c5b/7k/Gnf/SW1amZ1J39
+qPNHffjh7Onn/n3/Q3WZX3ff8t5XH3rjiotd4rKvvTRZN5c/8Ad3xZ+77VRuoH33
+y4+9+HXntf6Fuz4yfvbWx79z6cG97eLfuHTc32dd8soDXf8f9y+ue9/P3Rid8snk
+96+7+dn7r1Ku8kpC+LdP/uUz7/ryY+/snr7vK9/4Begfbzj/xhP++eMPX/mDf7ke
+/BD7k2f6Pzzcds/bXn3g+Dkfa6/+s+df/sGvfGE/u+bEX/7V2+86xn3j+z9+9s7h
+V/e33/Tko9c8+ev7S/KTTj7+5/Vf/+LbH3no49d9G7/1sgcffOuPmcEr7We6Ry+T
+30sj3M1fff34BWDV31o+/J7Tz33ihXMe/NA5w7855Spg8aMrtI8W+seg+B2fuvfG
+v3/gmtN+6x/UF+gr9NFVr+tnPN6j3149dR34vRueKl9If+ms48yZV9AfPC4un73j
+5TuuJ/4L
+=apuZ
+-----END PGP MESSAGE-----
diff --git a/src/scripts/cron.php b/src/scripts/cron.php
new file mode 100644
index 0000000..a1e7779
--- /dev/null
+++ b/src/scripts/cron.php
@@ -0,0 +1,33 @@
+backup_frequency && $config->backup_limit) {
+ Backup::auto();
+}
+
+// Send pending reminders
+Reminders::sendPending();
+
+if (Files::getVersioningPolicy() !== 'none') {
+ Files::pruneOldVersions();
+}
+
+// Make sure we are cleaning the trash
+Trash::clean();
+
+Plugins::fire('cron');
diff --git a/src/scripts/emails.php b/src/scripts/emails.php
new file mode 100644
index 0000000..baeadaa
--- /dev/null
+++ b/src/scripts/emails.php
@@ -0,0 +1,34 @@
+ printf("%s: %s\n", $action, $file->path);
+
+if ($command === 'import') {
+ Storage::migrate(FILE_STORAGE_BACKEND, 'SQLite', FILE_STORAGE_CONFIG, null, $callback);
+}
+elseif ($command === 'export') {
+ Storage::migrate('SQLite', FILE_STORAGE_BACKEND, null, FILE_STORAGE_CONFIG, $callback);
+}
+elseif ($command === 'truncate') {
+ Storage::truncate('SQLite', null);
+ print("Deleted all files contents from database.\n");
+}
+elseif ($command === 'scan') {
+ Storage::sync(null, $callback);
+}
+else {
+ printf("Usage: %s COMMAND
+COMMAND can be either:
+
+import
+ Import files from configured storage to database
+
+export
+ Export files from database to configured storage
+
+truncate
+ Delete all files contents from database.
+ (No confirmation asked!)
+
+scan
+ Update or rebuild files list in database by listing files
+ directly from configured storage.
+
+", $_SERVER['argv'][0]);
+ exit(1);
+}
diff --git a/src/scripts/upgrade.php b/src/scripts/upgrade.php
new file mode 100644
index 0000000..e8c8333
--- /dev/null
+++ b/src/scripts/upgrade.php
@@ -0,0 +1,25 @@
+getMessage() . PHP_EOL;
+ exit(1);
+}
diff --git a/src/sous-domaine.html b/src/sous-domaine.html
new file mode 100644
index 0000000..be9ac31
--- /dev/null
+++ b/src/sous-domaine.html
@@ -0,0 +1,13 @@
+
+
+Erreur
+Paheko n'est pas installé sur un sous-domaine dédié.
+Ce mode de fonctionnement n'est pas supporté officiellement.
+
+Installation conseillée
+Le mode conseillé est de positionner un sous-domaine dédié (virtual host ou vhost ) sur le répertoire www/
+Voir la documentation .
+
+Installation dans un sous-répertoire, sans virtual host (non conseillé)
+
+Voir la documentation dédiée pour configurer Paheko correctement et faire disparaître ce message d'erreur.
\ No newline at end of file
diff --git a/src/templates/_foot.tpl b/src/templates/_foot.tpl
new file mode 100644
index 0000000..bd65b77
--- /dev/null
+++ b/src/templates/_foot.tpl
@@ -0,0 +1,24 @@
+
+
+{if $is_logged}
+{* Keep session alive by requesting renewal every before it expires *}
+
+{/if}
+
+
+
diff --git a/src/templates/_head.tpl b/src/templates/_head.tpl
new file mode 100644
index 0000000..b5e7ef4
--- /dev/null
+++ b/src/templates/_head.tpl
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
{$title}
+
+
+
+ {if isset($custom_js)}
+
+ {foreach from=$custom_js item="js_url"}
+
+ {/foreach}
+ {/if}
+ {if isset($custom_css)}
+
+ {foreach from=$custom_css item="css_url"}
+
+ {/foreach}
+ {/if}
+ {if isset($plugin_css)}
+ {foreach from=$plugin_css item="css"}
+
+ {/foreach}
+ {/if}
+ {if isset($plugin_js)}
+ {foreach from=$plugin_js item="js"}
+
+ {/foreach}
+ {/if}
+
+ {if isset($logged_user) && $logged_user.preferences.force_handheld}
+
+ {else}
+
+ {/if}
+
+ {if isset($config)}
+
+ {/if}
+ {custom_colors config=$config}
+
+
+
+
+
+
+{if ALERT_MESSAGE && !$dialog}
+
=ALERT_MESSAGE?>
+{/if}
+
+{if !array_key_exists('_dialog', $_GET) && empty($layout)}
+
+{/if}
+
+
\ No newline at end of file
diff --git a/src/templates/acc/_table_actions.tpl b/src/templates/acc/_table_actions.tpl
new file mode 100644
index 0000000..85b893f
--- /dev/null
+++ b/src/templates/acc/_table_actions.tpl
@@ -0,0 +1,11 @@
+ Pour les écritures cochées :
+
+ {csrf_field key="projects_action"}
+
+ — Choisir une action à effectuer —
+ Ajouter/enlever d'un projet
+ Supprimer les écritures
+
+
+ {button type="submit" value="OK" shape="right" label="Valider"}
+
diff --git a/src/templates/acc/_year_select.tpl b/src/templates/acc/_year_select.tpl
new file mode 100644
index 0000000..2d9c12f
--- /dev/null
+++ b/src/templates/acc/_year_select.tpl
@@ -0,0 +1,5 @@
+
+ Exercice sélectionné :
+ {$current_year.label} — {$current_year.start_date|date_short} au {$current_year.end_date|date_short}
+ {linkbutton label="Changer d'exercice" href="!acc/years/select.php?from=%s"|args:rawurlencode($self_url) shape="settings"}
+
diff --git a/src/templates/acc/accounts/_nav.tpl b/src/templates/acc/accounts/_nav.tpl
new file mode 100644
index 0000000..5a23964
--- /dev/null
+++ b/src/templates/acc/accounts/_nav.tpl
@@ -0,0 +1,17 @@
+
+
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+
+ {linkbutton shape="edit" href="!acc/charts/accounts/%s?id=%d"|args:$page,$current_year.id_chart label="Modifier les comptes"}
+ {/if}
+ {linkbutton shape="search" href="!acc/search.php?year=%d"|args:$current_year.id label="Recherche"}
+
+
+
\ No newline at end of file
diff --git a/src/templates/acc/accounts/all.tpl b/src/templates/acc/accounts/all.tpl
new file mode 100644
index 0000000..b32d65b
--- /dev/null
+++ b/src/templates/acc/accounts/all.tpl
@@ -0,0 +1,50 @@
+
+{include file="_head.tpl" title="Tous les comptes" current="acc/accounts"}
+
+{include file="acc/_year_select.tpl"}
+
+{include file="acc/accounts/_nav.tpl" current="all"}
+
+{if !empty($balance)}
+
+
+
+ Numéro
+ Compte
+ Total des débits
+ Total des crédits
+ Solde
+
+
+
+ {foreach from=$balance item="account"}
+
+
+ {$account.code}
+
+ {$account.label}
+ {$account.debit|raw|money:false}
+ {$account.credit|raw|money:false}
+ {if $account.balance !== null}{$account.balance|escape|money:false} {/if}
+
+ {/foreach}
+
+
+{else}
+
+
Aucun compte ne comporte d'écriture sur cet exercice.
+
+ {linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"}
+
+
+{/if}
+
+
+ Note : n'apparaissent ici que les comptes qui ont été utilisés dans cet exercice (au moins une écriture).
+ Les lignes grisées correspondent aux comptes soldés.
+ Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au plan comptable .
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/accounts/deposit.tpl b/src/templates/acc/accounts/deposit.tpl
new file mode 100644
index 0000000..1bf9726
--- /dev/null
+++ b/src/templates/acc/accounts/deposit.tpl
@@ -0,0 +1,83 @@
+{include file="_head.tpl" title="Dépôt en banque : %s — %s"|args:$account.code,$account.label current="acc/accounts"}
+
+{form_errors}
+
+{if $missing_balance > 0}
+
+ Il existe une différence de {$missing_balance|raw|money_currency} entre la liste des écritures à déposer
+ et le solde du compte.
+ Cette situation est généralement dûe à des écritures de dépôt qui ont été supprimées.
+ {linkbutton shape="plus" label="Faire un virement pour régulariser" href="!acc/transactions/new.php?0=%d&l=Régularisation%%20dépôt&account=%d"|args:$missing_balance,$account.id}
+
+{/if}
+
+{if !$journal->count()}
+ Il n'y a aucune écriture qui nécessiterait un dépôt.
+
+{else}
+
+ Cocher les cases correspondant aux montants à déposer, une nouvelle écriture sera générée.
+
+
+
+ {include file="common/dynamic_list_head.tpl" check=true list=$journal}
+
+ {foreach from=$journal->iterate() item="line"}
+
+
+ {input type="checkbox" name="deposit[%d]"|args:$line.id_line value="1" data-debit=$line.debit|abs data-credit=$line.credit default=$line.checked}
+
+ #{$line.id}
+ {$line.date|date_short}
+ {$line.reference}
+ {$line.line_reference}
+ {$line.label}
+ {$line.debit|raw|money}
+ {if $line.running_sum > 0}-{/if}{$line.running_sum|abs|raw|money:false}
+
+ {/foreach}
+
+
+
+
+ Détails de l'écriture de dépôt
+
+ {input type="text" name="label" label="Libellé" required=1 default="Dépôt en banque"}
+ {input type="date" name="date" default=$date label="Date" required=1}
+ {input type="money" name="amount" label="Montant" required=1}
+ {input type="list" target="!acc/charts/accounts/selector.php?chart=%d&targets=%d"|args:$account.id_chart,$target name="account_transfer" label="Compte de dépôt" required=1}
+ {input type="text" name="reference" label="Numéro de pièce comptable"}
+ {input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
+
+
+
+
+ {csrf_field key="acc_deposit_%s"|args:$account.id}
+ {button type="submit" name="save" label="Enregistrer" class="main" shape="check"}
+
+
+
+ {literal}
+
+ {/literal}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/accounts/index.tpl b/src/templates/acc/accounts/index.tpl
new file mode 100644
index 0000000..d018f13
--- /dev/null
+++ b/src/templates/acc/accounts/index.tpl
@@ -0,0 +1,93 @@
+
+{include file="_head.tpl" title="Comptes favoris" current="acc/accounts"}
+
+{include file="acc/_year_select.tpl"}
+
+{include file="acc/accounts/_nav.tpl" current="index"}
+
+
+{if isset($_GET['chart_change'])}
+
+ L'exercice sélectionné utilise un plan comptable différent, merci de sélectionner un autre compte.
+
+{/if}
+
+{if $pending_count}
+ {include file="acc/transactions/_pending_message.tpl"}
+{/if}
+
+{if !empty($grouped_accounts)}
+
+
+
+
+ Numéro
+ Compte
+ Solde
+
+
+
+
+ {foreach from=$grouped_accounts item="group"}
+
+
+ {$group.label}
+
+ {foreach from=$group.accounts item="account"}
+
+ {if $account.bookmark}{icon shape="star" title="Compte favori"}{/if}
+ {$account.code}
+ {$account.label}
+
+ {if $account.balance < 0
+ || ($account.balance > 0 && $account.position == Account::LIABILITY && ($account.type == Account::TYPE_BANK || $account.type == Account::TYPE_THIRD_PARTY || $account.type == Account::TYPE_CASH))}
+ balance)*-1; ?>
+ {$balance|raw|money_currency:false:true}
+ {else}
+ {$account.balance|raw|money_currency:false}
+ {/if}
+
+
+ {if $account.type == Account::TYPE_THIRD_PARTY && $account.balance > 0}
+ {if $account.position == Account::LIABILITY}(Dette)
+ {elseif $account.position == Account::ASSET}(Créance)
+ {/if}
+ {elseif $account.type == Account::TYPE_BANK && $account.balance > 0 && $account.position == Account::LIABILITY}
+ (Découvert)
+ {elseif $account.type == Account::TYPE_CASH && $account.balance > 0 && $account.position == Account::LIABILITY}
+ (Anomalie)
+ {/if}
+
+
+ {linkbutton label="Journal" shape="menu" href="journal.php?id=%d&year=%d"|args:$account.id,$current_year.id}
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {if $account.type == Entities\Accounting\Account::TYPE_BANK && ($account.debit || $account.credit)}
+ {linkbutton label="Rapprochement" shape="check" href="reconcile.php?id=%d"|args:$account.id}
+ {elseif $account.type == Entities\Accounting\Account::TYPE_OUTSTANDING && $account.debit}
+ {linkbutton label="Dépôt en banque" shape="check" href="deposit.php?id=%d"|args:$account.id}
+ {/if}
+ {/if}
+
+
+ {/foreach}
+
+ {/foreach}
+
+{else}
+
+
Aucun compte favori ne comporte d'écriture sur cet exercice.
+
+ {linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"}
+
+
+{/if}
+
+
+ Note : n'apparaissent ici que les comptes qui ont été utilisés dans cet exercice (au moins une écriture) de types banque, caisse, tiers, dépenses ou recettes. Les autres comptes n'apparaissent que s'ils ont été utilisés et sont marqués comme favoris.
+ Pour voir le solde de tous les comptes, se référer à la liste de tous comptes de l'exercice .
+ Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au plan comptable .
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/accounts/journal.tpl b/src/templates/acc/accounts/journal.tpl
new file mode 100644
index 0000000..9f685f4
--- /dev/null
+++ b/src/templates/acc/accounts/journal.tpl
@@ -0,0 +1,164 @@
+{include file="_head.tpl" title="Journal : %s - %s"|args:$account.code:$account.label current="acc/accounts" body_id="rapport"}
+
+{if empty($year)}
+ {include file="acc/_year_select.tpl"}
+{else}
+
+ Exercice sélectionné :
+ {$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}
+
+{/if}
+
+{if $account.type}
+
+ {if $simple}
+ {if $account.type == $account::TYPE_THIRD_PARTY}
+ {if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0}
+ Ce tiers vous doit {$sum.balance|abs|raw|money_currency} .
+ {elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0}
+ Vous devez {$sum.balance|abs|raw|money_currency} à ce tiers.
+ {/if}
+ {elseif $account.type == $account::TYPE_BANK}
+ {if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0}
+ Ce compte est créditeur de {$sum.balance|abs|raw|money_currency} à la banque.
+ {elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0}
+ Ce compte est à découvert de {$sum.balance|abs|raw|money_currency} à la banque.
+ {/if}
+ {elseif $account.type == $account::TYPE_CASH}
+ {if $account->getPosition($year->id) == $account::ASSET && $sum.balance > 0}
+ Cette caisse est créditrice de {$sum.balance|abs|raw|money_currency} .
+ {elseif $account->getPosition($year->id) == $account::LIABILITY && $sum.balance > 0}
+ Cette caisse est débiteur de {$sum.balance|abs|raw|money_currency} . Est-ce normal ? Une vérification est peut-être nécessaire ?
+ {/if}
+ {elseif $account.type == $account::TYPE_OUTSTANDING}
+ {if $sum.balance < 0}
+ Ce compte est débiteur {$sum.balance|abs|raw|money_currency} . Est-ce normal ? Une vérification est peut-être nécessaire ?
+ {elseif $sum.balance > 0}
+ Ce compte d'attente est créditeur de {$sum.balance|abs|raw|money_currency} . {if $sum.balance > 200}Un dépôt à la banque serait peut-être une bonne idée ?{/if}
+ {/if}
+ {elseif $account.type == $account::TYPE_REVENUE && $sum.balance < 0}
+ Ce compte présente un solde négatif de {$sum.balance|raw|money_currency} . Est-ce normal ? Cette situation ne devrait se produire que si vous avez dû procéder à des remboursements par exemple, et que ceux-ci couvrent des recettes perçues sur un exercice précédent.
+ {elseif $account.type == $account::TYPE_EXPENSE && $sum.balance < 0}
+ Ce compte présente un solde négatif de {$sum.balance|raw|money_currency} . Est-ce normal ? Cette situation ne devrait se produire que si vous avez reçu des remboursements par exemple, et que ceux-ci couvrent des dépenses réglées sur un exercice précédent.
+ {/if}
+ {/if}
+
+
+
+
+ {if !$filter.start && !$filter.end}
+ {linkbutton shape="search" href="?start=1" label="Filtrer" onclick="g.toggle('#filterForm', true); this.remove(); return false;"}
+ {/if}
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {exportmenu}
+ {/if}
+ {linkbutton shape="search" href="!acc/search.php?year=%d&account=%s"|args:$year.id,$account.code label="Recherche"}
+ {if $year.id == CURRENT_YEAR_ID}
+ {linkbutton href="!acc/transactions/new.php?account=%d"|args:$account.id label="Saisir une écriture dans ce compte" shape="plus"}
+ {/if}
+
+
+{/if}
+
+
+
+ Filtrer par date
+
+ Du
+ {input type="date" name="start" source=$filter default=$year.start_date}
+ au
+ {input type="date" name="end" source=$filter default=$year.end_date}
+
+
+
+
+
+
+
+
+
+{include file="common/dynamic_list_head.tpl" check=$can_edit}
+
+ {foreach from=$list->iterate() item="line"}
+
+ {if $can_edit}
+
+ {input type="checkbox" name="check[%s]"|args:$line.id_line value=$line.id}
+
+ {/if}
+ #{$line.id}
+ {$line.date|date_short}
+ {if $simple}
+ {if $line.change > 0}+{else}-{/if}{$line.change|abs|raw|money}
+ {else}
+ {$line.debit|raw|money}
+ {$line.credit|raw|money}
+ {/if}
+ {if isset($line->sum)}
+ {$line.sum|raw|money:false}
+ {/if}
+ {$line.reference}
+ {$line.label}{if $simple && $line.line_label} — {$line.line_label} {/if}
+ {if !$simple}{$line.line_label} {/if}
+ {$line.line_reference}
+ {if $line.id_project}{$line.project_code} {/if}
+ {if isset($line.locked)}
+ {if $line.locked}{icon title="Écriture verrouillée" shape="lock"}{/if}
+ {/if}
+ {if $line.files}{$line.files}{/if}
+ {* Deposit status, might be consufing
+
+ {if $account.type == $account::TYPE_OUTSTANDING && $line.debit}
+ {if !($line.status & Entities\Accounting\Transaction::STATUS_DEPOSIT)}
+ {icon shape="alert" title="Cette opération n'a pas été déposée"}
+ {/if}
+ {/if}
+
+ *}
+
+ {if ($line.status & Entities\Accounting\Transaction::STATUS_WAITING)}
+ {if $line.type == Entities\Accounting\Transaction::TYPE_DEBT}
+ {linkbutton shape="check" label="Régler cette dette" href="!acc/transactions/new.php?payoff=%d"|args:$line.id}
+ {elseif $line.type == Entities\Accounting\Transaction::TYPE_CREDIT}
+ {linkbutton shape="export" label="Régler cette créance" href="!acc/transactions/new.php?payoff=%d"|args:$line.id}
+ {/if}
+ {/if}
+
+ {linkbutton href="!acc/transactions/details.php?id=%d"|args:$line.id label="Détails" shape="search"}
+
+
+ {/foreach}
+
+
+
+ {if $can_edit}
+
+ {/if}
+ {if !$simple} {/if}
+ {if null !== $sum}
+ {if !$simple}
+ Total
+ {$sum.debit|raw|money:false}
+ {$sum.credit|raw|money:false}
+ {$sum.balance|raw|money:false}
+ {else}
+
+ Total
+ {$sum.balance|raw|money:false}
+ {/if}
+ {else}
+
+ {/if}
+ {if !$simple} {/if}
+
+ {if $can_edit}
+ {include file="acc/_table_actions.tpl"}
+ {/if}
+
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/accounts/reconcile.tpl b/src/templates/acc/accounts/reconcile.tpl
new file mode 100644
index 0000000..e304ccb
--- /dev/null
+++ b/src/templates/acc/accounts/reconcile.tpl
@@ -0,0 +1,102 @@
+{include file="_head.tpl" title="Rapprochement : %s — %s"|args:$account.code,$account.label current="acc/accounts"}
+
+{include file="acc/_year_select.tpl"}
+
+
+
+
+
+
+ {if $prev || $next}
+
+ Rapprochement par mois
+
+ {if $prev}
+ {linkbutton shape="left" href=$prev.url label=$prev.date|date:'F Y'}
+ {/if}
+ {if $next}
+ {linkbutton shape="right" href=$next.url label=$next.date|date:'F Y'}
+ {/if}
+
+
+ {/if}
+
+ Période de rapprochement
+
+ Du
+ {input type="date" name="start" default=$start}
+ au
+ {input type="date" name="end" default=$end}
+
+
+ {input type="checkbox" name="only" value=1 default=$only} Seulement les écritures non rapprochées
+
+
+
+
+
+
+
+ Les écritures apparaissent ici dans le sens du relevé de banque, à l'inverse des journaux comptables.
+
+
+{form_errors}
+
+
+
+
+ {csrf_field key="acc_reconcile_%s"|args:$account.id}
+ {button type="submit" name="save" label="Enregistrer" class="main" shape="check"}
+ {if $next}
+ {button type="submit" name="save_next" label="Enregistrer et aller au mois suivant" class="main minor" shape="right"}
+ {/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/accounts/reconcile_assist.tpl b/src/templates/acc/accounts/reconcile_assist.tpl
new file mode 100644
index 0000000..43abdd6
--- /dev/null
+++ b/src/templates/acc/accounts/reconcile_assist.tpl
@@ -0,0 +1,174 @@
+{include file="_head.tpl" title="Rapprochement : %s — %s"|args:$account.code,$account.label current="acc/accounts"}
+
+{include file="acc/_year_select.tpl"}
+
+
+
+
+
+{if $_GET.msg === 'OK'}
+
+ Le rapprochement a bien été enregistré.
+
+{/if}
+
+{form_errors}
+
+
+ Le rapprochement assisté permet de s'aider d'un relevé de compte pour trouver les écritures manquantes ou erronées.
+ {linkbutton shape="help" href=$help_pattern_url|args:"rapprochement-assiste" target="_dialog" label="Aide détaillée"}
+
+
+ {if !$csv->loaded()}
+
+ Relevé de compte
+
+ {input type="file" name="file" label="Fichier à importer" accept="csv" required=1}
+ {include file="common/_csv_help.tpl" more_text="Le fichier doit obligatoirement disposer, soit d'une colonne 'Montant', soit de deux colonnes 'Débit' et 'Crédit'."}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="upload" label="Envoyer le fichier" class="main" shape="upload"}
+
+
+ {elseif !$csv->ready()}
+ {include file="common/_csv_match_columns.tpl"}
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="cancel" value="1" label="Annuler" shape="left"}
+ {button type="submit" name="assign" label="Continuer" class="main" shape="right"}
+
+ {else}
+
+ Relevé de compte
+
+
+ Nombre de lignes
+
+
+ {$csv->count()}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="cancel" value="1" label="Annuler le rapprochement" shape="left"}
+
+
+
+
+
+
+ Période de rapprochement
+
+
+ Du
+ {input type="date" name="start" default=$start}
+ au
+ {input type="date" name="end" default=$end}
+
+
+
+
+ {button type="submit" label="Modifier" shape="right"}
+
+
+ {/if}
+
+
+{if !empty($lines)}
+
+ Les écritures apparaissent ici dans le sens du relevé de banque, à l'inverse des journaux comptables.
+
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" class="main" shape="check"}
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/accounts/simple.tpl b/src/templates/acc/accounts/simple.tpl
new file mode 100644
index 0000000..78afed9
--- /dev/null
+++ b/src/templates/acc/accounts/simple.tpl
@@ -0,0 +1,113 @@
+
+{include file="_head.tpl" title="Suivi : %s"|args:$types[$type] current="acc/simple"}
+
+{include file="acc/_year_select.tpl"}
+
+
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {exportmenu href="?type=%d"|args:$type}
+ {/if}
+ {linkbutton shape="search" href="!acc/search.php?year=%d&type=%d"|args:$year.id,$type label="Recherche"}
+
+
+ {foreach from=$types key="key" item="label"}
+ {$label}
+ {/foreach}
+
+
+
+{if $pending_count}
+ {include file="acc/transactions/_pending_message.tpl"}
+{/if}
+
+{if !$list->count()}
+
+ Aucune écriture à afficher.
+
+{else}
+
+ {assign var="has_debt_or_credit" value=false}
+
+ {include file="common/dynamic_list_head.tpl" check=$can_edit}
+
+ {foreach from=$list->iterate() item="line"}
+
+ {if $can_edit}
+
+ {input type="checkbox" name="check[%s]"|args:$line.id_line value=$line.id default=0}
+
+ {/if}
+ {if $line.type_label}
+ {$line.type_label}
+ {/if}
+ #{$line.id}
+ {$line.date|date_short}
+ {$line.change|abs|raw|money}
+ {$line.reference}
+ {$line.label}
+ {$line.line_reference}
+ {foreach from=$line.project_code item="code" key="id"}{$code} {/foreach}
+ {if isset($line.locked)}
+ {if $line.locked}{icon title="Écriture verrouillée" shape="lock"}{/if}
+ {/if}
+ {if $line.files}{$line.files}{/if}
+ {if property_exists($line, 'status_label')}
+
+ {if $line.status & Entities\Accounting\Transaction::STATUS_WAITING}
+ {$line.status_label}
+ {else}
+ {$line.status_label}
+ {/if}
+
+ {/if}
+
+ {if $line.type == Transaction::TYPE_DEBT && ($line.status & Transaction::STATUS_WAITING)}
+ {assign var="has_debt_or_credit" value=true}
+ {linkbutton shape="check" label="Régler cette dette" href="!acc/transactions/new.php?payoff=%d"|args:$line.id}
+ {elseif $line.type == Transaction::TYPE_CREDIT && ($line.status & Transaction::STATUS_WAITING)}
+ {assign var="has_debt_or_credit" value=true}
+ {linkbutton shape="export" label="Régler cette créance" href="!acc/transactions/new.php?payoff=%d"|args:$line.id}
+ {/if}
+
+ {linkbutton href="!acc/transactions/details.php?id=%d"|args:$line.id label="Détails" shape="search"}
+
+
+ {/foreach}
+
+ {if $can_edit}
+
+
+
+
+ Pour les écritures cochées :
+
+
+ {csrf_field key="projects_action"}
+
+ — Choisir une action à effectuer —
+ {if $has_debt_or_credit}
+ Régler ces dettes
+ {elseif $type == Transaction::TYPE_CREDIT}
+ Régler ces créances
+ {elseif $has_debt_or_credit}
+ Régler ces dettes ou créances
+ {/if}
+ Ajouter/enlever d'un projet
+ Supprimer les écritures
+
+
+ {button type="submit" value="OK" shape="right" label="Valider"}
+
+
+
+
+ {/if}
+
+
+
+
+ {$list->getHTMLPagination()|raw}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/accounts/users.tpl b/src/templates/acc/accounts/users.tpl
new file mode 100644
index 0000000..03d8dff
--- /dev/null
+++ b/src/templates/acc/accounts/users.tpl
@@ -0,0 +1,51 @@
+{include file="_head.tpl" title="Comptes de membres" current="acc/accounts"}
+
+{include file="acc/_year_select.tpl"}
+
+{include file="acc/accounts/_nav.tpl" current="users"}
+
+
+
+ Ce tableau présente une liste de comptes « virtuels » représentant les membres liés aux écritures.
+ Seules les écritures liées à des comptes de tiers sont comptabilisées.
+ Les membres qui n'ont aucune écriture associée n'apparaissent pas dans ce tableau.
+
+
+
+{if !$list->count()}
+Aucune écriture liée à un membre n'existe sur cet exercice.
+{else}
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {$row.user_number}
+ {$row.user_identity}
+
+ {if $row.balance < 0}{/if}
+ {$row.balance|raw|money_currency:false}
+ {if $row.balance < 0} {/if}
+
+
+
+ {if $row.balance < 0}Dette
+ {elseif $row.balance > 0}Créance
+ {/if}
+
+
+
+ {linkbutton label="Journal" shape="menu" href="!acc/transactions/user.php?id=%d&year=%d"|args:$row.id,$current_year.id}
+
+
+ {/foreach}
+
+
+{/if}
+
+
+ Dette = l'association doit de l'argent à ce membre
+ Créance = le membre doit de l'argent à l'association
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/_country_input.tpl b/src/templates/acc/charts/_country_input.tpl
new file mode 100644
index 0000000..c5e36cf
--- /dev/null
+++ b/src/templates/acc/charts/_country_input.tpl
@@ -0,0 +1,47 @@
+ '— Autre'];
+
+if (!isset($chart)) {
+ $chart = new Chart;
+ $chart->country = Config::getInstance()->pays;
+}
+
+$name ??= 'country';
+?>
+
+{input type="select" name=$name label="Appliquer les règles comptables de ce pays" required=1 options=$country_list default=$chart.country}
+
+{if !$chart->exists()}
+Ce choix ne pourra plus être modifié une fois le plan comptable créé.
+{else}
+Si un pays est sélectionné, ce choix ne pourra plus être modifié.
+{/if}
+
+Attention : si « Autre » est sélectionné, alors :
+ - les comptes ne pourront pas être catégorisés automatiquement (banque, caisse, dépenses, recettes, etc.) ;
+ - il faudra donc parcourir tout le plan comptable pour sélectionner un compte
+ - la position des comptes au bilan ou compte de résultat ne pourra pas être contrôlée : des erreurs sont possibles
+ Si vous avez besoin d'ajouter les règles comptables d'un autre pays, merci de nous contacter .
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/acc/charts/_nav.tpl b/src/templates/acc/charts/_nav.tpl
new file mode 100644
index 0000000..9a261b6
--- /dev/null
+++ b/src/templates/acc/charts/_nav.tpl
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/src/templates/acc/charts/accounts/_account_form.tpl b/src/templates/acc/charts/accounts/_account_form.tpl
new file mode 100644
index 0000000..f53bf2a
--- /dev/null
+++ b/src/templates/acc/charts/accounts/_account_form.tpl
@@ -0,0 +1,64 @@
+{if $create}
+
+{/if}
+
+
+ {if $can_edit}
+ {if $account->canSetPosition()}
+ Position au bilan ou résultat (obligatoire)
+ La position permet d'indiquer dans quelle partie du bilan ou du résultat doit figurer le compte.
+ {input type="radio" label="Ne pas utiliser ce compte au bilan ni au résultat" name="position" value=0 source=$account}
+ {input type="radio" label="Bilan : actif" name="position" value=Entities\Accounting\Account::ASSET source=$account help="ce que possède l'association : stocks, locaux, soldes bancaires, etc."}
+ {input type="radio" label="Bilan : passif" name="position" value=Entities\Accounting\Account::LIABILITY source=$account help="ce que l'association doit : dettes, provisions, réserves, etc."}
+ {input type="radio" label="Bilan : actif ou passif" name="position" value=Entities\Accounting\Account::ASSET_OR_LIABILITY source=$account help="le compte sera placé à l'actif si son solde est débiteur, ou au passif s'il est créditeur"}
+ {input type="radio" label="Résultat : charge" name="position" value=Entities\Accounting\Account::EXPENSE source=$account help="dépenses"}
+ {input type="radio" label="Résultat : produit" name="position" value=Entities\Accounting\Account::REVENUE source=$account help="recettes"}
+ {elseif $account->canSetAssetOrLiabilityPosition()}
+ Position au bilan (obligatoire)
+ La position permet d'indiquer dans quelle partie du bilan doit figurer le compte.
+ En cas de doute, sélectionner « Actif ou passif » .
+ {input type="radio" label="Actif ou passif" name="position" value=Entities\Accounting\Account::ASSET_OR_LIABILITY source=$account help="le compte sera automatiquement placé à l'actif si son solde est débiteur, ou au passif si le solde est créditeur"}
+ {input type="radio" label="Actif" name="position" value=Entities\Accounting\Account::ASSET source=$account help="ce que possède l'association : stocks, locaux, soldes bancaires, etc."}
+ {input type="radio" label="Passif" name="position" value=Entities\Accounting\Account::LIABILITY source=$account help="ce que l'association doit : dettes, provisions, réserves, etc."}
+ {elseif $account->exists()}
+ Position du compte
+
+ {if $account.position == $account::EXPENSE || $account.position == $account::REVENUE}Au compte de résultat{else}Au bilan{/if}
+ —
+ {$account->position_name()}
+
+ {/if}
+
+ {if $account.type}
+ Numéro de compte (obligatoire)
+
+ {input type="text" readonly=true name="code_base" default=$code_base size=$code_base|strlen}
+ {input type="text" maxlength="15" size="15" pattern="[A-Z0-9]+" name="code_value" required=true default=$code_value}
+
+ Le numéro du compte sert à trier le compte dans le plan comptable, et à retrouver le compte plus rapidement.
+ {else}
+ {input type="text" label="Numéro" maxlength="20" pattern="[A-Z0-9]+" name="code" source=$account required=true help="Le numéro du compte sert à trier le compte dans le plan comptable, attention à choisir un numéro qui correspond au plan comptable."}
+ {/if}
+ Le numéro ne peut contenir que des chiffres et des lettres majuscules.
+ {input type="text" label="Libellé" name="label" source=$account required=true}
+ {else}
+ Position du compte
+
+ {if $account.position == $account::EXPENSE || $account.position == $account::REVENUE}Au compte de résultat{else}Au bilan{/if}
+ —
+ {$account->position_name()}
+
+ Type
+ {$account->type_name()}
+ Le type est déterminé selon le numéro du compte.
+ {input type="text" disabled=true name="code" source=$account label="Numéro de compte"}
+ {input type="text" label="Libellé" name="label" source=$account disabled=true}
+ {/if}
+
+ {input type="textarea" label="Description" name="description" source=$account}
+ {input type="checkbox" label="Compte favori" name="bookmark" source=$account value=1 help="Si coché, le compte apparaîtra en priorité dans les listes de comptes"}
+
+ {if !$account->exists() && in_array($account.type, [$account::TYPE_BANK, $account::TYPE_CASH, $account::TYPE_OUTSTANDING, $account::TYPE_THIRD_PARTY]) && !empty($current_year)}
+ {input type="money" name="opening_amount" label="Solde d'ouverture" help="Si renseigné, ce solde sera inscrit dans l'exercice « %s »."|args:$current_year.label}
+ {/if}
+
diff --git a/src/templates/acc/charts/accounts/_nav.tpl b/src/templates/acc/charts/accounts/_nav.tpl
new file mode 100644
index 0000000..25ddccd
--- /dev/null
+++ b/src/templates/acc/charts/accounts/_nav.tpl
@@ -0,0 +1,33 @@
+{if !$dialog || $dialog !== 'manage'}
+
+{if $dialog}
+ {* JS trick to get back to the original iframe URL! *}
+
+ {if $current != 'new' && !$chart.archived && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {linkbutton href="!acc/charts/accounts/new.php?id=%d&%s"|args:$chart.id,$types_arg label="Ajouter un compte" shape="plus"}
+ {/if}
+ {linkbutton shape="left" label="Retour à la sélection de compte" href="#" onclick="g.reloadParentDialog(); return false;"}
+
+
+
+ {$chart.label}
+{else}
+
+ {if !$chart.archived && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {linkbutton href="!acc/charts/accounts/new.php?id=%d&%s"|args:$chart.id,$types_arg label="Ajouter un compte" shape="plus" target=$dialog_target}
+ {/if}
+
+ {$chart.label}
+{/if}
+
+ {if $chart.country}
+ {link href="!acc/charts/accounts/?id=%d&%s"|args:$chart.id,$types_arg label="Comptes usuels"}
+ {/if}
+ {link href="!acc/charts/accounts/all.php?id=%d&%s"|args:$chart.id,$types_arg label="Tous les comptes"}
+
+
+{/if}
\ No newline at end of file
diff --git a/src/templates/acc/charts/accounts/all.tpl b/src/templates/acc/charts/accounts/all.tpl
new file mode 100644
index 0000000..aa3e8d1
--- /dev/null
+++ b/src/templates/acc/charts/accounts/all.tpl
@@ -0,0 +1,63 @@
+{include file="_head.tpl" title=$chart.label current="acc/years"}
+
+{include file="acc/charts/accounts/_nav.tpl" current="all"}
+
+
+
+
+ {button shape="delete" type="reset" title="Effacer la recherche"}
+ {* We can't use input type="search" because Firefox sucks *}
+
+
+
+ Les comptes marqués comme « Ajouté » ont été ajoutés au plan comptable officiel par vous-même.
+
+
+{if !$list->count() && $types_names}
+
+ Il n'existe aucun compte dans la catégorie « {$types_names} » dans le plan comptable.
+
+{else}
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="account"}
+
+ {$account.code}
+ {$account.label}
+ {if $account.description}
+ {$account.description|escape|nl2br}
+ {/if}
+
+ {$account.position_report}
+
+ {$account.position_name}
+
+
+ {if $account.user}Ajouté {/if}
+
+
+ bookmark ? 'check' : 'uncheck';
+ $title = $account->bookmark ? 'Ôter des favoris' : 'Marquer comme favori';
+ ?>
+ {button shape=$shape name="bookmark[%d]"|args:$account.id value=$account.bookmark label="Favori" title=$title type="submit"}
+
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$chart.archived}
+ {if $account.user || !$chart.code}
+ {linkbutton shape="delete" label="Supprimer" href="!acc/charts/accounts/delete.php?id=%d&%s"|args:$account.id,$types_arg target=$dialog_target}
+ {/if}
+ {linkbutton shape="edit" label="Modifier" href="!acc/charts/accounts/edit.php?id=%d%s"|args:$account.id,$types_arg target=$dialog_target}
+ {/if}
+
+
+ {/foreach}
+
+
+
+
+{/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/accounts/delete.tpl b/src/templates/acc/charts/accounts/delete.tpl
new file mode 100644
index 0000000..c64424e
--- /dev/null
+++ b/src/templates/acc/charts/accounts/delete.tpl
@@ -0,0 +1,8 @@
+{include file="_head.tpl" title="Supprimer un compte" current="acc/years"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ce plan comptable ?"
+ warning="Êtes-vous sûr de vouloir supprimer le compte « %s — %s » ?"|args:$account.code,$account.label
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/accounts/edit.tpl b/src/templates/acc/charts/accounts/edit.tpl
new file mode 100644
index 0000000..3f98829
--- /dev/null
+++ b/src/templates/acc/charts/accounts/edit.tpl
@@ -0,0 +1,28 @@
+{include file="_head.tpl" title="Modifier un compte" current="acc/years"}
+
+{include file="acc/charts/accounts/_nav.tpl" current="new"}
+
+{form_errors}
+
+
+
+ {if !$can_edit}
+
+ Il n'est pas possible de modifier le libellé, le numéro ou la position de ce compte car il {if $account.user}est utilisé dans des exercices clôturés{else}fait partie du plan comptable officiel{/if}.
+ Pour pouvoir modifier ce compte pour un nouvel exercice, il est conseillé de créer un nouveau plan comptable en y recopiant l'ancien plan comptable.
+
+ {/if}
+
+
+ Modifier un compte
+ {include file="acc/charts/accounts/_account_form.tpl" create=false}
+
+
+
+ {csrf_field key="acc_accounts_edit_%s"|args:$account.id}
+ {button type="submit" name="edit" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/accounts/index.tpl b/src/templates/acc/charts/accounts/index.tpl
new file mode 100644
index 0000000..b54a426
--- /dev/null
+++ b/src/templates/acc/charts/accounts/index.tpl
@@ -0,0 +1,59 @@
+{include file="_head.tpl" title=$chart.label current="acc/years"}
+
+{include file="acc/charts/accounts/_nav.tpl" current="favorites"}
+
+
+
+
+ {button shape="delete" type="reset" title="Effacer la recherche"}
+ {* We can't use input type="search" because Firefox sucks *}
+
+
+
+
+ Cette liste regroupe les comptes de banque, caisse, attente, tiers, dépense, recette ou bénévolat qui sont soit marqués comme favori, soit ajoutés manuellement, soit déjà utilisés dans un exercice.
+
+
+
+{foreach from=$accounts_grouped item="group"}
+
+
+ {$group.label}
+
+ {if !$chart.archived && $group.type}
+ {linkbutton label="Ajouter un compte" shape="plus" href="!acc/charts/accounts/new.php?id=%d&type=%d&%s"|args:$chart.id,$group.type,$types_arg target=$dialog_target}
+ {/if}
+
+
+
+ {foreach from=$group.accounts item="account"}
+
+ {$account.code}
+ {$account.label}
+ {$account.description}
+
+ bookmark ? 'check' : 'uncheck';
+ $title = $account->bookmark ? 'Ôter des favoris' : 'Marquer comme favori';
+ ?>
+ {button shape=$shape name="bookmark[%d]"|args:$account.id value=$account.bookmark label="Favori" title=$title type="submit"}
+
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$chart.archived}
+ {if (!$chart->code || $account->user) && $account->canDelete()}
+ {linkbutton shape="delete" label="Supprimer" href="!acc/charts/accounts/delete.php?id=%d&%s"|args:$account.id,$types_arg target=$dialog_target}
+ {/if}
+ {linkbutton shape="edit" label="Modifier" href="!acc/charts/accounts/edit.php?id=%d&%s"|args:$account.id,$types_arg target=$dialog_target}
+ {/if}
+
+
+ {/foreach}
+
+{/foreach}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/accounts/new.tpl b/src/templates/acc/charts/accounts/new.tpl
new file mode 100644
index 0000000..8587727
--- /dev/null
+++ b/src/templates/acc/charts/accounts/new.tpl
@@ -0,0 +1,140 @@
+{include file="_head.tpl" title="Nouveau compte" current="acc/years"}
+
+{include file="acc/charts/accounts/_nav.tpl" current="new"}
+
+{form_errors}
+
+{if !isset($account->type)}
+
+
+
+ Créer un nouveau compte
+
+ {foreach from=$types_create item="t" key="v"}
+ {input type="radio-btn" name="type" value=$v label=$t.label help=$t.help}
+ {/foreach}
+
+
+
+
+ {button type="submit" label="Continuer" shape="right" class="main"}
+
+
+
+{elseif $ask && $ask->isListedAsFavourite()}
+
+
+
+ Créer un sous-compte ?
+
+
+
Vous avez sélectionné le compte suivant :
+
+
{$ask.code} — {$ask.label}
+
+
+
+ Ce compte fait déjà partie de la liste des comptes favoris.
+ Vous pouvez créer un sous-compte pour détailler les écritures, si besoin.
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" shape="right" name="from" value=$ask.id label="Créer un sous-compte" class="main"}
+
+
+
+
+
+{elseif $ask}
+
+
+
+ Marquer comme favori, ou créer un sous-compte ?
+
+
+
Vous avez sélectionné le compte suivant :
+
+
{$ask.code} — {$ask.label}
+
+
+
+ Si ce compte vous convient tel quel, vous pouvez l'ajouter à vos comptes favoris, il apparaîtra ainsi toujours dans les listes de comptes.
+ Sinon vous pouvez créer un sous-compte pour plus de détails.
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" shape="star" name="toggle_bookmark" value=$ask.id label="Ajouter ce compte à mes favoris" class="main"}
+ — ou —
+ {button type="submit" shape="right" name="from" value=$ask.id label="Créer un sous-compte" class="main"}
+
+
+
+
+
+
+{elseif !empty($missing)}
+
+
+
+ Comptes disponibles
+
+ {button type="submit" shape="right" name="from" value="" label="Aucun compte ne correspond" class="main"}
+
+ Est-ce que le compte dont vous avez besoin est dans cette liste ?
+
+
+ Il est important de respecter le plan comptable :
+ pour cela il faut choisir le compte correspondant au besoin.
+ Si nécessaire, il sera possible de créer un sous-compte plus précis à l'étape suivante.
+
+
+
+
+ {foreach from=$missing item="item"}
+
+ {if $item.already_listed}{icon shape="star" title="Ce compte est déjà favori"}{/if}
+ {$item.code}
+ {linkbutton href="?id=%d&type=%d&ask=%d&%s"|args:$account.id_chart,$account.type,$item.id,$types_arg label=$item.label}
+ {if $item.description}{$item.description|escape|nl2br} {/if}
+
+
+ {linkbutton href="?id=%d&type=%d&ask=%d&%s"|args:$account.id_chart,$account.type,$item.id,$types_arg label="Sélectionner" shape="right"}
+
+
+ {/foreach}
+
+
+
+
+
+
+
+ {button type="submit" shape="right" name="from" value="" label="Aucun compte ne correspond" class="main"}
+
+
+
+
+{else}
+
+
+
+
+ Créer un nouveau compte
+ {include file="acc/charts/accounts/_account_form.tpl" can_edit=true create=true}
+
+
+
+ {csrf_field key=$csrf_key}
+ {if $from}
+
+ {/if}
+ {button type="submit" name="save" label="Créer" shape="right" class="main"}
+
+
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/accounts/selector.tpl b/src/templates/acc/charts/accounts/selector.tpl
new file mode 100644
index 0000000..b353ad1
--- /dev/null
+++ b/src/templates/acc/charts/accounts/selector.tpl
@@ -0,0 +1,95 @@
+{include file="_head.tpl" title="Sélectionner un compte"}
+
+
+
+
+ {if !$chart.archived && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+
+ {linkbutton href=$new_url label="Ajouter un compte" shape="plus"}
+ {linkbutton label="Modifier les comptes" href=$edit_url shape="edit"}
+
+ {/if}
+
+
+
+
+
+
+{if empty($grouped_accounts) && empty($accounts)}
+
+ Il n'existe aucun compte dans la catégorie « {$targets_names} » dans le plan comptable.
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+
+ Il faut modifier le plan comptable pour ajouter un compte de cette catégorie et pouvoir le sélectionner ensuite.
+
+ {/if}
+
+
+{elseif isset($grouped_accounts)}
+
+ {foreach from=$grouped_accounts item="group"}
+
{$group.label}
+
+ {if count($group.accounts)}
+
+
+ {foreach from=$group.accounts item="account"}
+
+ {if $account.bookmark}{icon shape="star" title="Compte favori"}{/if}
+ {$account.code}
+ {$account.label}
+ {$account.description}
+
+ code : $account->id; ?>
+ {button shape="right" value=$v data-label="%s — %s"|args:$account.code,$account.label label="Sélectionner"}
+
+
+
+ {/foreach}
+
+
+ {else}
+
Le plan comptable ne comporte aucun compte de ce type.
+ {/if}
+ {/foreach}
+ {if $index == 1 && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+
+ Il faut modifier le plan comptable pour ajouter un compte dans une catégorie et pouvoir le sélectionner ensuite.
+
+ {/if}
+
+
+{else}
+
+
+
+ {foreach from=$accounts item="account"}
+
+ {if $account.bookmark}{icon shape="star" title="Compte favori"}{/if}
+ {$account.code}
+ {$account.label}
+
+ code : $account->id; ?>
+ {button shape="right" value=$v data-label="%s — %s"|args:$account.code,$account.label label="Sélectionner"}
+
+
+ {/foreach}
+
+
+
+{/if}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/delete.tpl b/src/templates/acc/charts/delete.tpl
new file mode 100644
index 0000000..142d10f
--- /dev/null
+++ b/src/templates/acc/charts/delete.tpl
@@ -0,0 +1,9 @@
+{include file="_head.tpl" title="Supprimer un plan comptable" current="acc/years"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ce plan comptable ?"
+ warning="Êtes-vous sûr de vouloir supprimer le plan comptable « %s » ?"|args:$chart.label
+ csrf_key="acc_charts_delete_%s"|args:$chart.id
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/edit.tpl b/src/templates/acc/charts/edit.tpl
new file mode 100644
index 0000000..2154132
--- /dev/null
+++ b/src/templates/acc/charts/edit.tpl
@@ -0,0 +1,23 @@
+{include file="_head.tpl" title="Modifier un plan comptable" current="acc/years"}
+
+{form_errors}
+
+
+
+ Modifier un plan comptable
+
+ {input type="text" name="label" label="Libellé" required=1 source=$chart}
+ {if !$chart.code && !$chart.country}
+ {include file="./_country_input.tpl"}
+ {/if}
+ Archivage
+ {input type="checkbox" name="archived" value="1" source=$chart label="Plan comptable archivé" help="Ce plan comptable ne pourra plus être modifié ni utilisé dans un nouvel exercice"}
+
+
+ {csrf_field key="acc_charts_edit_%d"|args:$chart.id}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/charts/index.tpl b/src/templates/acc/charts/index.tpl
new file mode 100644
index 0000000..395ce28
--- /dev/null
+++ b/src/templates/acc/charts/index.tpl
@@ -0,0 +1,125 @@
+{include file="_head.tpl" title="Gestion des plans comptables" current="acc/years"}
+
+{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {include file="./_nav.tpl" current="charts"}
+{/if}
+
+{if $_GET.msg == 'OPEN'}
+
+ Il n'existe aucun exercice ouvert.
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ Merci d'en créer un nouveau pour pouvoir saisir des écritures.
+ {/if}
+
+{/if}
+
+{form_errors}
+
+{if count($list)}
+
+
+ Pays
+ Libellé
+ Type
+ Archivé
+
+
+
+ {foreach from=$list item="item"}
+
+ {if $item.country}{$item.country|get_country_name}{else}-Autre-{/if}
+ {$item.label}
+ {if $item.code}Officiel{else}Personnel{/if}
+ {if $item.archived}Archivé {/if}
+
+ {if $item.country}
+ {linkbutton shape="star" label="Comptes usuels" href="!acc/charts/accounts/?id=%d"|args:$item.id}
+ {/if}
+ {linkbutton shape="menu" label="Tous les comptes" href="!acc/charts/accounts/all.php?id=%d"|args:$item.id}
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {linkbutton shape="edit" label="Modifier" href="!acc/charts/edit.php?id=%d"|args:$item.id target="_dialog"}
+ {if $item->canDelete()}
+ {linkbutton shape="delete" label="Supprimer" href="!acc/charts/delete.php?id=%d"|args:$item.id target="_dialog"}
+ {/if}
+ {/if}
+ {exportmenu class="menu-btn-right" href="export.php?id=%d"|args:$item.id suffix="format="}
+
+
+ {/foreach}
+
+
+{/if}
+
+{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+
+
+ Créer un nouveau plan comptable
+
+ {input type="radio-btn" name="type" value="install" label="Ajouter un autre plan comptable officiel"}
+ {input type="radio-btn" name="type" value="copy" label="Recopier un plan comptable pour le modifier"}
+ {input type="radio-btn" name="type" value="import" label="Importer un plan comptable personnel" help="À partir d'un tableau (CSV, Office, etc.)"}
+
+
+
+
+ Créer un nouveau plan comptable à partir d'un existant
+
+ {input type="select_groups" name="copy" options=$charts_grouped label="Recopier depuis" required=1 default=$from}
+ {input type="text" name="label" label="Libellé" required=1}
+ {include file="./_country_input.tpl"}
+
+
+
+ Ajouter un nouveau plan comptable officiel
+
+ {input type="select" name="install" label="Plan comptable" required=true options=$install_list}
+
+
+
+
+ Importer un plan comptable personnel
+
+ {input type="text" name="label" label="Libellé" required=1}
+ {include file="./_country_input.tpl" name="import_country"}
+ {input type="file" name="file" label="Fichier à importer" accept="csv" required=1}
+ {* FIXME utiliser _csv_help.tpl ici ! *}
+ Règles à suivre pour créer le fichier :
+
+ Le fichier doit comporter les colonnes suivantes : {$columns}
+ Suggestion : pour obtenir un exemple du format attendu, faire un export d'un plan comptable existant
+
+
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="new" label="Créer" shape="right" class="main"}
+
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/index.tpl b/src/templates/acc/index.tpl
new file mode 100644
index 0000000..a901bc1
--- /dev/null
+++ b/src/templates/acc/index.tpl
@@ -0,0 +1,91 @@
+{include file="_head.tpl" title="Comptabilité" current="acc"}
+
+{if !empty($all_years)}
+
+
+ Recherche rapide
+
+
+ {input type="select" name="year" options=$all_years default=$first_year}
+ {button type="submit" shape="search" label="Chercher"}
+
+
+ Indiquer un numéro de compte, un numéro d'écriture précédé par le signe hash (#1234
), un montant précédé par le signe égal (=62,41
) ou une date (JJ/MM/AAAA
), sinon la recherche sera effectuée sur le libellé ou la pièce comptable.
+
+
+
+{/if}
+
+{foreach from=$years item="year"}
+
+ {$year.label} —
+ Du {$year.start_date|date_short} au {$year.end_date|date_short}
+
+
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {linkbutton shape="upload" href="!acc/years/import.php?year=%d"|args:$year.id label="Import & export"}
+ {/if}
+ {linkbutton shape="search" href="!acc/search.php?year=%d"|args:$year.id label="Recherche"}
+
+
+
+
+ {if $year.nb_transactions > 3}
+
+ {foreach from=$graphs key="url" item="label"}
+
+
+ {$label}
+
+ {/foreach}
+
+ {else}
+ Il n'y a pas encore suffisamment d'écritures dans cet exercice pour pouvoir afficher les statistiques.
+ {linkbutton label="Saisir une nouvelle écriture" shape="plus" href="transactions/new.php?set_year=%d"|args:$year.id}
+ {/if}
+
+ {if $year.nb_transactions}
+ id]; ?>
+ Dernières écritures
+ {include file="common/dynamic_list_head.tpl" check=false disable_user_ordering=true}
+ {foreach from=$list->iterate() item="line"}
+
+ {$line.type_label}
+ #{$line.id}
+ {$line.date|date_short}
+ {$line.change|abs|raw|money}
+ {$line.reference}
+ {$line.label}
+ {$line.line_reference}
+ {foreach from=$line.project_code item="code" key="id"}{$code} {/foreach}
+ {if isset($line.locked)}
+ {if $line.locked}{icon title="Écriture verrouillée" shape="lock"}{/if}
+ {/if}
+ {if $line.files}{$line.files}{/if}
+
+ {linkbutton href="!acc/transactions/details.php?id=%d"|args:$line.id label="Détails" shape="search"}
+
+
+ {/foreach}
+
+
+ {/if}
+
+
+
+{foreachelse}
+
+ Il n'y a aucun exercice ouvert en cours.
+ {linkbutton label="Ouvrir un nouvel exercice" shape="plus" href="!acc/years/new.php"}
+
+{/foreach}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/projects/_list.tpl b/src/templates/acc/projects/_list.tpl
new file mode 100644
index 0000000..07b2073
--- /dev/null
+++ b/src/templates/acc/projects/_list.tpl
@@ -0,0 +1,63 @@
+
+ {if !empty($caption)}{$caption} {/if}
+
+
+ Projet
+
+ Charges
+ Produits
+ Résultat
+ Débits
+ Crédits
+ Solde
+
+
+ {foreach from=$list item="parent"}
+
+
+
+ {$parent.label}{if $parent.archived} (archivé) {/if}
+ {if $parent.description}{$parent.description|escape|nl2br}
{/if}
+ {if !$table_export && !$by_year && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+
+ {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$parent.id target="_dialog"}
+ {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$parent.id target="_dialog"}
+
+ {/if}
+ {if !$table_export && $by_year}
+
+ {linkbutton href="!acc/reports/ledger.php?project=all&year=%d"|args:$parent.id_year label="Grand livre analytique"}
+
+ {/if}
+
+
+ {foreach from=$parent.items item="item"}
+ sum_revenue - $item->sum_expense; ?>
+
+ {$item.label}{if $item.archived} (archivé) {/if}
+
+ {if !$table_export}
+ id_project ?: 'all';
+ ?>
+
+ Graphiques
+ | Balance générale
+ | Journal général
+ | Grand livre
+ | Compte de résultat
+ | Bilan
+
+ {/if}
+
+ {$item.sum_expense|raw|money}
+ {$item.sum_revenue|raw|money}
+ {$result|raw|money:true:true}
+ {$item.debit|raw|money:false}
+ {$item.credit|raw|money:false}
+ {$item.sum|raw|money:false}
+
+ {/foreach}
+
+ {/foreach}
+
diff --git a/src/templates/acc/projects/_nav.tpl b/src/templates/acc/projects/_nav.tpl
new file mode 100644
index 0000000..f54917a
--- /dev/null
+++ b/src/templates/acc/projects/_nav.tpl
@@ -0,0 +1,25 @@
+{if !$dialog}
+
+
+
+
+ {if $current == 'index'}
+ {exportmenu class="menu-btn-right" xlsx=true suffix="_export="}
+ {/if}
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {linkbutton label="Créer un nouveau projet" href="edit.php" shape="plus" target="_dialog"}
+ {/if}
+
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+
+ {link href="!acc/projects/" label="Liste des projets"}
+ {link href="!acc/projects/config.php" label="Configuration"}
+
+ {/if}
+
+{/if}
\ No newline at end of file
diff --git a/src/templates/acc/projects/config.tpl b/src/templates/acc/projects/config.tpl
new file mode 100644
index 0000000..317e36f
--- /dev/null
+++ b/src/templates/acc/projects/config.tpl
@@ -0,0 +1,28 @@
+{include file="_head.tpl" title="Projets - configuration" current="acc/years"}
+
+{include file="./_nav.tpl" current="config" order_code=null}
+
+{if $_GET.msg == 'SAVED'}
+
+ La configuration a été enregistrée.
+
+{/if}
+
+{form_errors}
+
+
+
+ Configuration des projets
+
+ Lors de la saisie d'une écriture simplifiée (recette ou dépense), affecter le projet analytique…
+ {input type="radio" name="analytical_set_all" value="1" label="à tous les comptes" source=$config help="permet de suivre la caisse, banque, comptes de tiers, etc. dans un projet"}
+ {input type="radio" name="analytical_set_all" value="0" label="seulement aux comptes de charge et de produit" source=$config}
+
+
+
+ {csrf_field key="save_config"}
+ {button type="submit" name="save_config" label="Enregistrer" shape="right" class="main"}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/projects/delete.tpl b/src/templates/acc/projects/delete.tpl
new file mode 100644
index 0000000..aa4e370
--- /dev/null
+++ b/src/templates/acc/projects/delete.tpl
@@ -0,0 +1,8 @@
+{include file="_head.tpl" title="Supprimer un projet" current="acc/years"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ce projet ?"
+ warning="Êtes-vous sûr de vouloir supprimer le projet « %s » ?"|args:$project.label
+ info="Le contenu des écritures comptables ne sera pas modifiées, seule l'affectation à ce projet sera supprimée."}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/projects/edit.tpl b/src/templates/acc/projects/edit.tpl
new file mode 100644
index 0000000..125a999
--- /dev/null
+++ b/src/templates/acc/projects/edit.tpl
@@ -0,0 +1,25 @@
+{include file="_head.tpl" title="Projet" current="acc/years"}
+
+{include file="./_nav.tpl" current=null}
+
+{form_errors}
+
+
+
+ {if $project->exists()}Modifier un projet{else}Créer un projet{/if}
+
+ {input type="text" required=true name="label" label="Libellé du projet" source=$project}
+ {input type="text" name="code" label="Code du projet" source=$project help="Utile pour retrouver le projet rapidement. Ne peut contenir que des chiffres et des lettres majuscules." pattern="[0-9A-Z_]+"}
+ {input type="textarea" name="description" label="Description du projet" source=$project}
+ Archivage
+ {input type="checkbox" name="archived" label="Archiver ce projet" value=1 source=$project help="Si coché, ce projet ne sera plus proposé dans la sélection de projets lors de la saisie d'une écriture."}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/projects/index.tpl b/src/templates/acc/projects/index.tpl
new file mode 100644
index 0000000..274ab31
--- /dev/null
+++ b/src/templates/acc/projects/index.tpl
@@ -0,0 +1,38 @@
+{include file="_head.tpl" title="Projets" current="acc/years"}
+
+{include file="./_nav.tpl" current='index'}
+
+
+
+{if $projects_count}
+
+ {include file="./_list.tpl"}
+
+{else}
+ Il n'existe pas encore de projet. {linkbutton label="Créer un nouveau projet" href="edit.php" shape="plus" target="_dialog"}
+ Les projets (aussi appelés comptabilité analytique) permettent de suivre le budget d'une activité ou d'un projet. {linkbutton shape="help" label="Aide sur les projets" target="_dialog" href=$help_pattern_url|args:"comptabilite-analytique"}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/reports/_header.tpl b/src/templates/acc/reports/_header.tpl
new file mode 100644
index 0000000..d52b2d0
--- /dev/null
+++ b/src/templates/acc/reports/_header.tpl
@@ -0,0 +1,103 @@
+
+
+ {if !empty($allow_filter) && isset($year) && $criterias.before && $criterias.after}
+
+ Attention, seules les écritures du {$criterias.after|date_short} au {$criterias.before|date_short} sont prises en compte.
+
+ {/if}
diff --git a/src/templates/acc/reports/_journal.tpl b/src/templates/acc/reports/_journal.tpl
new file mode 100644
index 0000000..7cbb942
--- /dev/null
+++ b/src/templates/acc/reports/_journal.tpl
@@ -0,0 +1,46 @@
+
+
+
+ N°
+ Pièce comptable
+ Date
+ Libellé
+ {if !empty($with_linked_users)}Membres associés {/if}
+ Comptes
+ Débit
+ Crédit
+ Libellé ligne
+ Réf. ligne
+ {if isset($criterias) && $criterias.project}Cumul {/if}
+ {if !empty($action)} {/if}
+
+
+ {foreach from=$journal item="transaction"}
+
+
+ {if $transaction.id}#{$transaction.id} {/if}
+ {$transaction.reference}
+ {$transaction.date|date_short}
+ {$transaction.label}
+ {if !empty($with_linked_users)}{$transaction.linked_users} {/if}
+ {foreach from=$transaction.lines key="k" item="line"}
+ {if $k > 0} {/if}
+ {$line.account_code} - {$line.account_label}
+ {$line.debit|raw|money}
+ {$line.credit|raw|money}
+ {$line.label}
+ {$line.reference}
+ {if isset($criterias) && $criterias.project}
+ debit + $line->credit; ?>
+ {$running_sum|raw|money:false}
+ {/if}
+ {if !empty($action) && $k == 0}
+
+ {linkbutton href=$action.href|args:$transaction.id shape=$action.shape label=$action.label}
+
+ {/if}
+
+ {/foreach}
+
+ {/foreach}
+
\ No newline at end of file
diff --git a/src/templates/acc/reports/_journal_diff.tpl b/src/templates/acc/reports/_journal_diff.tpl
new file mode 100644
index 0000000..67ed741
--- /dev/null
+++ b/src/templates/acc/reports/_journal_diff.tpl
@@ -0,0 +1,148 @@
+
+
+
+ N°
+ Pièce comptable
+ Date
+ Libellé
+ {if !empty($with_linked_users)}Membres associés {/if}
+ Comptes
+ Débit
+ Crédit
+ Libellé ligne
+ Réf. ligne
+ Projet
+
+
+ {foreach from=$journal item="t"}
+ getLines());
+ ?>
+
+
+ {if $transaction.id}#{$transaction.id} {/if}
+
+ {if $diff.transaction.reference}
+ {$diff.transaction.reference[0]}
+ {$diff.transaction.reference[1]}
+ {else}
+ {$transaction.reference}
+ {/if}
+
+
+ {if $diff.transaction.date}
+ {$diff.transaction.date[0]|date_short}
+ {$diff.transaction.date[1]|date_short}
+ {else}
+ {$transaction.date|date_short}
+ {/if}
+
+
+ {if $diff.transaction.label}
+ {$diff.transaction.label[0]}
+ {$diff.transaction.label[1]}
+ {else}
+ {$transaction.label}
+ {/if}
+
+ {if !empty($with_linked_users)}
+
+ {if $diff.linked_users}
+ {$diff.linked_users[0]}
+ {$diff.linked_users[1]}
+ {else}
+ {$t.linked_users}
+ {/if}
+
+ {/if}
+ {if $diff.lines_removed || $diff.lines_new || $diff.lines}
+ {foreach from=$diff.lines_removed item="line"}
+ {$line.account}
+ {$line.debit|raw|money}
+ {$line.credit|raw|money}
+ {$line.label}
+ {$line.reference}
+ {$line.project}
+
+
+ {/foreach}
+ {foreach from=$diff.lines_new item="line"}
+ {$line.account}
+ {$line.debit|raw|money}
+ {$line.credit|raw|money}
+ {$line.label}
+ {$line.reference}
+ {$line.project}
+
+
+ {/foreach}
+ {foreach from=$diff.lines item="line"}
+
+ {if $line.diff.account}
+ {$line.diff.account[0]}
+ {$line.diff.account[1]}
+ {else}
+ {$line.account}
+ {/if}
+
+
+ {if $line.diff.debit}
+ {$line.diff.debit[0]|raw|money:false}
+ {$line.diff.debit[1]|raw|money:false}
+ {else}
+ {$line.debit|raw|money}
+ {/if}
+
+
+ {if $line.diff.credit}
+ {$line.diff.credit[0]|raw|money:false}
+ {$line.diff.credit[1]|raw|money:false}
+ {else}
+ {$line.credit|raw|money}
+ {/if}
+
+
+ {if $line.diff.label}
+ {$line.diff.label[0]}
+ {$line.diff.label[1]}
+ {else}
+ {$line.label}
+ {/if}
+
+
+ {if $line.diff.reference}
+ {$line.diff.reference[0]}
+ {$line.diff.reference[1]}
+ {else}
+ {$line.reference}
+ {/if}
+
+
+ {if $line.diff.project}
+ {$line.diff.project[0]}
+ {$line.diff.project[1]}
+ {else}
+ {$line.project}
+ {/if}
+
+
+
+ {/foreach}
+ {else}
+ {foreach from=$transaction->getLinesWithAccounts() item="line"}
+ {$line.account_code} - {$line.account_label}
+ {$line.debit|raw|money}
+ {$line.credit|raw|money}
+ {$line.label}
+ {$line.reference}
+ {$line.project}
+
+
+ {/foreach}
+ {/if}
+
+
+ {/foreach}
+
\ No newline at end of file
diff --git a/src/templates/acc/reports/_statement.tpl b/src/templates/acc/reports/_statement.tpl
new file mode 100644
index 0000000..438010c
--- /dev/null
+++ b/src/templates/acc/reports/_statement.tpl
@@ -0,0 +1,108 @@
+body_left), count($statement->body_right));
+?>
+
+ {if !empty($caption)}{$caption} {/if}
+
+
+ {$statement.caption_left}
+
+ {$statement.caption_right}
+
+ {if !empty($year2)}
+
+
+
+ {$year->label_years()}
+ {$year2->label_years()}
+ Écart
+
+
+
+ {$year->label_years()}
+ {$year2->label_years()}
+ Écart
+
+ {/if}
+
+
+ body_left[$i] ?? null;
+ $class = $i % 2 == 0 ? 'odd' : 'even';
+ ?>
+
+ {if $row}
+
+ {if !empty($year) && $row.id}
+ {link href="!acc/accounts/journal.php?id=%d&year=%d"|args:$row.id,$year.id label=$row.code}
+ {else}
+ {$row.code}
+ {/if}
+
+ {$row.label}
+ {$row.balance|raw|money:false}
+ {if isset($year2)}
+ {$row.balance2|raw|money:false}
+ {$row.change|raw|money:false:true}
+ {/if}
+ {else}
+
+ {/if}
+
+ body_right[$i] ?? null; ?>
+ {if $row}
+
+ {if !empty($year) && $row.id}
+ {link href="!acc/accounts/journal.php?id=%d&year=%d"|args:$row.id,$year.id label=$row.code}
+ {else}
+ {$row.code}
+ {/if}
+
+ {$row.label}
+ {$row.balance|raw|money:false}
+ {if isset($year2)}
+ {$row.balance2|raw|money:false}
+ {$row.change|raw|money:false:true}
+ {/if}
+ {else}
+
+ {/if}
+
+
+
+
+
+
+ foot_left), count($statement->foot_right)); ?>
+ foot_left[$i] ?? null;
+ $class = $i % 2 == 0 ? 'odd' : 'even';
+ ?>
+
+ {if $row}
+ {$row.label}
+ {$row.balance|raw|money:false}
+ {if $row.balance2 || $row.change}
+ {$row.balance2|raw|money:false}
+ {$row.change|raw|money:false:true}
+ {/if}
+ {else}
+
+ {/if}
+
+ foot_right[$i] ?? null; ?>
+ {if $row}
+ {$row.label}
+ {$row.balance|raw|money:false}
+ {if $row.balance2 || $row.change}
+ {$row.balance2|raw|money:false}
+ {$row.change|raw|money:false:true}
+ {/if}
+ {else}
+
+ {/if}
+
+
+
+
diff --git a/src/templates/acc/reports/_statement_table.tpl b/src/templates/acc/reports/_statement_table.tpl
new file mode 100644
index 0000000..b918cb9
--- /dev/null
+++ b/src/templates/acc/reports/_statement_table.tpl
@@ -0,0 +1,35 @@
+
+ {if !empty($caption)}
+ {$caption}
+ {/if}
+ {if !empty($year2)}
+
+
+
+
+ {$year2->label_years()}
+ {$year->label_years()}
+ Écart
+
+
+ {/if}
+
+ {foreach from=$accounts item="account"}
+
+
+ {if !empty($year) && $account.id}{$account.code}
+ {else}{$account.code}
+ {/if}
+
+ {$account.label}
+ {if isset($year2)}
+ {$account.balance2|raw|money:false}
+ {/if}
+ {$account.balance|raw|money:false}
+ {if isset($year2)}
+ {$account.change|raw|money:false:true}
+ {/if}
+
+ {/foreach}
+
+
\ No newline at end of file
diff --git a/src/templates/acc/reports/balance_sheet.tpl b/src/templates/acc/reports/balance_sheet.tpl
new file mode 100644
index 0000000..c4fa6d0
--- /dev/null
+++ b/src/templates/acc/reports/balance_sheet.tpl
@@ -0,0 +1,18 @@
+{include file="_head.tpl" title="%sBilan"|args:$project_title current="acc/years"}
+
+{include file="acc/reports/_header.tpl" current="balance_sheet" title="Bilan" allow_compare=true allow_filter=true}
+
+Le bilan représente une image de votre organisation : l'actif étant ce que l'organisation possède comme ressources (immeubles, comptes en banque, outillage, etc.), et le passif représente comment l'organisation a obtenu ces ressources (dettes, fonds de réserve, résultat…). En gros : à gauche = ce qu'on a, à droite = comment on l'a obtenu.
+
+{if $balance.sums.asset != $balance.sums.liability}
+
+ Le bilan n'est pas équilibré !
+ Vérifiez que vous n'avez pas oublié de reporter des soldes depuis le précédent exercice.
+
+{/if}
+
+{include file="acc/reports/_statement.tpl" statement=$balance}
+
+Toutes les écritures sont libellées en {$config.currency}.
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/reports/graphs.tpl b/src/templates/acc/reports/graphs.tpl
new file mode 100644
index 0000000..fba0ea5
--- /dev/null
+++ b/src/templates/acc/reports/graphs.tpl
@@ -0,0 +1,24 @@
+{include file="_head.tpl" title="%sGraphiques"|args:$project_title current="acc/years"}
+
+{include file="acc/reports/_header.tpl" current="graphs" title="Graphiques" allow_filter=false}
+
+{if $nb_transactions < 3}
+ Il n'y a pas encore suffisamment d'écritures dans cet exercice pour pouvoir afficher les statistiques.
+{else}
+
+
+ {foreach from=$graphs key="url" item="label"}
+
+
+ {$label}
+
+ {/foreach}
+
+
+
+
+ En raison des arrondis, la somme des pourcentages peut ne pas être égale à 100%.
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/reports/journal.tpl b/src/templates/acc/reports/journal.tpl
new file mode 100644
index 0000000..9cb8d0c
--- /dev/null
+++ b/src/templates/acc/reports/journal.tpl
@@ -0,0 +1,9 @@
+{include file="_head.tpl" title="%sJournal général"|args:$project_title current="acc/years"}
+
+{include file="acc/reports/_header.tpl" current="journal" title="Journal général" allow_filter=true}
+
+{include file="acc/reports/_journal.tpl"}
+
+Toutes les écritures sont libellées en {$config.currency}.
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/reports/ledger.tpl b/src/templates/acc/reports/ledger.tpl
new file mode 100644
index 0000000..1ffa02e
--- /dev/null
+++ b/src/templates/acc/reports/ledger.tpl
@@ -0,0 +1,162 @@
+{if !empty($criterias.projects_only)}
+ {include file="_head.tpl" title="Grand livre analytique" current="acc/years"}
+ {include file="acc/reports/_header.tpl" current="analytical_ledger" title="Grand livre analytique" allow_filter=true}
+{else}
+ {include file="_head.tpl" title="%sGrand livre"|args:$project_title current="acc/years"}
+ {include file="acc/reports/_header.tpl" current="ledger" title="Grand livre" allow_filter=true}
+{/if}
+
+
+
+{if $table_export}
+
+
+
+
+ {if !empty($criterias.projects_only)}
+ Compte
+ {/if}
+ N° pièce
+ Réf. ligne
+ Date
+ Intitulé
+ Débit
+ Crédit
+ Solde
+
+
+{/if}
+
+{foreach from=$ledger item="account"}
+
+ {if $table_export}
+
+
+ {if $account.code}{$account.code} — {/if}{$account.label}
+
+
+ {else}
+
+
+ {if !empty($criterias.projects_only)}
+ id, $account->id_year); ?>
+ {elseif !$criterias.project}
+ id, $account->id_year); ?>
+ {else}
+
+ {/if}
+ {if $link}{/if}
+ {if $account.code}{$account.code} — {/if}{$account.label}
+ {if $link} {/if}
+
+
+
+
+
+
+ {if !empty($criterias.projects_only)}
+ Compte
+ {/if}
+ N° pièce
+ Réf. ligne
+ Date
+ Intitulé
+ Débit
+ Crédit
+ Solde
+
+
+ {/if}
+
+
+
+ {foreach from=$account.lines item="line"}
+
+ #{$line.id}
+ {if !empty($criterias.projects_only)}
+ {link href="!acc/accounts/journal.php?id=%d&year=%d"|args:$line.id_account,$line.id_year label=$line.account_code}
+ {/if}
+ {$line.reference}
+ {$line.line_reference}
+ {$line.date|date_short}
+ {$line.label}{if $line.line_label} ({$line.line_label}) {/if}
+ {$line.debit|raw|money}
+ {$line.credit|raw|money}
+ {$line.running_sum|raw|money:false}
+
+ {/foreach}
+
+
+
+
+
+ Solde final
+ {$account.debit|raw|money}
+ {$account.credit|raw|money}
+ {$account.sum|raw|money:false}
+
+
+ {if $table_export && isset($account->all_debit)}
+
+
+ Totaux
+ {$account.all_debit|raw|money:false}
+ {$account.all_credit|raw|money:false}
+
+
+ {/if}
+
+
+
+ {if !$table_export}
+
+
+
+ {/if}
+
+ {if !$table_export && isset($account->all_debit)}
+
+
+
+
+
+
+
+
+
+ Totaux
+ {$account.all_debit|raw|money:false}
+ {$account.all_credit|raw|money:false}
+
+
+
+
+ {/if}
+
+{/foreach}
+
+{if $table_export}
+
+{/if}
+
+{literal}
+
+{/literal}
+
+Toutes les écritures sont libellées en {$config.currency}.
+
+{include file="_foot.tpl"}
diff --git a/src/templates/acc/reports/statement.tpl b/src/templates/acc/reports/statement.tpl
new file mode 100644
index 0000000..bc2bb20
--- /dev/null
+++ b/src/templates/acc/reports/statement.tpl
@@ -0,0 +1,16 @@
+{include file="_head.tpl" title="%sCompte de résultat"|args:$project_title current="acc/years"}
+
+{include file="acc/reports/_header.tpl" current="statement" title="Compte de résultat" allow_compare=true allow_filter=true}
+
+Le compte de résultat indique les recettes (produits) et dépenses (charges), ainsi que le résultat réalisé.
+
+{include file="acc/reports/_statement.tpl" statement=$general}
+
+{if !empty($volunteering.body_left) || !empty($volunteering.body_right)}
+ Contributions bénévoles en nature
+ {include file="acc/reports/_statement.tpl" statement=$volunteering header=false caption1="Emplois des contributions volontaires en nature" caption2="Contributions volontaires en nature" caption="Contributions bénévoles en nature"}
+{/if}
+
+Toutes les écritures sont libellées en {$config.currency}.
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/reports/trial_balance.tpl b/src/templates/acc/reports/trial_balance.tpl
new file mode 100644
index 0000000..9710c70
--- /dev/null
+++ b/src/templates/acc/reports/trial_balance.tpl
@@ -0,0 +1,44 @@
+{include file="_head.tpl" title="%sBalance générale"|args:$project_title current="acc/years"}
+
+{include file="acc/reports/_header.tpl" current="trial_balance" title="Balance générale" allow_filter=true}
+
+
+
+
+ Numéro
+ Compte
+ Total des débits
+ Total des crédits
+ {if !$simple}
+ Solde débiteur
+ Solde créditeur
+ {else}
+ Solde
+ {/if}
+
+
+
+ {foreach from=$balance item="account"}
+
+
+ {if !empty($year) && !$criterias.project}{$account.code}
+ {else}{$account.code}
+ {/if}
+
+ {$account.label}
+ {$account.debit|raw|money:false}
+ {$account.credit|raw|money:false}
+ {if !$simple}
+ {if $account.balance > 0}{$account.balance|abs|escape|money:false}{/if}
+ {if $account.balance < 0}{$account.balance|abs|escape|money:false}{/if}
+ {else}
+ {if $account.balance !== null}{$account.balance|escape|money:false} {/if}
+ {/if}
+
+ {/foreach}
+
+
+
+Toutes les écritures sont libellées en {$config.currency}. Les lignes grisées correspondent aux comptes soldés.
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/search.tpl b/src/templates/acc/search.tpl
new file mode 100644
index 0000000..fc89f80
--- /dev/null
+++ b/src/templates/acc/search.tpl
@@ -0,0 +1,188 @@
+{include file="_head.tpl" title="Recherche" current="acc"}
+
+
+
+
+
+
+
+{include file="common/search/advanced.tpl" legend="Rechercher des écritures…"}
+
+{if $list !== null}
+ {$list->count()} écritures trouvées pour cette recherche.
+
+ {if $list->count() > 0}
+ {exportmenu form=true name="_dl_export" class="menu-btn-right"}
+ {/if}
+
+ {include file="common/dynamic_list_head.tpl" check=$is_admin use_buttons=true}
+
+
+
+ {foreach from=$list->iterate() item="row"}
+
+ {if $is_admin && $row.id_line && $row.id}
+ {input type="checkbox" name="check[%s]"|args:$row.id_line value=$row.id}
+ {/if}
+ {foreach from=$row key="key" item="value"}
+
+ {if $prev_id == $row.id && !in_array($key, ['debit', 'credit', 'account_code', 'line_label', 'line_reference', 'project_code'])}
+
+ {elseif $key == 'id'}
+
+ {link href="!acc/transactions/details.php?id=%d"|args:$value label="#%d"|args:$value}
+
+ {elseif $key == 'credit' || $key == 'debit'}
+
+
+ {$value|raw|money:false}
+
+ {else}
+
+ {if $key === 'date'}
+ {$value|date_short}
+ {elseif $column.type === 'boolean'}
+ {if $value === null}
+ —
+ {elseif $value}
+ Oui
+ {else}
+ Non
+ {/if}
+ {else}
+ {$value}
+ {/if}
+
+ {/if}
+ {/foreach}
+
+ {if $prev_id != $row.id}
+ {linkbutton shape="search" label="Détails" href="!acc/transactions/details.php?id=%d"|args:$row.id}
+ {/if}
+
+
+ id; ?>
+ {/foreach}
+
+
+ {if $debit !== null || $credit !== null}
+ $v) {
+ if ($key == 'credit' || $key == 'debit') {
+ break;
+ }
+ $span1++;
+ }
+ if ($is_admin) {
+ $span1--;
+ }
+ $span2 = count((array)$row) - $span1;
+ ?>
+
+
+ {if $is_admin} {/if}
+ Totaux de cette page
+ {foreach from=$row key="key" item="value"}
+ {if $key == 'credit' || $key == 'debit'}
+
+
+ {$total|raw|money:false}
+
+ {/if}
+ {/foreach}
+
+ {if $is_admin}
+ {include file="acc/_table_actions.tpl"}
+ {/if}
+
+
+
+ {/if}
+
+
+
+ {$list->getHTMLPagination(true)|raw}
+
+{elseif $count}
+
+ {exportmenu form=true name="_export" class="menu-btn-right"}
+
+ {$count} résultats trouvés pour cette recherche.
+
+
+
+
+
+
+ {if $is_admin}
+
+ {/if}
+ {foreach from=$header item="column"}
+ {$column}
+ {/foreach}
+
+
+
+ {foreach from=$results item="row"}
+
+
+
+ {if $is_admin && $id_column !== false && $id_line_column !== false}
+ {input type="checkbox" name="check[%s]"|args:$id_line value=$id}
+ {elseif $is_admin}
+
+ {/if}
+ {foreach from=$row key="key" item="value"}
+ {if $prev_id == $id && $key === $id_column}
+
+ {elseif $id_column === $key}
+ {link href="!acc/transactions/details.php?id=%d"|args:$value label="#%d"|args:$value}
+ {else}
+ {$value}
+ {/if}
+ {/foreach}
+
+
+ {/foreach}
+
+ {if $is_admin && $id_column !== false && $id_line_column !== false}
+
+
+
+ {include file="acc/_table_actions.tpl"}
+
+
+
+ {/if}
+
+
+{elseif $count === 0}
+
+ Aucun résultat trouvé pour cette recherche.
+
+{/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/_form.tpl b/src/templates/acc/transactions/_form.tpl
new file mode 100644
index 0000000..44d75bc
--- /dev/null
+++ b/src/templates/acc/transactions/_form.tpl
@@ -0,0 +1,135 @@
+type) && !$transaction->exists() && !$transaction->label;
+$is_quick = count(array_intersect_key($_GET, array_flip(['a', 'l', 'd', 't', 'account']))) > 0;
+
+?>
+
+ {form_errors}
+
+
+ Type d'écriture
+
+ {if isset($payoff)}
+ {input type="radio-btn" name="type" value=99 source=$transaction label=$payoff.type_label}
+ {input type="radio-btn" name="type" value=0 source=$transaction label="Avancé"}
+ {else}
+ {foreach from=$types_details item="type"}
+
+ {input type="radio" name="type" value=$type.id source=$transaction label=null}
+
+
+
{$type.label}
+ {if !empty($type.help)}
+
{$type.help}
+ {/if}
+
+
+
+ {/foreach}
+ {/if}
+
+
+
+ {if isset($payoff)}
+
+ {if $payoff.type == $transaction::TYPE_DEBT}Règlement de dette{else}Règlement de créance{/if}
+
+ Écritures d'origine
+ {foreach from=$payoff.transactions item="t"}
+ {link class="num" href="!acc/transactions/details.php?id=%d"|args:$t.id label="#%d"|args:$t.id} — {$t.label} — {$t->sum()|money_currency|raw}
+ {/foreach}
+ {input type="checkbox" name="mark_paid" value="1" default="1" label="Marquer ces écritures comme réglées"}
+
+
+ {/if}
+
+
+ Informations
+
+ {input type="date" name="date" label="Date" required=1 source=$transaction}
+ {input type="text" name="label" label="Libellé" required=1 source=$transaction}
+ {input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc." source=$transaction}
+
+
+ {if !isset($payoff) || !$payoff.multiple}
+ {input type="money" name="amount" label="Montant" required=1 default=$amount}
+ {else}
+ {input type="money" name="amount" label="Montant" required=1 default=$amount disabled=true help="Le montant ne peut être modifié pour le règlement de plusieurs écritures."}
+ {/if}
+
+
+
+ {if !empty($has_reconciled_lines)}
+
+ Attention, cette écriture contient des lignes qui ont été rapprochées. Modifier son montant ou le compte bancaire entraînera la perte du rapprochement.
+
+ {/if}
+
+ {if isset($payoff)}
+
+ {$payoff.type_label}
+
+ {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$payoff.targets,$chart.id name="payoff_account" label="Compte de règlement" required=1}
+
+
+ {/if}
+
+ {foreach from=$types_details item="type"}
+
+ {$type.label}
+ {if $type.id == $transaction::TYPE_ADVANCED}
+ {* Saisie avancée *}
+ {include file="acc/transactions/_lines_form.tpl" chart_id=$chart.id}
+ {else}
+
+ {foreach from=$type.accounts key="key" item="account"}
+ {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$account.targets_string,$chart.id name=$account.selector_name label=$account.label required=1 default=$account.selector_value}
+ {/foreach}
+
+ {/if}
+
+ {/foreach}
+
+
+ Détails facultatifs
+
+ {input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." default=$transaction->payment_reference()}
+
+
+ {input type="list" multiple=true name="users" label="Membres associés" target="!users/selector.php" default=$linked_users}
+ {input type="textarea" name="notes" label="Remarques" rows=4 cols=30 source=$transaction}
+
+ {if !isset($payoff)}
+
+ {input type="list" name="linked" label="Écritures liées" default=$linked_transactions target="!acc/transactions/selector.php" multiple=true}
+
+ {/if}
+
+ {if !empty($projects)}
+ {input type="select" name="id_project" label="Projet (analytique)" options=$projects default=$id_project default_empty="— Aucun —"}
+ {/if}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
+
+ Vous pourrez ajouter des fichiers à cette écriture une fois qu'elle aura été enregistrée.
+
+{/if}
+
+
+
+
+{/literal}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/_lines_form.tpl b/src/templates/acc/transactions/_lines_form.tpl
new file mode 100644
index 0000000..360e41c
--- /dev/null
+++ b/src/templates/acc/transactions/_lines_form.tpl
@@ -0,0 +1,47 @@
+
+
+
+
+
+ Compte
+ Débit
+ Crédit
+ Libellé ligne
+ Réf. ligne
+ {if count($projects) > 0}
+ Projet
+ {/if}
+
+
+
+
+ {foreach from=$lines key="k" item="line"}
+
+
+ {input type="list" target="!acc/charts/accounts/selector.php?year=%d"|args:$transaction.id_year name="lines[account_selector][]" default=$line.account_selector}
+
+ {input type="money" name="lines[debit][]" default=$line.debit size=5}
+ {input type="money" name="lines[credit][]" default=$line.credit size=5}
+ {input type="text" name="lines[label][]" default=$line.label class="full-width"}
+ {input type="text" name="lines[reference][]" default=$line.reference size=10 class="full-width"}
+ {if count($projects) > 0}
+ {input default=$line.id_project type="select" name="lines[id_project][]" options=$projects default_empty="— Aucun —"}
+ {/if}
+ {button label="Enlever" title="Enlever la ligne" shape="minus" min="2" name="remove_line"}
+
+ {/foreach}
+
+
+
+ Total
+ {input type="money" name="debit_total" readonly="readonly" tabindex="-1" }
+ {input type="money" name="credit_total" readonly="readonly" tabindex="-1" }
+
+ {button label="Ajouter" title="Ajouter une ligne" shape="plus"}
+
+
+
diff --git a/src/templates/acc/transactions/_pending_message.tpl b/src/templates/acc/transactions/_pending_message.tpl
new file mode 100644
index 0000000..7d9379c
--- /dev/null
+++ b/src/templates/acc/transactions/_pending_message.tpl
@@ -0,0 +1,8 @@
+
+ {
+ {Il y a une dette ou créance à régler dans un autre exercice.}
+ {Il y a %n dettes ou créances à régler dans d'autres exercices.}
+ n=$pending_count
+ }
+ {linkbutton href="!acc/transactions/pending.php" label="Voir les dettes et créances en attente" shape="menu"}
+
\ No newline at end of file
diff --git a/src/templates/acc/transactions/action_project.tpl b/src/templates/acc/transactions/action_project.tpl
new file mode 100644
index 0000000..a54ea61
--- /dev/null
+++ b/src/templates/acc/transactions/action_project.tpl
@@ -0,0 +1,36 @@
+{include file="_head.tpl" title="Ajouter/supprimer des écritures à un projet" current="acc/accounts"}
+
+{form_errors}
+
+
+
+
+ Affecter {$count} écritures sélectionnées à un projet
+
+ {input type="select" name="id_project" options=$projects label="Projet à utiliser" help="Pour retirer les écritures de leur projet actuellement affecté, sélectionner « Aucun projet »." default_empty="— Aucun projet —"}
+ {input type="checkbox" name="apply_lines" value="1" default="1" checked=1 label="Appliquer à toutes les lignes des écritures"}
+ Si décoché, alors seules les lignes sélectionnées seront modifiées. Si coché, toutes les lignes des écritures sélectionnées seront modifiées. Laisser coché en cas de doute.
+
+
+
+
+ {csrf_field key="acc_actions"}
+
+ {button type="submit" name="change_project" label="Modifier les écritures" shape="right" class="main"}
+
+ {if isset($extra)}
+ {foreach from=$extra key="key" item="value"}
+ {if is_array($value)}
+ {foreach from=$value key="subkey" item="subvalue"}
+
+ {/foreach}
+ {else}
+
+ {/if}
+ {/foreach}
+ {/if}
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/acc/transactions/actions_delete.tpl b/src/templates/acc/transactions/actions_delete.tpl
new file mode 100644
index 0000000..8fe2841
--- /dev/null
+++ b/src/templates/acc/transactions/actions_delete.tpl
@@ -0,0 +1,11 @@
+{include file="_head.tpl" title="Supprimer %d écritures"|args:$count current="acc"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ces écritures ?"
+ warning="Êtes-vous sûr de vouloir supprimer %d écritures ?"|args:$count
+ confirm="Cocher cette case pour confirmer la suppression"
+ csrf_key=$csrf_key
+ extra=$extra
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/creator.tpl b/src/templates/acc/transactions/creator.tpl
new file mode 100644
index 0000000..2439420
--- /dev/null
+++ b/src/templates/acc/transactions/creator.tpl
@@ -0,0 +1,9 @@
+{include file="_head.tpl" title="Écritures créées par %s"|args:$transaction_creator->name() current="acc/accounts"}
+
+
+ De la plus récente à la plus ancienne.
+
+
+{include file="acc/reports/_journal.tpl"}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/delete.tpl b/src/templates/acc/transactions/delete.tpl
new file mode 100644
index 0000000..cc00ce0
--- /dev/null
+++ b/src/templates/acc/transactions/delete.tpl
@@ -0,0 +1,9 @@
+{include file="_head.tpl" title="Supprimer l'écriture n°%d"|args:$transaction.id current="acc"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer cette écriture ?"
+ warning="Êtes-vous sûr de vouloir supprimer l'écriture n°%d « %s » ?"|args:$transaction.id,$transaction.label
+ csrf_key=$csrf_key
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/details.tpl b/src/templates/acc/transactions/details.tpl
new file mode 100644
index 0000000..3d5cb06
--- /dev/null
+++ b/src/templates/acc/transactions/details.tpl
@@ -0,0 +1,293 @@
+{include file="_head.tpl" title="Écriture n°%d"|args:$transaction.id current="acc"}
+
+
+{if isset($_GET['created'])}
+
+ L'écriture a bien été créée.
+
+{/if}
+
+
+
+ {if !$transaction.hash && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {linkbutton href="lock.php?id=%d"|args:$transaction.id shape="lock" label="Verrouiller" target="_dialog"}
+ {/if}
+{if PDF_COMMAND}
+ {linkbutton href="?id=%d&_pdf"|args:$transaction.id shape="download" label="Télécharger en PDF"}
+{/if}
+
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$transaction->validated && !$transaction_year->closed}
+ {linkbutton href="edit.php?id=%d"|args:$transaction.id shape="edit" label="Modifier cette écriture" accesskey="M"}
+ {linkbutton href="delete.php?id=%d"|args:$transaction.id shape="delete" label="Supprimer cette écriture" accesskey="S"}
+ {/if}
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
+ {linkbutton href="new.php?copy=%d"|args:$transaction.id shape="plus" label="Dupliquer cette écriture" accesskey="D"}
+ {/if}
+
+
+
+
+ {$config.nom_asso}
+ {"Écriture n°%d"|args:$transaction.id}
+
+
+{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE) && $transaction->isWaiting()}
+
+
+ {if $transaction.type == $transaction::TYPE_DEBT}
+ Dette en attente
+ {linkbutton shape="check" label="Régler cette dette" href="!acc/transactions/new.php?payoff=%d"|args:$transaction.id}
+ {else}
+ Créance en attente
+ {linkbutton shape="export" label="Régler cette créance" href="!acc/transactions/new.php?payoff=%d"|args:$transaction.id}
+ {/if}
+ {button type="submit" shape="check" label="Marquer manuellement comme réglée" name="mark_paid" value="1"}
+ {csrf_field key=$csrf_key}
+
+
+{/if}
+
+{if $transaction.status & $transaction::STATUS_ERROR}
+
+
Cette écriture est erronée suite à un bug. Il est conseillé de la modifier pour remettre les comptes manquants, ou la supprimer et la re-créer.
+ Voir cette page pour plus d'explications
+
Les lignes erronées sont affichées en bas de cette page.
+
(Ce message disparaîtra si vous modifiez l'écriture pour la corriger.)
+
+{/if}
+
+
+
+
+ Libellé
+ {$transaction.label|escape|linkify_transactions}
+ Type
+
+ {$transaction->getTypeName()}
+
+ {if $transaction.hash}
+ Verrou
+ {icon shape="lock"} Écriture verrouillée
+ {/if}
+
+ {if $transaction.type == $transaction::TYPE_DEBT || $transaction.type == $transaction::TYPE_CREDIT}
+ Statut
+
+ {if $transaction.status & $transaction::STATUS_PAID}
+ {icon shape="check"} Réglée
+ {elseif $transaction.status & $transaction::STATUS_WAITING}
+ {icon shape="alert"} En attente de règlement
+ {/if}
+
+ {/if}
+
+ Date
+ {$transaction.date|date:'l j F Y (d/m/Y)'}
+
+ Exercice
+
+ {link href="!acc/reports/ledger.php?year=%d"|args:$transaction.id_year label=$transaction_year.label}
+ — Du {$transaction_year.start_date|date_short} au {$transaction_year.end_date|date_short}
+ — {if $transaction_year.closed}Clôturé{else}En cours{/if}
+
+
+
+ Numéro pièce comptable
+ {if $transaction.reference}{$transaction.reference} {else}—{/if}
+
+ {if $transaction.type != $transaction::TYPE_ADVANCED}
+ Référence de paiement
+ {if $ref = $transaction->getPaymentReference()}{$ref} {else}—{/if}
+ Projet
+
+ {if $project = $transaction->getProject()}
+ {link href="!acc/reports/statement.php?project=%d&year=%d"|args:$project.id:$transaction.id_year label=$project.name}
+ {else}
+ —
+ {/if}
+ {/if}
+
+ Remarques
+ {if $transaction.notes}{$transaction.notes|escape|nl2br|linkify_transactions}{else}—{/if}
+
+
+ {if $transaction.id_creator}
+ Écriture créée par
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
+ {$creator_name}
+ {else}
+ {$creator_name}
+ {/if}
+
+ {/if}
+
+
+
+
+ {if $transaction.type != $transaction::TYPE_ADVANCED}
+
+ {link href="?id=%d&advanced=0"|args:$transaction.id label="Vue simplifiée"}
+ {link href="?id=%d&advanced=1"|args:$transaction.id label="Vue comptable"}
+
+ {/if}
+
+
+ {if $transaction.type != $transaction::TYPE_ADVANCED}
+
+
+
{$transaction->getTypeName()}
+
+ {$transaction->getLinesCreditSum()|abs|escape|money_currency}
+
+
+
+
{$details.left.label}
+ {link href="!acc/accounts/journal.php?id=%d"|args:$details.left.id label=$details.left.name}
+ {*({if $details.left.direction == 'credit'}Crédit{else}Débit{/if}) *}
+
+ {if $transaction.type == $transaction::TYPE_TRANSFER}
+
{icon shape="right"}
+ {/if}
+
+
{$details.right.label}
+ {link href="!acc/accounts/journal.php?id=%d&year=%d"|args:$details.right.id,$transaction.id_year label=$details.right.name}
+ {*({if $details.right.direction == 'credit'}Crédit{else}Débit{/if}) *}
+
+
+ {/if}
+
+
+
+
+
+ N° compte
+ Compte
+ Débit
+ Crédit
+ Libellé ligne
+ Référence ligne
+ Projet
+
+
+
+ {foreach from=$transaction->getLinesWithAccounts() item="line"}
+
+ {$line.account_code}
+ {$line.account_label}
+ {if $line.debit}{$line.debit|escape|money}{/if}
+ {if $line.credit}{$line.credit|escape|money}{/if}
+ {$line.label}
+ {$line.reference}
+
+ {if $line.id_project}
+ {link href="!acc/reports/statement.php?project=%d&year=%d"|args:$line.id_project:$transaction.id_year label=$line.project_name}
+ {/if}
+
+
+ {/foreach}
+
+
+
+
+ {if $files_edit || count($files)}
+
+
Fichiers joints
+ {include file="common/files/_context_list.tpl" files=$files edit=$files_edit path=$file_parent}
+
+ {/if}
+
+
+
+
+ {if count($linked_users)}
+
+ Membres liés
+
+ {foreach from=$linked_users item="u"}
+
+ {$u.number}
+ {$u.identity}
+ {linkbutton href="!users/details.php?id=%d"|args:$u.id label="Fiche membre" shape="user"}
+
+ {/foreach}
+
+
+ {/if}
+
+ {if count($linked_subscriptions)}
+
+ Inscriptions liées
+
+ {foreach from=$linked_subscriptions item="s"}
+
+ {link href="!users/details.php?id=%d"|args:$s.id_user label=$s.user_number}
+ {$s.user_identity}
+ {linkbutton href="!services/user/?id=%d&only=%s"|args:$s.id_user:$s.id_subscription label="Inscription" shape="right"}
+
+ {/foreach}
+
+
+ {/if}
+
+ {if count($linked_transactions)}
+
+
+ Écritures liées
+
+ {foreach from=$linked_transactions item="linked"}
+ sum(); ?>
+
+ #{$linked.id}
+ {$linked.label}
+ {$linked.date|date_short}
+ {$linked->sum()|money_currency|raw}
+
+ {/foreach}
+
+ {if ($transaction.status & $transaction::STATUS_WAITING || $transaction.status & $transaction::STATUS_PAID) || count($linked_transactions) > 1}
+
+
+ Total
+ {$amount|money_currency|raw}
+
+ {if $transaction.status & $transaction::STATUS_WAITING || $transaction.status & $transaction::STATUS_PAID}
+ sum() - $amount); ?>
+
+ Reste à régler
+ {$left|money_currency:false|raw}
+
+ {/if}
+
+ {/if}
+
+ {/if}
+
+
+
+
+{literal}
+
+{/literal}
+
+{$snippets|raw}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/edit.tpl b/src/templates/acc/transactions/edit.tpl
new file mode 100644
index 0000000..f7478fb
--- /dev/null
+++ b/src/templates/acc/transactions/edit.tpl
@@ -0,0 +1,5 @@
+{include file="_head.tpl" title="Modification d'une écriture" current="acc/simple"}
+
+{include file="./_form.tpl"}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/lock.tpl b/src/templates/acc/transactions/lock.tpl
new file mode 100644
index 0000000..9268899
--- /dev/null
+++ b/src/templates/acc/transactions/lock.tpl
@@ -0,0 +1,24 @@
+{include file="_head.tpl" title="Verrouiller une écriture" current="acc"}
+
+
+ {form_errors}
+
+
+ Verrouiller une écriture
+
+ Le verrouillage (aussi appelé validation) d'écritures permet d'empêcher leur modification.
+
+
+ Attention : une fois verrouillée, l'écriture ne pourra plus être modifiée ni supprimée.
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="lock" label="Verrouiller" shape="right" class="main"}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/new.tpl b/src/templates/acc/transactions/new.tpl
new file mode 100644
index 0000000..90c1ebd
--- /dev/null
+++ b/src/templates/acc/transactions/new.tpl
@@ -0,0 +1,17 @@
+{include file="_head.tpl" title="Saisie d'une écriture" current="acc/new"}
+
+{include file="acc/_year_select.tpl"}
+
+{if !empty($duplicate_from)}
+
+ Cette saisie est dupliquée depuis l'écriture {link class="num" href="details.php?id=%d"|args:$duplicate_from label="#%d"|args:$duplicate_from}
+
+{/if}
+
+{if isset($snippets)}
+ {$snippets|raw}
+{/if}
+
+{include file="./_form.tpl"}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/pending.tpl b/src/templates/acc/transactions/pending.tpl
new file mode 100644
index 0000000..18cbb54
--- /dev/null
+++ b/src/templates/acc/transactions/pending.tpl
@@ -0,0 +1,45 @@
+{include file="_head.tpl" title="Dettes et créances non réglées sur les autres exercices" current="acc/simple"}
+
+
+
+ {exportmenu}
+ {linkbutton shape="search" href="!acc/search.php" label="Recherche"}
+
+
+
+{if !$list->count()}
+
+ Aucune écriture à afficher.
+
+{else}
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="line"}
+
+ {$line.year_label}
+ {$line.type_label}
+ {link href="!acc/transactions/details.php?id=%d"|args:$line.id label="#%d"|args:$line.id}
+ {$line.date|date_short}
+ {$line.change|abs|raw|money}
+ {$line.reference}
+ {$line.label}
+
+ {if $line.type == Entities\Accounting\Transaction::TYPE_DEBT && ($line.status & Entities\Accounting\Transaction::STATUS_WAITING)}
+ {linkbutton shape="check" label="Régler cette dette" href="!acc/transactions/new.php?payoff=%d"|args:$line.id}
+ {elseif $line.type == Entities\Accounting\Transaction::TYPE_CREDIT && ($line.status & Entities\Accounting\Transaction::STATUS_WAITING)}
+ {linkbutton shape="export" label="Régler cette créance" href="!acc/transactions/new.php?payoff=%d"|args:$line.id}
+ {/if}
+
+ {linkbutton href="!acc/transactions/details.php?id=%d"|args:$line.id label="Détails" shape="search"}
+
+
+ {/foreach}
+
+
+
+
+
+ {$list->getHTMLPagination()|raw}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/selector.tpl b/src/templates/acc/transactions/selector.tpl
new file mode 100644
index 0000000..cad56eb
--- /dev/null
+++ b/src/templates/acc/transactions/selector.tpl
@@ -0,0 +1,81 @@
+{include file="_head.tpl" title="Sélectionner une ou des écritures"}
+
+
+
+ {input type="text" placeholder="Numéro ou libellé d'écriture" value="{$query}" name="q"}
+ {input type="select" name="id_year" default_empty="— Tous les exercices —" options=$years}
+ {button shape="search" type="submit" label="Chercher"}
+
+
+
+{if $list}
+
+
+ {foreach from=$list item="row"}
+
+ #{$row.id}
+
+ {$row.label}
+
+
+ {$row.date|date_short}
+
+
+ id_year];?>
+ {$year}
+
+
+ Sélectionner
+
+
+ {/foreach}
+
+
+
+
+ {if empty($row)}
+
+ Aucun résultat.
+
+ {/if}
+{/if}
+
+
+{literal}
+
+{/literal}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/service_user.tpl b/src/templates/acc/transactions/service_user.tpl
new file mode 100644
index 0000000..80a6621
--- /dev/null
+++ b/src/templates/acc/transactions/service_user.tpl
@@ -0,0 +1,38 @@
+{include file="_head.tpl" title="Écritures liées à une inscription" current="acc/accounts"}
+
+
+ {linkbutton href="!users/details.php?id=%d"|args:$user_id label="Retour à la fiche membre" shape="user"}
+ {linkbutton href="!services/user/payment.php?id=%d"|args:$service_user_id label="Nouveau règlement" shape="plus" target="_dialog"}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
+ {linkbutton href="!services/user/link.php?id=%d"|args:$service_user_id label="Lier à une écriture" shape="check" target="_dialog"}
+ {/if}
+
+
+{if empty($balance)}
+ Aucune écriture n'est liée à cette inscription.
+{else}
+ {include file="acc/reports/_journal.tpl"}
+
+ Solde des comptes
+
+
+
+
+ Numéro
+ Compte
+ Solde
+
+
+
+ {foreach from=$balance item="account"}
+
+ {$account.code}
+ {$account.label}
+ {$account.balance|raw|money:false}
+
+ {/foreach}
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/transactions/user.tpl b/src/templates/acc/transactions/user.tpl
new file mode 100644
index 0000000..c86e57d
--- /dev/null
+++ b/src/templates/acc/transactions/user.tpl
@@ -0,0 +1,51 @@
+{include file="_head.tpl" title="Écritures liées à %s"|args:$transaction_user->name() current="acc/accounts"}
+
+{if !$dialog}
+
+ {linkbutton href="!users/details.php?id=%d"|args:$transaction_user.id label="Retour à la fiche membre" shape="user"}
+
+{/if}
+
+
+ De la plus récente à la plus ancienne.
+
+
+{include file="acc/reports/_journal.tpl"}
+
+Solde des comptes
+
+
+
+ Exercice
+
+ {input type="select" name="year" options=$years onchange="this.form.submit();" default=$year}
+
+
+
+
+
+
+
+
+Cette liste représente le solde des comptes uniquement pour les écritures liées à ce membre.
+
+
+
+
+ Numéro
+ Compte
+ Solde
+
+
+
+ {foreach from=$balance item="account"}
+
+ {$account.code}
+ {$account.label}
+ {$account.balance|raw|money}
+
+ {/foreach}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/balance.tpl b/src/templates/acc/years/balance.tpl
new file mode 100644
index 0000000..b74e052
--- /dev/null
+++ b/src/templates/acc/years/balance.tpl
@@ -0,0 +1,153 @@
+{include file="_head.tpl" title="Balance d'ouverture" current="acc/years"}
+
+{form_errors}
+
+{if !empty($_GET.from) && empty($_POST)}
+
+ L'exercice a bien été créé.
+
+{/if}
+
+{if $year_selected}
+ {if $has_balance}
+
+ Attention !
+ Une balance d'ouverture existe déjà dans cet exercice.
+ En validant ce formulaire, les écritures de balance et d'affectation du résultat qui existent seront supprimées et remplacées .
+
+ {elseif $year->countTransactions()}
+
+ Attention !
+ Cet exercice a déjà des écritures, peut-être avez-vous déjà renseigné manuellement la balance d'ouverture ?
+
+ {/if}
+{/if}
+
+
+
+
+
+ Exercice : « {$year.label} » du {$year.start_date|date_short} au {$year.end_date|date_short}
+
+ {if !$year_selected}
+
+ Reporter les soldes de fermeture d'un exercice
+ Pour reprendre les soldes des comptes de l'exercice précédent.
+
+
+ {foreach from=$years item="year"}
+ {$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short} ({if $year.closed}clôturé{else}en cours{/if})
+ {/foreach}
+ — Saisie manuelle —
+
+
+
+ Attention l'exercice sélectionné n'est pas clôturé ! Si vous modifiez cet exercice après avoir validé cette balance d'ouverture, celle-ci pourrait ne plus correspondre au bilan de l'exercice précédent !
+
+
+ {literal}
+
+ {/literal}
+ {else}
+
+ Renseigner ici les soldes d'ouverture (débiteur ou créditeur) des comptes.
+
+ {if !empty($_GET.from)}
+
+ Normalement il suffit de valider ce formulaire pour faire le report à nouveau des soldes de comptes.
+
+ {/if}
+
+
+
+ {if $chart_change}
+ Ancien compte
+ Nouveau compte
+ {else}
+ Compte
+ {/if}
+ Débit
+ Crédit
+
+
+
+
+ {foreach from=$lines key="k" item="line"}
+
+ {if $chart_change || isset($line->code, $line->label)}
+
+ {$line.code} — {$line.label}
+
+
+
+ {/if}
+
+ {input type="list" target="!acc/charts/accounts/selector.php?chart=%d"|args:$year.id_chart name="lines[account_selector][]" default=$line.account_selector}
+
+ {input type="money" name="lines[debit][]" default=$line.debit size=5}
+ {input type="money" name="lines[credit][]" default=$line.credit size=5}
+ {button label="Enlever la ligne" shape="minus" min="1" name="remove_line"}
+
+ {/foreach}
+
+
+
+ Total
+ {if $chart_change}
+
+ {/if}
+ {input type="money" name="debit_total" readonly="readonly" tabindex="-1" }
+ {input type="money" name="credit_total" readonly="readonly" tabindex="-1" }
+ {button label="Ajouter une ligne" shape="plus"}
+
+
+
+ {if $can_appropriate}
+
+ {input type="checkbox" name="appropriation" value="1" checked="checked" label="Affecter automatiquement le résultat (conseillé)"}
+ Si cette case est cochée, le résultat sera automatiquement affecté au compte « {$appropriation_account.code} — {$appropriation_account.label} ».
+
+ {/if}
+ {/if}
+
+
+
+ {if null === $previous_year}
+ {button type="submit" name="next" label="Continuer" shape="right" class="main"}
+ {if $_GET.from}
+ — ou —
+ {linkbutton shape="reset" href="!acc/years/" label="Passer cet étape"}
+ (Il sera toujours possible de reprendre la balance d'ouverture plus tard.)
+ {/if}
+ {else}
+ {csrf_field key=$csrf_key}
+ {if $previous_year}
+
+ {else}
+
+ {/if}
+ {if $year_selected}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+ {else}
+ {button type="submit" name="save" label="Continuer" shape="right" class="main"}
+ {/if}
+ {literal}
+
+ {/literal}
+ {/if}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/close.tpl b/src/templates/acc/years/close.tpl
new file mode 100644
index 0000000..1f6912a
--- /dev/null
+++ b/src/templates/acc/years/close.tpl
@@ -0,0 +1,34 @@
+{include file="_head.tpl" title="Clôturer un exercice" current="acc/years"}
+
+{form_errors}
+
+
+
+
+ Clôturer un exercice
+
+ Êtes-vous sûr de vouloir clôturer l'exercice « {$year.label} » ?
+
+
+ Un exercice clôturé ne peut plus être modifié !
+ Il ne sera plus possible de modifier ou supprimer les écritures de l'exercice clôturé.
+
+
+ Début de l'exercice
+ {$year.start_date|date_short}
+ Fin de l'exercice
+ {$year.end_date|date_short}
+ Si la date de clôture ne convient pas, il est possible de modifier l'exercice préalablement à la clôture.
+
+
+
+ Les soldes créditeurs ou débiteurs de chaque compte pourront être reportés automatiquement lors de l'ouverture de l'exercice suivant.
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="close" label="Clôturer" shape="lock" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/delete.tpl b/src/templates/acc/years/delete.tpl
new file mode 100644
index 0000000..dc60713
--- /dev/null
+++ b/src/templates/acc/years/delete.tpl
@@ -0,0 +1,11 @@
+{include file="_head.tpl" title="Supprimer un exercice" current="acc/years"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer cet exercice ?"
+ warning="Êtes-vous sûr de vouloir supprimer l'exercice « %s » et ses %d écritures ?"|args:$year.label,$nb_transactions
+ alert="Attention, il ne sera pas possible de récupérer les écritures supprimées."
+ confirm="Cocher cette case pour confirmer la suppression de cet exercice et des %d écritures liées."|args:$nb_transactions
+ csrf_key="acc_years_delete_%s"|args:$year.id
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/edit.tpl b/src/templates/acc/years/edit.tpl
new file mode 100644
index 0000000..56e2fbc
--- /dev/null
+++ b/src/templates/acc/years/edit.tpl
@@ -0,0 +1,39 @@
+{include file="_head.tpl" title="Modifier un exercice" current="acc/years"}
+
+{form_errors}
+
+
+
+
+ Modifier un exercice
+
+ {input type="text" label="Libellé" name="label" source=$year required=true}
+ {input type="date" label="Début de l'exercice" name="start_date" source=$year required=true}
+ {input type="date" label="Fin de l'exercice" name="end_date" source=$year required=true}
+ {input type="checkbox" label="Déplacer les écritures postérieures dans un autre exercice" value=1 name="split"}
+
+
+ En cochant cette case, toute écriture située après la date de fin indiquée ci-dessus sera déplacée dans l'exercice sélectionné ci-dessous.
+ {input type="select" name="split_year" options=$split_years label="Nouvel exercice à utiliser" help="Les écritures situées après la date de fin seront transférées dans cet exercice"}
+
+
+
+
+ {csrf_field key="acc_years_edit_%s"|args:$year.id}
+ {button type="submit" name="edit" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{literal}
+
+{/literal}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/export.tpl b/src/templates/acc/years/export.tpl
new file mode 100644
index 0000000..41715b7
--- /dev/null
+++ b/src/templates/acc/years/export.tpl
@@ -0,0 +1,62 @@
+{include file="_head.tpl" title="Export d'exercice" current="acc/years"}
+
+
+ Exercice sélectionné :
+ {$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}
+
+
+{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$year.closed}
+
+
+
+
+
+{/if}
+
+{form_errors}
+
+
+
+
+ Export du journal général
+
+ Format d'export
+ {input type="radio" name="format" value="ods" default="ods" label="LibreOffice" help="également lisible par Excel, Google Docs, etc."}
+ {input type="radio" name="format" value="csv" label="CSV"}
+ {if CALC_CONVERT_COMMAND}
+ {input type="radio" name="format" value="xlsx" label="Excel"}
+ {/if}
+ Type d'export
+ {foreach from=$types key="type" item="info"}
+ {input type="radio-btn" name="type" value=$type label=$info.label help=$info.help default="full"}
+
+ Exemple :
+
+ {foreach from=$examples[$type] item="row"}
+
+ {foreach from=$row item="v"}
+ {$v}
+ {/foreach}
+
+ {/foreach}
+
+
+ {/foreach}
+
+
+
+
+
+ {button type="submit" name="load" label="Télécharger" shape="download" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/first_setup.tpl b/src/templates/acc/years/first_setup.tpl
new file mode 100644
index 0000000..477697b
--- /dev/null
+++ b/src/templates/acc/years/first_setup.tpl
@@ -0,0 +1,115 @@
+{include file="_head.tpl" title="Démarrer la comptabilité" current="acc"}
+
+{form_errors}
+
+
+
Bienvenue dans la comptabilité !
+
Les informations ci-dessous sont nécessaire pour démarrer la comptabilité.
+
{linkbutton shape="help" href=$help_pattern_url|args:"premier-exercice" target="_dialog" label="Démarrer le premier exercice comptable"}
+
+
+
+
+{if $step == 0}
+
+ 1. Plan comptable
+
+ Le plan comptable contient la liste des comptes selon les postes (dépenses, recettes, etc.).
+
+
+ Pays
+ {$config.country|get_country_name} {linkbutton href="!config/" shape="settings" label="Modifier le pays dans la configuration"}
+
+ {if $default_chart && $year.id_chart == $default_chart.id}
+
+ Plan comptable recommandé
+ {$default_chart.label} {button id="f_change_chart" shape="edit" label="Choisir un autre plan comptable" onclick="g.toggle('.chart-default', false); g.toggle('.charts', true);"}
+ Le choix du plan comptable ne peut être modifié une fois que l'exercice sera ouvert. Mais il sera possible d'y ajouter de nouveaux comptes si nécessaire.
+
+
+ {else}
+
+ {/if}
+ {input type="select" options=$charts_list label="Plan comptable" required=true name="chart" default=$default_chart_code}
+ {linkbutton shape="edit" href="!acc/charts/" label="Gérer les plans comptables"}
+
+
+
+
+ 2. Premier exercice
+
+ La comptabilité utilise des exercices. Un exercice, c'est une période comptable, généralement d'une année (12 mois), souvent une année civile, du 1er janvier au 31 décembre, mais d'autres choix sont possibles.
+ {linkbutton shape="help" href=$help_pattern_url|args:"exercice-comptable" target="_dialog" label="Qu'est-ce qu'un exercice comptable ?"}
+
+
+ {input type="date" label="Date de début de l'exercice" name="start_date" required=true source=$year}
+ {input type="date" label="Date de fin de l'exercice" name="end_date" required=true source=$year}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="step" value="1" label="Étape suivante" shape="right" class="main"}
+
+
+{else}
+
+ 3. Comptes bancaires
+
+ Créez ici vos comptes de banque (compte courant, livret, etc.) et de prestataires de paiement (type Paypal, SumUp, HelloAsso, etc.).
+ Vous pouvez aussi indiquer le solde du compte à la date de début de l'exercice.
+
+
+
+
+ Nom du compte
+ Solde du compte
+
+
+
+
+ {foreach from=$new_accounts item="account"}
+
+ {input type="text" name="accounts[label][]" default=$account.label required=false}
+ {input type="money" name="accounts[balance][]" default=$account.balance required=false}
+ {button label="Enlever" title="Enlever la ligne" shape="minus" min="2" name="remove_line"}
+
+ {/foreach}
+
+
+
+
+ {button label="Ajouter" title="Ajouter une ligne" shape="plus"}
+
+
+
+
+
+ {if $appropriation_account}
+
+ 4. Résultat précédent
+
+ Si vous aviez déjà réalisé une comptabilité auparavant, merci de reporter ci-dessous le résultat de l'exercice précédent.
+
+
+ {input type="money" label="Résultat de l'exercice précédent" name="previous_result" help="Si le résultat était en déficit, ajouter un signe moins (-) au début du nombre." name="result"}
+
+
+
+ {/if}
+
+
+ {csrf_field key=$csrf_key}
+ {input type="hidden" name="start_date" default=$year.start_date}
+ {input type="hidden" name="end_date" default=$year.end_date}
+ {input type="hidden" name="id_chart" default=$year.id_chart}
+ {button type="submit" name="step" value="0" label="Retour" shape="left" }
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+{/if}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/import.tpl b/src/templates/acc/years/import.tpl
new file mode 100644
index 0000000..9018c2b
--- /dev/null
+++ b/src/templates/acc/years/import.tpl
@@ -0,0 +1,173 @@
+
+{include file="_head.tpl" title="Importer des écritures" current="acc/years"}
+
+
+ Exercice sélectionné :
+ {$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}
+
+
+
+
+
+
+{form_errors}
+
+{if $type_name && $csv->ready()}
+
+
+ Aucun problème n'a été détecté.
+ Voici un résumé des changements qui seront apportés par cet import :
+
+
+ {if $report.created_count}
+
+
+ {{%n écriture sera créée}{%n écritures seront créées} n=$report.created_count}
+
+ Les écritures suivantes mentionnées dans le fichier seront ajoutées.
+ {include file="acc/reports/_journal.tpl" journal=$report.created with_linked_users=true}
+
+ {/if}
+
+ {if $report.modified_count}
+
+
+ {{%n écriture sera modifiée}{%n écritures seront modifiées} n=$report.modified_count}
+
+ Les écritures suivantes mentionnées dans le fichier seront modifiées. En rouge ce qui sera supprimé, en vert ce qui sera ajouté.
+ {include file="acc/reports/_journal_diff.tpl" journal=$report.modified}
+
+ {/if}
+
+ {if $report.unchanged_count}
+
+
+ {{%n écriture ne sera pas affectée}{%n écritures ne seront pas affectées} n=$report.unchanged_count}
+
+ Les écritures suivantes mentionnées dans le fichier ne seront pas modifiées .
+ {include file="acc/reports/_journal.tpl" journal=$report.unchanged with_linked_users=true}
+
+ {/if}
+
+ {if !$report.modified_count && !$report.created_count}
+
+ Aucune modification ne serait apportée par ce fichier à importer. Il n'est donc pas possible de terminer l'import.
+
+ {else}
+
+ En validant ce formulaire, ces changements seront appliqués.
+
+ {/if}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="cancel" value="1" label="Annuler" shape="left"}
+ {if $report.modified_count || $report.created_count}
+ {button type="submit" name="import" label="Importer" class="main" shape="upload"}
+ {/if}
+
+
+{elseif $type_name && $csv->loaded()}
+
+ {include file="common/_csv_match_columns.tpl"}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="cancel" value="1" label="Annuler" shape="left"}
+ {button type="submit" name="preview" label="Prévisualiser" class="main" shape="right"}
+
+
+{elseif $type_name}
+
+
+
+ Importer un fichier
+
+
+ Type d'import
+
+
+ {$type_name}
+
+ {input type="file" name="file" label="Fichier à importer" accept="csv" required=true}
+ {include file="common/_csv_help.tpl" csv=$csv more_text="
+ Si le fichier comporte des écritures dont la date est en dehors de l'exercice courant, elles seront ignorées."}
+
+
+
+
+
+ Configuration de l'import
+
+ Mode d'import (obligatoire)
+
+ {input type="radio" name="ignore_ids" value="1" label="Créer toutes les écritures" required=true}
+ Toutes les écritures du fichier seront créées, sans tenir compte du numéro s'il est fourni. Cela peut amener à avoir des écritures en doublon si on réalise plusieurs imports du même fichier.
+
+ {input type="radio" name="ignore_ids" value="0" label="Mettre à jour en utilisant le numéro d'écriture" required=true}
+
+ Les écritures dans le fichier qui mentionnent un numéro d'écriture seront mises à jour en utilisant ce numéro.
+ Si une ligne du fichier mentionne un numéro d'écriture qui n'existe pas, l'import échouera.
+ Les écritures qui ne mentionnent pas de numéro seront créées.
+
+
+ {if $type == Export::FEC}
+ Avec le format FEC, cette option effacera certaines données des écritures mises à jour : référence du paiement et projet analytique.
+ {/if}
+
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {linkbutton href="?year=%d"|args:$year.id label="Annuler" shape="left"}
+ {button type="submit" name="load" label="Charger le fichier" shape="right" class="main"}
+
+
+
+
+{else}
+
+
+
+ Import d'écritures
+
+ Type de fichier à importer
+ {foreach from=$types key="type" item="info"}
+ {input type="radio-btn" name="type" value=$type label=$info.label help=$info.help default="simple"}
+
+ Exemple :
+
+ {foreach from=$examples[$type] item="row"}
+
+ {foreach from=$row item="v"}
+ {$v}
+ {/foreach}
+
+ {/foreach}
+
+
+ {/foreach}
+
+
+
+
+ Il est conseillé de procéder à une sauvegarde avant de faire un import,
+ cela vous permettra de revenir en arrière en cas d'erreur.
+
+
+
+
+ {button type="submit" label="Continuer" shape="right" class="main"}
+
+
+
+{/if}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/index.tpl b/src/templates/acc/years/index.tpl
new file mode 100644
index 0000000..e0935ab
--- /dev/null
+++ b/src/templates/acc/years/index.tpl
@@ -0,0 +1,105 @@
+{include file="_head.tpl" title="Exercices" current="acc/years"}
+
+
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ {linkbutton shape="plus" href="!acc/years/new.php" label="Nouvel exercice"}
+ {/if}
+ {linkbutton shape="search" href="!acc/search.php" label="Recherche"}
+
+
+
+
+{if $_GET.msg == 'IMPORT'}
+
+ L'import s'est bien déroulé.
+
+{/if}
+
+{if $_GET.msg == 'WELCOME'}
+
+
Votre premier exercice a été créé !
+
Vous pouvez désormais utiliser la comptabilité.
+
{linkbutton shape="plus" href="!acc/transactions/new.php" label="Saisir une écriture"}
+
+{/if}
+
+{if $_GET.msg == 'OPEN'}
+
+ Il n'existe aucun exercice ouvert.
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
+ Merci d'en créer un nouveau pour pouvoir saisir des écritures.
+ {/if}
+
+{/if}
+
+{if $_GET.msg == 'UPDATE_FEES'}
+
+ Des tarifs d'activité étaient associés à l'ancien exercice clôturé.
+ Ces tarifs ont été déconnectés de la comptabilité à cause du changement de plan comptable, il vous faudra les reconnecter manuellement au nouvel exercice.
+
+{/if}
+
+{if !empty($list)}
+ {if count($list) > 1}
+
+
+
+
+ Soldes des banques et caisses par exercice
+
+
+
+ Recettes et dépenses par exercice
+
+
+
+ {/if}
+
+
+ {foreach from=$list item="year"}
+
+
+ {$year.label}
+ {$year.nb_transactions} écritures | {$year.chart_name}
+
+
+ {$year.start_date|date_short} au {$year.end_date|date_short}
+
+ Graphiques
+ | Balance générale
+ | Journal général
+ | Grand livre
+ | Compte de résultat
+ | Bilan
+
+
+
+ {if $year.closed}Clôturé {else}En cours {/if}
+
+ {linkbutton label="Export" shape="export" href="export.php?year=%d"|args:$year.id}
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$year.closed}
+ {linkbutton label="Import" shape="upload" href="import.php?year=%d"|args:$year.id}
+ {linkbutton label="Balance d'ouverture" shape="reset" href="balance.php?id=%d"|args:$year.id}
+ {linkbutton label="Modifier" shape="edit" href="edit.php?id=%d"|args:$year.id}
+ {linkbutton label="Clôturer" shape="lock" href="close.php?id=%d"|args:$year.id}
+ {linkbutton label="Supprimer" shape="delete" href="delete.php?id=%d"|args:$year.id}
+ {/if}
+
+
+
+ {/foreach}
+
+{else}
+
+ Il n'y a pas d'exercice en cours.
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/new.tpl b/src/templates/acc/years/new.tpl
new file mode 100644
index 0000000..ebdb370
--- /dev/null
+++ b/src/templates/acc/years/new.tpl
@@ -0,0 +1,39 @@
+{include file="_head.tpl" title="Commencer un exercice" current="acc/years"}
+
+{if isset($_GET.from)}
+ L'exercice a bien été clôturé. Vous pouvez commencer un nouvel exercice ci-dessous.
+{/if}
+
+{form_errors}
+
+
+
+
+ Commencer un nouvel exercice
+
+ {input type="select_groups" options=$charts name="id_chart" label="Plan comptable" required=true source=$year}
+
+ Il ne sera pas possible de changer le plan comptable une fois l'exercice ouvert.
+ Il ne sera également pas possible de modifier ou supprimer un compte du plan comptable si le compte est utilisé dans un autre exercice déjà clôturé.
+ Si vous souhaitez modifier le plan comptable pour ce nouvel exercice, il est recommandé de créer un nouveau plan comptable, recopié à partir de l'ancien plan comptable. Ainsi tous les comptes seront modifiables et supprimables.
+
+ {linkbutton shape="settings" label="Gestion des plans comptables" href="!acc/charts/"}
+ {input type="text" name="label" label="Libellé" required=true source=$year}
+ {input type="date" label="Début de l'exercice" name="start_date" required=true source=$year}
+ {input type="date" label="Fin de l'exercice" name="end_date" required=true source=$year}
+
+
+
+
+ {csrf_field key="acc_years_new"}
+ {if isset($_GET.from)}
+ {linkbutton shape="left" href="./" label="Ne pas créer de nouvel exercice"}
+ {else}
+ {linkbutton shape="left" href="./" label="Annuler"}
+ {/if}
+ {button type="submit" name="new" label="Créer ce nouvel exercice" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/acc/years/select.tpl b/src/templates/acc/years/select.tpl
new file mode 100644
index 0000000..bf90914
--- /dev/null
+++ b/src/templates/acc/years/select.tpl
@@ -0,0 +1,26 @@
+{include file="_head.tpl" title="Changer d'exercice" current="acc/years"}
+
+
+
+ Changer l'exercice de travail
+
+
+
+ {foreach from=$list item="year"}
+ {$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}
+ {/foreach}
+
+
+ Ici ne peuvent être sélectionnés que les exercices ouverts, car il n'est pas possible de modifier un exercice clos.
+ Pour consulter les rapports pour les exercices clos, voir la liste des exercices .
+
+
+
+
+ {csrf_field key="acc_select_year"}
+
+ {button type="submit" name="change" label="Changer" shape="right" class="main"}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/ask_share_password.tpl b/src/templates/ask_share_password.tpl
new file mode 100644
index 0000000..db38c4c
--- /dev/null
+++ b/src/templates/ask_share_password.tpl
@@ -0,0 +1,23 @@
+{include file="_head.tpl" title="Accès document" current=null layout="public"}
+
+{if $has_password}
+
+ Le mot de passe fourni ne correspond pas. Merci de vérifier la saisie.
+
+{else}
+ Un mot de passe est nécessaire pour accéder à ce document.
+{/if}
+
+
+
+ Accès au document
+
+ {input type="password" name="p" required=true label="Mot de passe"}
+
+
+ {button type="submit" label="Accéder au document" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/common/_csv_help.tpl b/src/templates/common/_csv_help.tpl
new file mode 100644
index 0000000..3fd829f
--- /dev/null
+++ b/src/templates/common/_csv_help.tpl
@@ -0,0 +1,19 @@
+
+ Merci de respecter les règles suivantes :
+
+ {if !CALC_CONVERT_COMMAND}
+ Il est recommandé d'utiliser LibreOffice pour créer le fichier CSV
+ Le fichier doit être en UTF-8
+ Le séparateur doit être le point-virgule ou la virgule
+ Cocher l'option "Mettre en guillemets toutes les cellules du texte"
+ {/if}
+ Le fichier peut comporter les colonnes suivantes : {$csv->getColumnsString()} .
+ {if ($columns = $csv->getMandatoryColumnsString())}Le fichier doit obligatoirement comporter les colonnes suivantes : {$columns} {/if}
+ {if isset($more_text)}
+
+ {foreach from=$more item="text"}
+ {$text}
+ {/foreach}
+ {/if}
+
+
\ No newline at end of file
diff --git a/src/templates/common/_csv_match_columns.tpl b/src/templates/common/_csv_match_columns.tpl
new file mode 100644
index 0000000..f6cfb79
--- /dev/null
+++ b/src/templates/common/_csv_match_columns.tpl
@@ -0,0 +1,36 @@
+
+ Importer depuis un tableau
+
+ {$csv->count()} lignes trouvées dans le fichier
+ {input type="checkbox" name="skip_first_line" value="1" label="Ne pas importer la première ligne" help="Décocher cette case si la première ligne ne contient pas l'intitulé des colonnes, mais des données" default=1}
+ Correspondance des colonnes
+
+
+
+
+ Colonne du fichier à importer
+
+ Importer cette colonne comme…
+
+
+
+ getSelectedTable(); ?>
+ {foreach from=$csv->getFirstLine() key="index" item="csv_field"}
+
+ {$csv_field}
+ {icon shape="right"}
+
+
+ -- Ne pas importer cette colonne
+ {foreach from=$csv->getColumnsWithDefaults() item="column"}
+ {$column.label}
+ {/foreach}
+
+
+
+ {/foreach}
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/common/_sql_table.tpl b/src/templates/common/_sql_table.tpl
new file mode 100644
index 0000000..5b36ae3
--- /dev/null
+++ b/src/templates/common/_sql_table.tpl
@@ -0,0 +1,76 @@
+
+
+
+ {$table.name}
+ {if $table.comment} ({$table.comment}) {/if}
+
+
+
+ {if $indexes !== null}
+ Index
+ {/if}
+ Colonne
+ Type
+ Nul ?
+ Valeur par défaut
+ Référence
+ Commentaire
+
+
+
+ {foreach from=$table.columns item="column"}
+
+ {if $indexes !== null}
+
+ {if $column.pk}P {/if}
+ {foreach from=$indexes key="i" item="idx"}
+ {if array_key_exists($column.name, $idx.columns)}
+ {$i}{if $idx.unique}U {/if}
+ {/if}
+ {/foreach}
+
+ {/if}
+ {$column.name}
+ {if $column.type}{$column.type}{else}Dynamique {/if}
+ {if $column.notnull}{else}Oui{/if}
+ {if $column.dflt_value !== null}{$column.dflt_value} {elseif !$column.notnull}NULL {else}Aucune {/if}
+
+ {if !empty($column.fk)}
+ →
+ {if !empty($fk_link)}
+ {$column.fk.table}
+ {else}
+ {$column.fk.table}
+ {/if}
+ ({$column.fk.to})
+ {/if}
+
+
+
+ {/foreach}
+
+
+
+{if $indexes}
+Liste des index
+
+
+
+ Num.
+ Nom
+ Type
+ Colonnes
+
+
+
+ {foreach from=$indexes item="idx" key="i"}
+
+ {$i}
+ {$idx.name}
+ {if $idx.unique}Unique{/if}
+ {foreach from=$idx.columns item="c"}{$c.name} {/foreach}
+
+ {/foreach}
+
+
+{/if}
\ No newline at end of file
diff --git a/src/templates/common/delete_form.tpl b/src/templates/common/delete_form.tpl
new file mode 100644
index 0000000..e7e9360
--- /dev/null
+++ b/src/templates/common/delete_form.tpl
@@ -0,0 +1,56 @@
+{form_errors}
+
+
+
+
+ {$legend}
+
+ {$warning}
+
+ {if isset($alert)}
+
+ {$alert}
+
+ {/if}
+ {if isset($error)}
+
+ {$error}
+
+ {/if}
+ {if isset($confirm_text, $confirm_label)}
+
+ {input type="text" required=true label=$confirm_label name="confirm_delete"}
+
+ {/if}
+ {if isset($info)}
+
+ {$info}
+
+ {/if}
+ {if isset($confirm)}
+
+ {input type="checkbox" name="confirm_delete" value=1 label=$confirm}
+
+ {/if}
+
+
+
+ {csrf_field key=$csrf_key}
+ {if !isset($shape)}
+ {assign var="shape" value="delete"}
+ {/if}
+ {button type="submit" name="delete" label="Supprimer" shape=$shape class="main"}
+ {if isset($extra)}
+ {foreach from=$extra key="key" item="value"}
+ {if is_array($value)}
+ {foreach from=$value key="subkey" item="subvalue"}
+
+ {/foreach}
+ {else}
+
+ {/if}
+ {/foreach}
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/src/templates/common/dynamic_list_head.tpl b/src/templates/common/dynamic_list_head.tpl
new file mode 100644
index 0000000..cee80b0
--- /dev/null
+++ b/src/templates/common/dynamic_list_head.tpl
@@ -0,0 +1,49 @@
+
+
+
+ {if !empty($check)}
+
+ {/if}
+ {foreach from=$list->getHeaderColumns() key="key" item="column"}
+ {if empty($disable_user_ordering) && (!array_key_exists('select', $column) || !is_null($column['select'])) && !(array_key_exists('order', $column) && null === $column['order'])}
+
+ {if !empty($use_buttons)}
+
+ {else}
+
+ {/if}
+
+ {if $list.desc}
+ {icon shape="down" class="dn"}
+ {else}
+ {icon shape="up" class="up"}
+ {/if}
+
+ {if $column.header_icon}
+ {icon shape=$column.header_icon title=$column.label}
+ {else}
+ {$column.label}
+ {/if}
+
+ {if !empty($use_buttons)}
+
+ {else}
+
+ {/if}
+ {else}
+
+
+ {if $column.header_icon}
+ {icon shape=$column.header_icon title=$column.label}
+ {else}
+ {$column.label}
+ {/if}
+
+
+ {/if}
+
+ {/foreach}
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/common/files/_context_list.tpl b/src/templates/common/files/_context_list.tpl
new file mode 100644
index 0000000..a02222b
--- /dev/null
+++ b/src/templates/common/files/_context_list.tpl
@@ -0,0 +1,59 @@
+
+
+{if $can_upload}
+
+
+ {linkbutton shape="upload" href="!common/files/upload.php?p=%s"|args:$path target="_dialog" label=$button_label}
+ (ou glisser et déposer un fichier ici)
+
+{/if}
+
+
+{foreach from=$files item="file"}
+ canRead()) {
+ break;
+ }
+ ?>
+
+ {$file->link($session, 'auto')|raw}
+
+ {$file->link($session)|raw}
+
+
+ {linkbutton shape="download" href=$file->url(true) target="_blank" label="Télécharger"}
+ {if $edit && $file->canDelete()}
+ {linkbutton shape=$delete_shape target="_dialog" href="!common/files/delete.php?p=%s%s"|args:$file.path:$trash label="Supprimer"}
+ {/if}
+
+
+{foreachelse}
+ {if !$can_upload}
+ —
+ {/if}
+{/foreach}
+
+
+{if $can_upload}
+
+{/if}
\ No newline at end of file
diff --git a/src/templates/common/files/_file_render_encrypted.tpl b/src/templates/common/files/_file_render_encrypted.tpl
new file mode 100644
index 0000000..d889153
--- /dev/null
+++ b/src/templates/common/files/_file_render_encrypted.tpl
@@ -0,0 +1,14 @@
+
+
+ Vous dever activer javascript pour pouvoir déchiffrer cette page.
+
+
+
+
+
Cette page est chiffrée.
+
+
+
+
+ {$content}
+
\ No newline at end of file
diff --git a/src/templates/common/files/_preview.tpl b/src/templates/common/files/_preview.tpl
new file mode 100644
index 0000000..41063ac
--- /dev/null
+++ b/src/templates/common/files/_preview.tpl
@@ -0,0 +1,5 @@
+{include file="_head.tpl" title=$file.name layout="raw preview"}
+
+{$content|raw}
+
+{include file="_foot.tpl"}
diff --git a/src/templates/common/files/delete.tpl b/src/templates/common/files/delete.tpl
new file mode 100644
index 0000000..ab48346
--- /dev/null
+++ b/src/templates/common/files/delete.tpl
@@ -0,0 +1,37 @@
+{include file="_head.tpl" title="Supprimer un fichier" current=null}
+
+{if $trash}
+ {if $file.type == $file::TYPE_DIRECTORY}
+ {include file="common/delete_form.tpl"
+ shape="trash"
+ legend="Supprimer ce dossier ?"
+ warning="Êtes-vous sûr de vouloir mettre le dossier « %s » à la corbeille ?"|args:$file.name
+ alert="Tous les sous-dossiers et fichiers de ce dossier seront placés à la corbeille !"
+ info="Seul un membre administrateur pourra récupérer le fichier dans la corbeille."
+ }
+ {else}
+ {include file="common/delete_form.tpl"
+ shape="trash"
+ legend="Supprimer ce fichier ?"
+ warning="Êtes-vous sûr de vouloir mettre le fichier « %s » à la corbeille ?"|args:$file.name
+ info="Seul un membre administrateur pourra récupérer le fichier dans la corbeille."
+ }
+ {/if}
+{else}
+ {if $file.type == $file::TYPE_DIRECTORY}
+ {include file="common/delete_form.tpl"
+ legend="Supprimer ce dossier ?"
+ warning="Êtes-vous sûr de vouloir supprimer le dossier « %s » ?"|args:$file.name
+ alert="Tous les sous-dossiers et fichiers de ce dossier seront supprimés !"
+ info="Il ne sera pas possible de récupérer les données."
+ }
+ {else}
+ {include file="common/delete_form.tpl"
+ legend="Supprimer ce fichier ?"
+ warning="Êtes-vous sûr de vouloir supprimer le fichier « %s » ?"|args:$file.name
+ info="Il ne sera pas possible de récupérer les données."
+ }
+ {/if}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/common/files/edit_code.tpl b/src/templates/common/files/edit_code.tpl
new file mode 100644
index 0000000..0f2bc4e
--- /dev/null
+++ b/src/templates/common/files/edit_code.tpl
@@ -0,0 +1,18 @@
+{include file="_head.tpl" title="Édition de fichier"}
+
+{form_errors}
+
+
+
+ {input type="textarea" name="content" cols="90" rows="50" default=$content}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer et fermer" shape="right" class="main"}
+
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/common/files/edit_web.tpl b/src/templates/common/files/edit_web.tpl
new file mode 100644
index 0000000..131eb2a
--- /dev/null
+++ b/src/templates/common/files/edit_web.tpl
@@ -0,0 +1,16 @@
+{include file="_head.tpl" title="Édition de fichier" custom_js=['web_editor.js']}
+
+
+
+
+ {input type="textarea" name="content" cols="70" rows="30" default=$content data-preview-url="!common/files/_preview.php?f=%s"|local_url|args:$path data-fullscreen="1" data-attachments="0" data-savebtn="1" data-format=$format}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer et fermer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/common/files/history.tpl b/src/templates/common/files/history.tpl
new file mode 100644
index 0000000..71d0485
--- /dev/null
+++ b/src/templates/common/files/history.tpl
@@ -0,0 +1,95 @@
+{include file="_head.tpl" title="Historique" current="docs"}
+
+{$file.name} — Historique
+
+{form_errors}
+
+{if $_GET.msg == 'RENAMED'}
+
+ La version a été nommée.
+
+{elseif $_GET.msg == 'DELETED'}
+
+ La version a été supprimée.
+
+{elseif $_GET.msg == 'RESTORED'}
+
+ La version a été restaurée.
+
+{/if}
+
+
+ Voici la liste des anciennes versions de ce fichier.
+
+
+
+
+
+
+ Nom
+ Date
+ Taille
+
+
+
+
+
+ Version actuelle
+ {$file.modified|relative_date:true}
+ {$file.size|size_in_bytes:true}
+
+
+
+ {foreach from=$versions item="v"}
+
+ {*{$v.version} *}
+ {$v.name}
+ {$v.date|relative_date:true}
+ {$v.size|size_in_bytes:true}
+
+ {if $file->canDelete()}
+ {button shape="delete" label="Supprimer cette version" name="delete" value=$v.version type="submit"}
+ {/if}
+ {button shape="history" label="Restaurer" name="restore" value=$v.version type="submit"}
+ {linkbutton shape="edit" label="Nommer" href="?p=%s&rename=%d"|args:$file->path_uri():$v.version}
+ {linkbutton shape="download" label="Télécharger" href="?p=%s&download=%d"|args:$file->path_uri():$v.version target="_blank"}
+
+
+ {/foreach}
+
+
+ {csrf_field key=$csrf_key}
+
+
+
+ Les anciennes versions sont supprimées automatiquement, sauf pour les versions nommées qui sont conservées.
+
+
+
Les anciennes versions sont supprimées automatiquement selon ces règles :
+
+ {if (FILE_VERSIONING_POLICY ?? $config.file_versioning_policy) === 'min'}
+ Dans les 10 premières minutes, on conserve une version ;
+ Dans l'heure suivante, on conserve une version ;
+ Dans les 24h suivantes, on conserve une version ;
+ Dans les 2 mois suivants, on conserve une version ;
+ Ensuite, on conserve une seule version.
+ {elseif (FILE_VERSIONING_POLICY ?? $config.file_versioning_policy) === 'avg'}
+ Dans les 10 premières minutes, on conserve une version toutes les 5 minutes ;
+ Dans l'heure suivante, on conserve une version toutes les 15 minutes ;
+ Dans les 24h suivantes, on conserve une version toutes les 3 heures ;
+ Dans les 4 mois suivants, on conserve une version par mois ;
+ Ensuite, on conserve une seule version.
+ {elseif (FILE_VERSIONING_POLICY ?? $config.file_versioning_policy) === 'max'}
+ Dans les 10 premières minutes, on conserve une version par minute ;
+ Dans l'heure suivante, on conserve une version toutes les 10 minutes ;
+ Dans les 24h suivantes, on conserve une version par heure ;
+ Dans les 2 mois suivants, on conserve une version par semaine ;
+ Ensuite, on conserve une version par trimestre.
+ {/if}
+
+
Les versions nommées ne sont pas concernées par la suppression automatique, elles seront toujours conservées à moins d'être supprimées manuellement.
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/common/files/history_rename.tpl b/src/templates/common/files/history_rename.tpl
new file mode 100644
index 0000000..365d867
--- /dev/null
+++ b/src/templates/common/files/history_rename.tpl
@@ -0,0 +1,20 @@
+{include file="_head.tpl" title="Nommer une version" current=null}
+
+{form_errors}
+
+
+
+ Nommer une version
+
+ Nom actuel
+
+ {input type="text" name="new_name" required="required" label="Nouveau nom" default=$version.name}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="rename" value=$version.version label="Renommer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/common/files/rename.tpl b/src/templates/common/files/rename.tpl
new file mode 100644
index 0000000..048995d
--- /dev/null
+++ b/src/templates/common/files/rename.tpl
@@ -0,0 +1,29 @@
+{include file="_head.tpl" title="Renommer" current=null}
+
+{form_errors}
+
+
+
+ Renommer
+
+ Nom actuel
+
+ {input type="text" name="new_name" required="required" label="Nouveau nom" default=$file.name}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="rename" label="Renommer" shape="right" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/common/files/share.tpl b/src/templates/common/files/share.tpl
new file mode 100644
index 0000000..413708f
--- /dev/null
+++ b/src/templates/common/files/share.tpl
@@ -0,0 +1,32 @@
+{include file="_head.tpl" title="Partager" current="docs"}
+
+{form_errors}
+
+
+
+ {$file.name}
+
+ Un lien de partage sera créé, permettant de partager ce fichier publiquement, sans avoir à se connecter à la gestion de l'association.
+
+ {if $share_url}
+
+ {input type="url" copy=true readonly=true name="" size=100 onclick="this.select();" label="Pour partager ce fichier, transmettez cette adresse :" default=$share_url}
+
+ {else}
+
+ {input type="select" name="expiry" required=true label="Durée de validité du lien" options=$expiry_options default=24*365 help="Après ce délai, le lien ne sera plus valide."}
+ {input type="password" name="password" label="Définir un mot de passe" help="Si renseigné, alors la personne devra entrer ce mot de passe pour accéder au fichier partagé"}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="share" label="Partager" shape="right" class="main"}
+
+ {/if}
+
+
+
+
+ Le lien de partage cessera de fonctionner si le fichier est renommé ou déplacé.
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/common/files/upload.tpl b/src/templates/common/files/upload.tpl
new file mode 100644
index 0000000..8c868e0
--- /dev/null
+++ b/src/templates/common/files/upload.tpl
@@ -0,0 +1,18 @@
+{include file="_head.tpl" title="Envoi de fichier"}
+
+{form_errors}
+
+
+
+ Téléverser des fichiers
+
+ {input type="file" name="file[]" multiple=$multiple label="Fichiers à envoyer" data-enhanced=1}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="upload" label="Envoyer" shape="upload" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/common/search/advanced.tpl b/src/templates/common/search/advanced.tpl
new file mode 100644
index 0000000..aeaf338
--- /dev/null
+++ b/src/templates/common/search/advanced.tpl
@@ -0,0 +1,69 @@
+type == $s::TYPE_SQL_UNPROTECTED;
+$sql_disabled = (!$session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN) && $is_unprotected);
+?>
+
+{form_errors}
+
+
+{if $s.type !== $s::TYPE_JSON}
+ {if $sql_disabled}
+ Recherche enregistrée
+ {$s.label}
+ {else}
+ Recherche SQL
+
+ {input type="textarea" name="sql" cols="100" rows="8" required=1 label="Requête SQL" help="Si aucune limite n'est précisée, une limite de 100 résultats sera appliquée." default=$s.content}
+ {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
+ {input type="checkbox" name="unprotected" value=1 label="Autoriser l'accès à toutes les tables de la base de données" default=$is_unprotected}
+ Attention : en cochant cette case vous autorisez la requête à lire toutes les données de toutes les tables de la base de données !
+ {/if}
+
+
+ {foreach from=$schema item="table"}
+
+ Table : {$table.comment} ({$table.name} )
+ {include file="common/_sql_table.tpl" indexes=null class=null}
+
+
+ {/foreach}
+
+
+
+ {button type="submit" name="run" label="Exécuter" shape="search" class="main"}
+
+ {if $s->exists()}
+ {button name="save" value=1 type="submit" label="Enregistrer : %s"|args:$s.label|truncate:40:"…":true shape="upload"}
+ {button name="save_new" value=1 type="submit" label="Enregistrer nouvelle recherche" shape="plus"}
+ {else}
+ {button name="save" value=1 type="submit" label="Enregistrer cette recherche" shape="upload"}
+ {/if}
+ {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
+ {linkbutton href="!config/advanced/sql.php" target="_blank" shape="menu" label="Voir le schéma SQL complet"}
+ {/if}
+
+ {/if}
+{else}
+ {if isset($legend)}{$legend}{else}Rechercher{/if}
+
+
+
+ {button name="search" value=1 type="submit" label="Chercher" shape="search" id="send" class="main"}
+
+
+ {if $s.id}
+ {button name="save" value=1 type="submit" label="Enregistrer : %s"|args:$s.label|truncate:40:"…":true shape="upload"}
+ {button name="save_new" value=1 type="submit" label="Enregistrer nouvelle recherche" shape="plus"}
+ {else}
+ {button name="save" value=1 type="submit" label="Enregistrer cette recherche" shape="upload"}
+ {/if}
+ {if $is_admin}
+ {button name="to_sql" value=1 type="submit" label="Recherche SQL" shape="edit"}
+ {/if}
+
+
+{/if}
+
diff --git a/src/templates/common/search/saved_searches.tpl b/src/templates/common/search/saved_searches.tpl
new file mode 100644
index 0000000..d7a343e
--- /dev/null
+++ b/src/templates/common/search/saved_searches.tpl
@@ -0,0 +1,85 @@
+{include file="_head.tpl" title="Recherches enregistrées" current=$target}
+
+{if $target == 'users'}
+ {include file="users/_nav.tpl" current="saved_searches"}
+{else}
+
+
+
+{/if}
+
+{if $mode == 'edit'}
+ {form_errors}
+
+
+
+ Modifier une recherche enregistrée
+
+ {input type="text" name="label" label="Intitulé" required=1 source=$search}
+ Statut
+ id_user); ?>
+ {input type="radio" name="public" value="0" default=$public label="Recherche privée" help="Visible seulement par moi-même"}
+ {if $session->canAccess($access_section, $session::ACCESS_WRITE)}
+ {input type="radio" name="public" value="1" default=$public label="Recherche publique" help="Visible et exécutable par tous les membres ayant accès à la gestion %s"|args:$target}
+ {/if}
+ Type
+
+ {if $search.type == $search::TYPE_JSON}
+ Avancée
+ {elseif $search.type == $search::TYPE_SQL_UNPROTECTED}
+ SQL non protégée
+ {else}
+ SQL
+ {/if}
+
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+{elseif $mode == 'delete'}
+
+ {include file="common/delete_form.tpl"
+ legend="Supprimer cette recherche enregistrée ?"
+ warning="Êtes-vous sûr de vouloir supprimer la recherche enregistrée « %s » ?"|args:$search.label
+ csrf_key=$csrf_key
+ }
+
+{elseif count($list) == 0}
+ Aucune recherche enregistrée. Faire une nouvelle recherche
+{else}
+
+
+
+ Recherche
+ Type
+ Statut
+
+
+
+
+ {foreach from=$list item="search"}
+
+ {$search.label}
+ {if $search.type == $search::TYPE_JSON}Avancée{else}SQL{/if}
+ {if !$search.id_user}Publique{else}Privée{/if}
+
+ {linkbutton href="%s?id=%d"|args:$search_url,$search.id shape="search" label="Rechercher"}
+ {if $search.id_user || $session->canAccess($access_section, $session::ACCESS_ADMIN)}
+ {linkbutton href="?edit=%d"|args:$search.id shape="edit" label="Modifier"}
+ {linkbutton href="?delete=%d"|args:$search.id shape="delete" label="Supprimer"}
+ {/if}
+
+
+ {/foreach}
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/_menu.tpl b/src/templates/config/_menu.tpl
new file mode 100644
index 0000000..c1336ad
--- /dev/null
+++ b/src/templates/config/_menu.tpl
@@ -0,0 +1,37 @@
+{if !$dialog}
+
+
+
+
+ {if $current == 'users'}
+ {if $sub_current == 'fields'}
+ {linkbutton shape="plus" label="Ajouter un champ" href="new.php"}
+ {/if}
+
+
+ {elseif $current == 'advanced'}
+
+ {/if}
+
+ {/if}
\ No newline at end of file
diff --git a/src/templates/config/advanced/api.tpl b/src/templates/config/advanced/api.tpl
new file mode 100644
index 0000000..cf1af16
--- /dev/null
+++ b/src/templates/config/advanced/api.tpl
@@ -0,0 +1,67 @@
+{include file="_head.tpl" title="API" current="config"}
+
+{include file="config/_menu.tpl" current="advanced" sub_current="api"}
+
+{form_errors}
+
+{if count($list)}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button name="delete" value=1 type="submit" label="Supprimer l'identifiant sélectionné" shape="delete"}
+
+
+
+
+
+
+
+
+ Description
+ Identifiant
+ Accès
+ Création
+ Dernière utilisation
+
+
+
+ {foreach from=$list item="c"}
+
+
+ {input type="radio" name="id" value=$c.id}
+
+ {$c.label}
+ {$c.key}
+ {$access_levels[$c.access_level]}
+ {$c.created|date_short}
+ {if $c.last_use}{$c.last_use|date}{else}-{/if}
+
+ {/foreach}
+
+
+
+
+{/if}
+
+
+
+ Créer un nouvel identifiant
+
+ Cet identifiant vous permettra de faire des requêtes vers l'API, pour modifier ou récupérer les informations de votre association.
+ {linkbutton shape="help" label="Documentation de l'API" href="%swiki?name=API"|args:$website}
+
+
+ {input type="text" name="label" label="Description" required=true}
+ {input type="text" name="key" label="Identifiant" help="Seules les lettres minuscules, chiffres et tirets bas sont acceptés." pattern="[a-z0-9_]+" required=true default=$default_key}
+ {input type="text" label="Mot de passe" default=$secret readonly="readonly" help="Ce mot de passe ne sera plus affiché, il est conseillé de le copier/coller et l'enregistrer de votre côté." name="secret" copy=true}
+ {input type="select" required=true label="Autorisation d'accès" options=$access_levels name="access_level"}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="add" label="Créer" shape="plus" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/advanced/audit.tpl b/src/templates/config/advanced/audit.tpl
new file mode 100644
index 0000000..c103edb
--- /dev/null
+++ b/src/templates/config/advanced/audit.tpl
@@ -0,0 +1,19 @@
+{include file="_head.tpl" title="Journal d'audit — Historique des actions des membres" current="config"}
+
+{include file="../_menu.tpl" current="advanced" sub_current="audit"}
+
+
+ Cette page liste les tentatives de connexion, les modifications de mot de passe ou d'identifiant, et toutes les actions de création, suppression ou modification effectuées par tous les membres.
+
+
+{if $list->count()}
+ {include file="users/_log_list.tpl"}
+{else}
+
+ Aucune activité trouvée.
+
+{/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/advanced/errors.tpl b/src/templates/config/advanced/errors.tpl
new file mode 100644
index 0000000..0bf1a6c
--- /dev/null
+++ b/src/templates/config/advanced/errors.tpl
@@ -0,0 +1,86 @@
+{include file="_head.tpl" title="Journaux" current="config"}
+
+{include file="config/_menu.tpl" current="advanced" sub_current="errors"}
+
+{if isset($reports) && isset($id)}
+
+ {foreach from=$main.errors item="error"}
+ {$error.type}: {$error.message} [Code: {$error.errorCode}]
+ {if !empty($error.backtrace)}
+ {foreach from=$error.backtrace item=trace}
+
+ {if $trace.function}
+
+ {$trace.function}
+ {if !empty($trace.args)}
+
+ {foreach from=$trace.args key=name item=arg}
+
+ {$name}
+ {$arg}
+
+ {/foreach}
+
+ {/if}
+
+ {/if}
+ {if $trace.file}{$trace.file}:{$trace.line} {/if}
+ {if !empty($trace.code)}
+ {foreach from=$trace.code item=line key=n}{if $n == $trace.line}{/if}{$n} {$line}{if $n == $trace.line} {/if} {/foreach}
+ {/if}
+
+ {/foreach}
+ {/if}
+ {/foreach}
+
+ {foreach from=$reports item=report}
+
+ Occurence du {$report.context.date|date}
+
+ {foreach from=$report.context key="k" item="v"}
+
+ {$k}
+ {if $k == 'date'}{$v|date}{else}{$v}{/if}
+
+ {/foreach}
+
+
+ {/foreach}
+
+{elseif isset($errors)}
+ {if !count($errors)}
+ Aucune erreur n'a été trouvée dans le journal error.log
+ {else}
+
+
+
+ Réf.
+ Site
+ Erreur
+ Occurences
+ Dernière fois
+
+
+
+
+ {foreach from=$errors item=error key=ref}
+
+ {$ref}
+ {$error.hostname}
+
+ {$error.message}
+ {$error.source}
+
+ {$error.count}
+ {$error.last_seen|date}
+
+ {linkbutton shape="menu" label="Voir les détails" href="%s?type=errors&id=%s"|args:$self_url_no_qs,$ref}
+
+
+ {/foreach}
+
+
+ {/if}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/advanced/index.tpl b/src/templates/config/advanced/index.tpl
new file mode 100644
index 0000000..a544eff
--- /dev/null
+++ b/src/templates/config/advanced/index.tpl
@@ -0,0 +1,87 @@
+{include file="_head.tpl" title="Fonctions avancées" current="config"}
+
+{include file="config/_menu.tpl" current="advanced" sub_current=null}
+
+{form_errors}
+
+{if $_GET.msg == 'RESET'}
+
+ La remise à zéro a été effectuée. Une sauvegarde a également été créée.
+
+{else if $_GET.msg == 'REOPEN'}
+
+ L'exercice sélectionné a été réouvert.
+
+{/if}
+
+
+ Ces fonctionnalités sont réservées à un public averti.
+
+
+
+ Réouvrir un exercice clôturé
+
+ À utiliser si vous avez clôturé un exercice par erreur. Attention, en comptabilité cette action est normalement exceptionnelle.
+
+
+ {linkbutton shape="reload" href="reopen.php" label="Réouvrir un exercice clôturé"}
+
+
+ Journal d'audit
+
+ Affiche l'historique des actions (connexion, changement de mot de passe, création de membre, modification comptable, etc.) effectuées par tous les membres.
+
+
+ {linkbutton shape="history" label="Voir le journal d'audit" href="audit.php"}
+
+
+ Accès à l'API
+
+ Permet de gérer les identifiants d'accès à l'API. Pour interfacer d'autres programmes et scripts avec les données de votre association.
+
+
+ {linkbutton shape="settings" label="Gérer les accès à l'API" href="api.php"}
+
+
+ SQL — Accès à la base de données brute
+
+ Visualiser le schéma des tables et les données brutes de la base de données, ou y effectuer des requêtes SQL.
+
+
+ {linkbutton shape="code" label="Visualiser la base de données SQL" href="sql.php"}
+
+
+ {if ENABLE_TECH_DETAILS}
+ Journal des erreurs système
+
+ Affiche le détail des erreurs système de Paheko et PHP.
+
+
+ {linkbutton shape="menu" label="Voir le journal des erreurs système" href="errors.php"}
+
+
+ {if SQL_DEBUG}
+ Journal des requêtes SQL
+
+ Affiche le détail de toutes les requêtes SQL exécutées et leurs performances.
+
+
+ {linkbutton shape="menu" label="Voir le journal des requêtes SQL" href="sql_debug.php"}
+
+ {/if}
+
+ {/if}
+
+ {if $logged_user.password}
+ Remise à zéro
+
+ Efface toutes les données, sauf votre compte de membre. Utile pour revenir à l'état initial après une période d'essai.
+
+
+ {linkbutton shape="delete" href="reset.php" label="Remise à zéro"}
+
+ {/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/advanced/reopen.tpl b/src/templates/config/advanced/reopen.tpl
new file mode 100644
index 0000000..284f416
--- /dev/null
+++ b/src/templates/config/advanced/reopen.tpl
@@ -0,0 +1,29 @@
+{include file="_head.tpl" title="Réouvrir un exercice clôturé" current="config"}
+
+{include file="config/_menu.tpl" current="advanced"}
+
+{form_errors}
+
+
+{if count($closed_years)}
+
+
+ Réouvrir un exercice clôturé
+
+ L'exercice sera réouvert, mais une écriture sera ajoutée au journal général indiquant que celui-ci a été réouvert après clôture. Cette écriture ne peut pas être supprimée.
+
+
+ {input type="select" options=$closed_years label="Exercicer à réouvrir" name="year" required=true default_empty="Sélectionner un exercice"}
+
+
+
+ {csrf_field key="reopen_year"}
+ {button type="submit" name="reopen_ok" label="Réouvrir l'exercice sélectionné" shape="reset" class="main"}
+
+
+{else}
+ Il n'y a aucun exercice clôturé. Il est donc impossible d'en réouvrir un :-)
+{/if}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/advanced/reset.tpl b/src/templates/config/advanced/reset.tpl
new file mode 100644
index 0000000..d2e8c7f
--- /dev/null
+++ b/src/templates/config/advanced/reset.tpl
@@ -0,0 +1,36 @@
+{include file="_head.tpl" title="Remise à zéro" current="config"}
+
+{include file="config/_menu.tpl" current="advanced"}
+
+{form_errors}
+
+
+
+ Remise à zéro
+
+
Attention : toutes les données seront effacées !
+
+ Les membres seront supprimés, ainsi que les activités et l'historique d'inscription
+ Les écritures et exercices comptables seront aussi supprimés, avec toutes les autres données comptables
+ Le contenu du site web
+ Les documents, etc.
+ Bref : tout sera effacé !
+
+
Seul votre compte membre sera re-créé avec le même email et mot de passe.
+
+
+ Une sauvegarde sera automatiquement créée avant de procéder à la remise à zéro.
+
+
+ {input type="password" name="password_check" label="Merci d'entrer ici votre mot de passe" help="Pour valider que vous désirez bien tout effacer !" required=true}
+
+
+ {csrf_field key="reset"}
+ {button type="submit" name="reset_ok" label="Oui, je veux tout effacer et repartir de zéro" shape="delete" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/advanced/sql.tpl b/src/templates/config/advanced/sql.tpl
new file mode 100644
index 0000000..6f55fd4
--- /dev/null
+++ b/src/templates/config/advanced/sql.tpl
@@ -0,0 +1,185 @@
+{include file="_head.tpl" title="SQL" current="config"}
+
+{include file="config/_menu.tpl" current="advanced" sub_current="sql"}
+
+
+ {if isset($query) || isset($table) || isset($table_info)}
+ {linkbutton shape="left" label="Retour à la liste des tables SQL" href="sql.php"}
+ {else}
+ {linkbutton shape="search" label="Exécuter une requête SQL" href="?query="}
+ {/if}
+
+ {linkbutton shape="check" label="Vérifier la BDD" href="?pragma=integrity_check"}
+ {linkbutton shape="check" label="Vérifier les clés étrangères" href="?pragma=foreign_key_check"}
+ {if ENABLE_TECH_DETAILS}
+ {linkbutton shape="reload" label="Reconstruire" href="?pragma=vacuum"}
+ {/if}
+
+
+
+{form_errors}
+
+{if isset($query)}
+
+ {if $query !== null}
+ Requête SQL
+
+
+ Faire une requête SQL en lecture
+
+ {input type="textarea" cols="70" rows="10" name="query" default=$query class="full-width"}
+
+
+ {button type="submit" name="run" label="Exécuter" shape="search"}
+
+
+ {/if}
+
+ {if !empty($result_count)}
+
+
+ {exportmenu form=true right=true}
+
+ {$result_count} résultats trouvés pour cette requête, en {$query_time} ms.
+
+ {if $result_header}
+
+
+ {foreach from=$result_header item="label"}
+ {$label}
+ {/foreach}
+
+
+ {/if}
+
+ {foreach from=$result item="row"}
+
+ {foreach from=$row key="key" item="value"}
+
+ {if null === $value}
+ NULL
+ {else}
+ {$value}
+ {/if}
+
+ {/foreach}
+
+ {/foreach}
+
+
+
+ {elseif isset($result)}
+
+
+ Aucun résultat trouvé.
+
+
+ {/if}
+
+
+{elseif !empty($table_info)}
+
+
+
Table : {$table_info.name}
+
+ {linkbutton shape="menu" href="?table=%s"|args:$table_info.name label="Parcourir les données"}
+
+
+
+ {include file="common/_sql_table.tpl" table=$table_info.schema indexes=$table_info.indexes fk_link=true class="center"}
+
+
Schéma de la table
+
{$table_info.sql}
+
Schéma des index
+
{if empty($table_info.sql_indexes)}Aucun index {else}{$table_info.sql_indexes}{/if}
+
+
+{elseif !empty($table)}
+
+ Table : {$table}
+
+
+ {linkbutton shape="table" href="?table_info=%s"|args:$table label="Voir la structure"}
+ {exportmenu}
+
+
+
+ {$list->getHTMLPagination()|raw}
+
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {foreach from=$row key="key" item="value"}
+
+ {if null == $value}
+ NULL
+ {elseif $is_module && $key === 'document'}
+ {$value|format_json}
+ {else}
+ {$value}
+ {/if}
+
+ {/foreach}
+
+
+ {/foreach}
+
+
+
+
+ {$list->getHTMLPagination()|raw}
+
+{else}
+
+ Liste des tables
+
+
+
+
+ Nom
+
+ Nombre de lignes
+ Taille
+
+
+
+ {foreach from=$tables_list key="name" item="table"}
+
+ {$name}
+
+ {linkbutton shape="menu" href="?table=%s"|args:$name label="Parcourir"}
+ {linkbutton shape="table" href="?table_info=%s"|args:$name label="Structure"}
+
+ {$table.count}
+ {if $table.size !== null}{$table.size|size_in_bytes}{else}(inconnue){/if}
+
+ {/foreach}
+
+
+
+ Liste des triggers
+
+
+ {foreach from=$triggers_list key="name" item="sql"}
+
+ {$name}
+ {$sql}
+
+ {/foreach}
+
+
+ Liste des index
+
+
+ {foreach from=$index_list key="name" item="sql"}
+
+ {$name}
+ {$sql}
+
+ {/foreach}
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/advanced/sql_debug.tpl b/src/templates/config/advanced/sql_debug.tpl
new file mode 100644
index 0000000..c233f71
--- /dev/null
+++ b/src/templates/config/advanced/sql_debug.tpl
@@ -0,0 +1,74 @@
+{include file="_head.tpl" title="Journal SQL" current="config"}
+
+{include file="config/_menu.tpl" current="advanced" sub_current="sql_debug"}
+
+{if isset($debug)}
+
+
+
+ T.
+ Durée
+ Trace
+
+
+ {foreach from=$debug.list item="row"}
+
+
+ =round($row->time / 1000, 2)?>
+
+ duration / 1000, 2); ?>
+ {if $d > 0.4}
+ {$d}
+ {else}
+ {$d}
+ {/if}
+
+ {$row.trace}
+
+
+ {$row.sql}
+
+
+ EXPLAIN: {$row.explain}
+
+
+ {/foreach}
+
+{elseif isset($list)}
+
+ Liste des pages consultées ayant mené à des requêtes SQL.
+
+
+ {if !count($list)}
+ Aucune requête n'a été trouvée dans le log
+ {else}
+
+
+
+ ID
+ Date
+ Script
+ Membre connecté
+ Durée totale
+ Durée SQL
+ Nombre de requêtes
+
+
+
+ {foreach from=$list item="row"}
+
+ {$row.id}
+ {$row.date|date_format}
+ {$row.script}
+ {$row.user}
+ {if $row.request_time >= 80}{$row.request_time} ms {else}{$row.request_time} ms{/if}
+ {if $row.sql_time >= 20}{$row.sql_time} ms {else}{$row.sql_time} ms{/if}
+ {$row.count}
+
+ {/foreach}
+
+
+ {/if}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/backup/_menu.tpl b/src/templates/config/backup/_menu.tpl
new file mode 100644
index 0000000..f24e95e
--- /dev/null
+++ b/src/templates/config/backup/_menu.tpl
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/src/templates/config/backup/auto.tpl b/src/templates/config/backup/auto.tpl
new file mode 100644
index 0000000..0dc5ca4
--- /dev/null
+++ b/src/templates/config/backup/auto.tpl
@@ -0,0 +1,50 @@
+{include file="_head.tpl" title="Sauvegardes automatiques" current="config"}
+
+{include file="config/_menu.tpl" current="backup"}
+
+{include file="config/backup/_menu.tpl" current="auto"}
+
+{form_errors}
+
+{if $_GET.msg === 'CONFIG_SAVED'}
+
+ La configuration des sauvegardes a bien été enregistrée.
+
+{/if}
+
+
+
+
+ Configuration de la sauvegarde automatique
+
+ En activant cette option une sauvegarde sera automatiquement créée à chaque intervalle donné.
+ Par exemple en activant une sauvegarde hebdomadaire, une copie des données sera réalisée
+ une fois par semaine, sauf si aucune modification n'a été effectuée sur les données
+ ou que personne ne s'est connecté.
+
+
+ {input type="select" name="backup_frequency" source=$config label="Fréquence de sauvegarde" required=true options=$frequencies}
+ {input type="number" step="1" min="0" max="50" name="backup_limit" source=$config label="Nombre de sauvegardes conservées" required=true options=$frequencies help="Par exemple avec une fréquence mensuelle, en indiquant de conserver 12 sauvegardes, vous pourrez garder un an d'historique de sauvegardes." default=1}
+
+ Attention : si vous choisissez un nombre important et un intervalle réduit,
+ l'espace disque occupé par vos sauvegardes va rapidement augmenter.
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="config" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
+ Attention, la sauvegarde automatique permet uniquement de revenir à un état antérieur, mais ne prévient pas de la perte des données !
+ Pour cela, il est recommandé de faire des sauvegardes manuelles en téléchargeant une copie des données sur votre ordinateur.
+
+ {if FILE_STORAGE_BACKEND !== 'SQLite'}
+
La sauvegarde automatique ne concerne que la base de données, mais pas les documents, fichiers joints aux écritures ou aux membres, ni le contenu du site web.
+ {/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/backup/documents.tpl b/src/templates/config/backup/documents.tpl
new file mode 100644
index 0000000..8e8d7bb
--- /dev/null
+++ b/src/templates/config/backup/documents.tpl
@@ -0,0 +1,43 @@
+{include file="_head.tpl" title="Restaurer les documents et fichiers joints" current="config"}
+
+{include file="config/_menu.tpl" current="backup"}
+
+{include file="config/backup/_menu.tpl" current="restore"}
+
+{if $ok}
+La restauration a été effectuée.
+{/if}
+
+{if $failed}
+{$failed} fichiers n'ont pas pu être restaurés car ils dépassaient la taille autorisée.
+{/if}
+
+{form_errors}
+
+
+
+
+ Restaurer les fichiers avec une archive ZIP de sauvegarde
+
+ Sélectionner ici une sauvegarde (archive ZIP) des documents pour les restaurer.
+
+
+ {input type="file" name="file" label="Archive ZIP à restaurer" no_size_limit=true required=true}
+
+
+ Les fichiers existants qui portent le même nom seront écrasés. Les documents existants qui ne figurent pas dans la sauvegarde ne seront pas affectés.
+
+
+ {csrf_field key="files_restore"}
+ {button type="submit" name="restore" label="Restaurer cette sauvegarde des documents" shape="upload" class="main"}
+
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/backup/index.tpl b/src/templates/config/backup/index.tpl
new file mode 100644
index 0000000..d5d1941
--- /dev/null
+++ b/src/templates/config/backup/index.tpl
@@ -0,0 +1,77 @@
+{include file="_head.tpl" title="Sauvegarder" current="config"}
+
+{include file="config/_menu.tpl" current="backup"}
+
+{include file="config/backup/_menu.tpl" current="index"}
+
+{if $_GET.msg == 'BACKUP_CREATED'}
+
+ Une nouvelle sauvegarde a été créée.
+
+{/if}
+
+{if !$config.backup_frequency}
+
+
Les sauvegardes automatiques sont désactivées. Il est recommandé de les activer pour pouvoir revenir en arrière en cas de problème majeur.
+
Attention : cela ne dispense pas de réaliser des sauvegardes régulières sur votre ordinateur.
+
{linkbutton shape="settings" href="auto.php" label="Configurer les sauvegardes automatiques"}
+
+{/if}
+
+
+
+
+ Sauvegarder la base de données sur mon ordinateur
+ En cas de problème sur le serveur (plantage, dysfonctionnement du disque dur, incendie, etc.) vous pourriez perdre vos données.
+ Il est donc recommandé de réaliser régulièrement des sauvegardes et de les conserver sur votre ordinateur ou sur une clé USB !
+
+
+ Cliquez sur le bouton ci-dessous pour télécharger une copie de la base de données et enregistrez-la ensuite sur votre ordinateur ou une clé USB :
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="download" label="Télécharger la base de données" shape="download"} ({$db_size|size_in_bytes})
+
+
+
+
+ Créer une nouvelle sauvegarde de la base de données sur le serveur
+ Cette sauvegarde sera enregistrée sur le serveur et pourra être restaurée plus tard.
+ Conseillé par exemple avant de réaliser une opération importante, pour pouvoir revenir en arrière.
+ {if FILE_STORAGE_BACKEND !== 'SQLite'}
+ Attention : seule la base de données est sauvegardées, pas les documents et fichiers joints.
+ {/if}
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="create" label="Créer une nouvelle sauvegarde" shape="plus"}
+
+
+
+
+ Télécharger les fichiers sur mon ordinateur
+ Permet de télécharger un fichier ZIP contenant tous les fichiers (hors base de données et sauvegardes de la base de données) : documents, logo, fichiers joints aux écritures, aux fiches de membres, et aux pages du site web.
+ {if $files_size > 1024*1024*100}
+
+ Ce téléchargement de {$files_size|size_in_bytes} peut prendre plusieurs minutes.
+ Veillez à utiliser une bonne connexion internet.
+
+ {/if}
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="zip" label="Télécharger une archive ZIP de tous les documents" shape="download"} ({$files_size|size_in_bytes})
+
+
+
+
+
+ Exporter les données comptables
+ Il est conseillé d'exporter les informations comptables (bilan, compte de résultat, grand livre et journal) pour archivage après la clôture de chaque exercice, et de les stocker sur un support pérenne (clé USB, carte mémoire, disque dur externe).
+ Ils doivent être conservés 10 ans.
+
+ {linkbutton shape="menu" label="Voir la liste des exercices" href="!acc/years/"}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/backup/restore.tpl b/src/templates/config/backup/restore.tpl
new file mode 100644
index 0000000..8bb2a16
--- /dev/null
+++ b/src/templates/config/backup/restore.tpl
@@ -0,0 +1,126 @@
+{include file="_head.tpl" title="Restaurer" current="config"}
+
+{include file="config/_menu.tpl" current="backup"}
+
+{include file="config/backup/_menu.tpl" current="restore"}
+
+{form_errors}
+
+{if $code == Backup::INTEGRITY_FAIL && ALLOW_MODIFIED_IMPORT}
+ Pour passer outre, renvoyez le fichier en cochant la case « Ignorer les erreurs ».
+ Attention, si vous avez effectué des modifications dans la base de données, cela peut créer des bugs !
+{/if}
+
+{if $ok}
+
+ {if $ok == 'restore'}La restauration a bien été effectuée.
+ {if $ok_code & Backup::NOT_AN_ADMIN}
+
+
+ Vous n'êtes pas administrateur dans cette sauvegarde. Paheko a donné les droits d'administration à toutes les catégories afin d'empêcher de ne plus pouvoir se connecter.
+ Merci de corriger les droits des catégories maintenant.
+ {elseif $ok_code & Backup::CHANGED_USER}
+
+
+ Votre compte membre n'existait pas dans la sauvegarde qui a été restaurée, vous êtes désormais connecté avec le premier compte administrateur.
+
+ {/if}
+ {elseif $ok == 'remove'}La sauvegarde a été supprimée.
+ {/if}
+
+{/if}
+
+{if $_GET.from_file}
+
+
+
+ Restaurer depuis un fichier de sauvegarde
+
+ Attention, l'intégralité des données courantes seront effacées et remplacées par celles
+ contenues dans le fichier fourni.
+
+
+ Une sauvegarde des données courantes sera effectuée avant le remplacement,
+ en cas de besoin d'annuler cette restauration.
+
+
+ {input type="file" name="file" label="Fichier de sauvegarde à restaurer" required=true}
+
+
+ {csrf_field key="backup_restore"}
+ {button type="submit" name="restore_file" label="Restaurer depuis le fichier sélectionné" shape="upload" class="main"}
+
+ {if $code && ($code == Backup::INTEGRITY_FAIL && ALLOW_MODIFIED_IMPORT)}
+
+ {input type="checkbox" name="force_import" value="1" label="Ignorer les erreurs, je sais ce que je fait"}
+
+ {/if}
+
+
+
+
+{else}
+
+
+
+ {linkbutton shape="reload" label="Restaurer les documents et fichiers joints" href="documents.php"}
+ {linkbutton shape="upload" label="Restaurer la base de données à partir de mon ordinateur" href="restore.php?from_file=1"}
+
+
+
+ {if !$code && !$ok}
+
+ Espace disque occupé par les sauvegardes : {$size|size_in_bytes}
+
+ {/if}
+
+
+
+ {if empty($list)}
+ Aucune copie de sauvegarde disponible.
+ {else}
+
+
+
+
+
+ Nom
+ Taille
+ Date
+ Version
+
+
+
+ {foreach from=$list item="backup"}
+
+ {if $backup.can_restore}{input type="radio" name="selected" value=$backup.filename}{/if}
+ {$backup.name}
+ {$backup.size|size_in_bytes}
+ {$backup.date|date_short:true}
+ {if $backup.error}
+ Sauvegarde corrompue : {$backup.error}
+ {else}
+ {$backup.version}{if !$backup.can_restore} — Version trop ancienne pour pouvoir être restaurée {/if}
+ {/if}
+
+
+ {linkbutton href="?download=%s"|args:$backup.filename label="Télécharger" shape="download"}
+
+
+ {/foreach}
+
+
+
+ Attention, en cas de restauration, l'intégralité des données courantes seront effacées et remplacées par celles contenues dans la sauvegarde sélectionnée.
+
+
+ {csrf_field key="backup_manage"}
+ {button type="submit" name="restore" label="Restaurer la sauvegarde sélectionnée" shape="reset" class="main"}
+ {button type="submit" name="remove" label="Supprimer la sauvegarde sélectionnée" shape="delete"}
+
+ {/if}
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/backup/versions.tpl b/src/templates/config/backup/versions.tpl
new file mode 100644
index 0000000..296d12f
--- /dev/null
+++ b/src/templates/config/backup/versions.tpl
@@ -0,0 +1,99 @@
+{include file="_head.tpl" title="Versionnement des fichiers" current="config"}
+
+{include file="config/_menu.tpl" current="backup"}
+
+{include file="config/backup/_menu.tpl" current="versions"}
+
+{form_errors}
+
+
+{if $_GET.msg === 'CONFIG_SAVED'}
+
+ La configuration du versionnement a bien été enregistrée.
+
+{elseif $_GET.msg === 'PRUNED'}
+ Les anciennes versions des fichiers qui étaient trop anciennes ont bien été supprimées.
+{/if}
+
+{if FILE_VERSIONING_POLICY}
+
+
+
L'administrateur du serveur a défini cette règle de conservation des anciennes versions :
+
{$policy.label} — {$policy.help}
+
Note : les fichiers de plus de =FILE_VERSIONING_MAX_SIZE?> ne seront pas versionnés.
+
+{else}
+
+
+
+ Conservation des anciennes versions des fichiers
+
+ Règle de conservation des anciennes versions
+ {foreach from=$versioning_policies key="key" item="policy"}
+ {input type="radio-btn" name="file_versioning_policy" value=$key default="" source=$config label=$policy.label help=$policy.help}
+ {/foreach}
+
+
+ {if FILE_VERSIONING_MAX_SIZE}
+ Note : les fichiers de plus de =FILE_VERSIONING_MAX_SIZE?> ne seront pas versionnés.
+ {else}
+ {input type="number" name="file_versioning_max_size" min=1 label="Taille maximale des fichiers à versionner" source=$config required=true help="Les fichiers qui sont plus gros que cette taille ne seront pas versionnés." suffix="Mo" max=100 size=3}
+ {/if}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
+{/if}
+
+
+
+ Le versionnement des fichiers, c'est quoi ?
+
+ Pour éviter de perdre un travail précieux en cas de maladresse, les anciennes versions des fichiers peuvent être conservées.
+ Lorsqu'un fichier est modifié, l'ancienne version est sauvegardée.
+ Seuls les fichiers suivants sont versionnés :
+
+ documents de l'association (menu "Documents") ;
+ fichiers joints aux fiches des membres ;
+ fichiers joints aux écritures comptables.
+
+ {linkbutton shape="help" href="%sversionnement"|args:$help_url label="Plus d'informations sur le versionnement"}
+
+
+
+
+ Espace disque utilisé par les anciennes versions : {$disk_use|size_in_bytes}.
+ {linkbutton shape="gallery" label="Voir le détail de l'utilisation de l'espace disque" href="!config/disk_usage.php"}
+
+
+{if $disk_use}
+ {if $config.file_versioning_policy === 'none'}
+
+ {linkbutton href="?delete_versions=1" shape="delete" label="Supprimer les anciennes versions" target="_dialog"}
+
+ {else}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="prune_versions" value=1 shape="reload" label="Nettoyer les anciennes versions"} (pour gagner un peu d'espace disque)
+
+
+ {/if}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/backup/versions_delete.tpl b/src/templates/config/backup/versions_delete.tpl
new file mode 100644
index 0000000..762abaa
--- /dev/null
+++ b/src/templates/config/backup/versions_delete.tpl
@@ -0,0 +1,13 @@
+{include file="_head.tpl" title="Suppression des anciennes versions" current="config"}
+
+{include file="config/_menu.tpl"}
+
+{assign var="size_bytes" value=$disk_use|size_in_bytes}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer les anciennes versions ?"
+ warning="Libérer %s d'espace disque en supprimant toutes les anciennes versions ?"|args:$size_bytes
+ alert="Après cette action, seule la dernière version de chaque fichier sera conservée."
+ info="Même les versions nommées seront supprimées."}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/categories/delete.tpl b/src/templates/config/categories/delete.tpl
new file mode 100644
index 0000000..d4b9636
--- /dev/null
+++ b/src/templates/config/categories/delete.tpl
@@ -0,0 +1,11 @@
+{include file="_head.tpl" title="Supprimer une catégorie de membre" current="config"}
+
+{include file="config/_menu.tpl" current="categories"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer cette catégorie de membres ?"
+ warning="Êtes-vous sûr de vouloir supprimer la catégorie « %s » ?"|args:$cat.name
+ alert="Attention, la catégorie ne doit plus contenir de membres pour pouvoir être supprimée."
+ info="Les écritures comptables liées à l'historique des membres inscrits à cette activité ne seront pas supprimées, et la comptabilité demeurera inchangée."}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/categories/edit.tpl b/src/templates/config/categories/edit.tpl
new file mode 100644
index 0000000..5e1124b
--- /dev/null
+++ b/src/templates/config/categories/edit.tpl
@@ -0,0 +1,54 @@
+{include file="_head.tpl" title="Modifier une catégorie de membre" current="config"}
+
+{include file="config/_menu.tpl" current="categories"}
+
+{form_errors}
+
+
+
+
+ Informations générales
+
+ {input type="text" name="name" label="Nom" required=true source=$cat}
+ Configuration
+ {input type="checkbox" name="hidden" label="Catégorie cachée" source=$cat value=1}
+
+ Si coché, les membres de cette catégorie :
+ - ne seront visibles que par les membres ayant le droit d'administration ;
+ - ne recevront pas de messages collectifs ;
+ - ne recevront pas de rappels de cotisation ;
+ - leurs inscriptions aux activités seront cachées.
+ Utile par exemple pour archiver les membres qui n'ont pas renouvelé leur cotisation, avant suppression.
+
+
+
+
+
+ Droits
+
+ {foreach from=$permissions key="type" item="perm"}
+ {$perm.label}
+ {if $perm.disabled}
+
+ En tant qu'administrateur, vous ne pouvez pas désactiver ce droit pour votre propre catégorie.
+ Ceci afin d'empêcher que vous ne puissiez plus vous connecter.
+
+ {/if}
+ {foreach from=$perm.options key="level" item="label"}
+
+ {'perm_' . $type} == $level}checked="checked"{/if} {if $perm.disabled}disabled="disabled"{/if} />
+ {$perm.shape} {$label}
+
+ {/foreach}
+ {/foreach}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/categories/index.tpl b/src/templates/config/categories/index.tpl
new file mode 100644
index 0000000..4378d17
--- /dev/null
+++ b/src/templates/config/categories/index.tpl
@@ -0,0 +1,49 @@
+{include file="_head.tpl" title="Catégories de membres" current="config"}
+
+{include file="config/_menu.tpl" current="users" sub_current="categories"}
+
+
+
+ Nom
+ Membres
+ Droits
+
+
+
+ {foreach from=$list item="cat"}
+
+ {$cat.name}
+ {$cat.count}
+
+ {display_permissions permissions=$cat}
+
+
+ {if $cat.id != $logged_user.id_category}
+ {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$cat.id target="_dialog"}
+ {/if}
+ {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$cat.id}
+ {linkbutton shape="users" label="Liste des membres" href="!users/?cat=%d"|args:$cat.id}
+
+
+ {/foreach}
+
+
+
+
+
+
+ Ajouter une catégorie
+
+ {input type="text" name="name" label="Nom" required=true}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Ajouter" shape="right" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/custom.tpl b/src/templates/config/custom.tpl
new file mode 100644
index 0000000..7fb8908
--- /dev/null
+++ b/src/templates/config/custom.tpl
@@ -0,0 +1,104 @@
+{include file="_head.tpl" title="Personnalisation" current="config"}
+
+{include file="config/_menu.tpl" current="custom"}
+
+{if isset($_GET['ok']) && !$form->hasErrors()}
+
+ La configuration a bien été enregistrée.
+
+{/if}
+
+{form_errors}
+
+
+ Association et site web
+
+ Logo
+ {if $url = $config->fileURL('logo', '150px')}
+
+
+
+ {/if}
+
+ {linkbutton href="!config/edit_file.php?k=%s"|args:'logo' label="Modifier" shape="edit" target="_dialog"}
+
+
+ Ce logo sera affiché en haut du menu de l'administration, sur le site web et sur les documents imprimés.
+
+ Petite icône
+ {if $url = $config->fileURL('favicon')}
+
+
+
+ {/if}
+
+ {linkbutton href="!config/edit_file.php?k=%s"|args:'favicon' label="Modifier" shape="edit" target="_dialog"}
+
+
+ Cette image sera affichée dans l'onglet du navigateur (favicon).
+
+ Grande icône
+ {if $url = $config->fileURL('icon', '150px')}
+
+
+
+
+ {$config.org_name|truncate:12:'…':true}
+
+
+ {/if}
+
+ {linkbutton href="!config/edit_file.php?k=%s"|args:'icon' label="Modifier" shape="edit" target="_dialog"}
+
+
+ Cette image sera utilisée comme icône de l'application mobile (à installer depuis {link href="!" label="la page d'accueil"} et le bouton « Installer comme application sur l'écran d'accueil »).
+
+ Signature ou tampon de l'association
+ {if $url = $config->fileURL('signature', '150px')}
+
+
+
+ {/if}
+
+ {linkbutton href="!config/edit_file.php?k=%s"|args:'signature' label="Modifier" shape="edit" target="_dialog"}
+
+
+ Cette image sera utilisée dans les documents générés pour l'association.
+ Attention : ne pas mettre la vraie signature d'une personne physique, car tous les membres connectés ont accès à cette image. Il est conseillé de créer une signature ou un tampon spécifique à l'association.
+
+
+
+
+
+
+ Interface d'administration
+
+ {input type="color" pattern="#[a-f0-9]{6}" title="Couleur au format hexadécimal" default=$color1 source=$config name="color1" label="Couleur primaire" placeholder=$color1}
+ {input type="color" pattern="#[a-f0-9]{6}" title="Couleur au format hexadécimal" default=$color2 source=$config name="color2" label="Couleur secondaire" placeholder=$color2}
+ {input type="file" label="Image de fond" name="background" help="Il est conseillé d'utiliser une image en noir et blanc avec un fond blanc pour un meilleur rendu. Dimensions recommandées : 380x200" accept="image/*,*.jpeg,*.jpg,*.png,*.gif"}
+ Texte de la page d'accueil
+
+ {linkbutton href="!config/edit_file.php?k=%s"|args:'admin_homepage' label="Modifier" shape="edit" target="_dialog" data-dialog-height="90%"}
+
+
+ Ce contenu sera affiché à la connexion d'un membre, ou en cliquant sur l'onglet 'Accueil' du menu de gauche.
+
+ Personnalisation CSS de l'administration
+
+ {linkbutton href="!config/edit_file.php?k=%s"|args:'admin_css' label="Modifier" shape="edit" target="_dialog" data-dialog-height="90%"}
+
+
+ Permet de rajouter des règles CSS qui modifieront l'apparence de l'interface d'administration.
+
+
+
+
+ {csrf_field key="config_custom"}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/disk_usage.tpl b/src/templates/config/disk_usage.tpl
new file mode 100644
index 0000000..a454929
--- /dev/null
+++ b/src/templates/config/disk_usage.tpl
@@ -0,0 +1,65 @@
+{include file="_head.tpl" title="Utilisation de l'espace disque" current="config"}
+
+{include file="config/_menu.tpl"}
+
+Base de données
+
+
+
+ {if FILE_STORAGE_BACKEND == 'SQLite'}
+ La base de données stocke toutes les informations : membres, activités, rappels, comptabilité, site web, documents, etc.
+ {else}
+ La base de données stocke toutes les informations : membres, activités, rappels, comptabilité, site web, etc. sauf les documents .
+ {/if}
+
+
+
+
+
+ Total
+ {$db_total|size_in_bytes}
+
+
+
+ Base de données seule
+ {$db|size_in_bytes}
+ {linkbutton shape="download" label="Faire une sauvegarde" href="!config/backup/"}
+
+
+ Sauvegardes
+ {$db_backups|size_in_bytes}
+ {linkbutton shape="menu" label="Liste des sauvegardes" href="!config/backup/restore.php"}
+
+
+
+Fichiers
+
+
+
{$quota_used|size_in_bytes} utilisés sur {$quota_max|size_in_bytes} autorisés ({$quota_left|size_in_bytes} libres)
+
+
+
+
+ Total
+ {$quota_used|size_in_bytes}
+ {linkbutton shape="download" label="Télécharger tous les fichiers" href="!config/backup/"}
+
+
+ {foreach from=$contexts item="context" key="ctx"}
+
+ {$context.label}
+ {$context.size|size_in_bytes}
+
+ {if $ctx == 'trash'}
+ {linkbutton shape="trash" label="Voir les fichiers supprimés" href="!docs/trash.php"}
+ {elseif $ctx == 'versions' && $versioning_policy !== 'none'}
+ {linkbutton href="!config/backup/versions.php" shape="reload" label="Nettoyer les anciennes versions"}
+ {elseif $ctx == 'versions' && $versioning_policy === 'none' && $context.size}
+ {linkbutton href="!config/backup/versions.php?prune_versions=1" shape="delete" label="Supprimer les anciennes versions" target="_dialog"}
+ {/if}
+
+
+ {/foreach}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/edit_image.tpl b/src/templates/config/edit_image.tpl
new file mode 100644
index 0000000..bd55767
--- /dev/null
+++ b/src/templates/config/edit_image.tpl
@@ -0,0 +1,19 @@
+{include file="_head.tpl" title="Envoi d'image"}
+
+{form_errors}
+
+
+
+ Téléverser un fichier
+
+ {input type="file" name="file" label="Fichier à envoyer" data-enhanced=1}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="upload" label="Envoyer" shape="upload" class="main"}
+ {button type="submit" name="reset" label="Supprimer" shape="delete"}
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/config/ext/_details.tpl b/src/templates/config/ext/_details.tpl
new file mode 100644
index 0000000..4f75bbc
--- /dev/null
+++ b/src/templates/config/ext/_details.tpl
@@ -0,0 +1,92 @@
+
+
+ {if $item.broken_message || $item.missing}
+
+
+
{$item.label}
+ {if $item.broken_message}
+
+ Extension cassée : installation impossible
+ Erreur : {$item.broken_message}
+
+ {elseif $item.missing}
+
+ {if ENABLE_TECH_DETAILS}
+ Le code source de l'extension "{$item.name}" est absent du dossier des plugins
+ {else}
+ Cette extension n'est pas installée sur ce serveur.
+ {/if}
+
+ Il n'est pas possible de la supprimer non plus, le code source est nécessaire pour pouvoir la supprimer.
+
+ {/if}
+
+ {else}
+
+ {if $item.icon_url}
+
+ {/if}
+
+
+
+
+
+ {if $item.module && $item.module->canDelete()}
+
+ {icon shape="edit"} Modifiée
+
+ {elseif $item.module}
+
Modifiable
+ {/if}
+
+
+ {$item.description|escape|nl2br}
+
+
+ {if $item.author && $item.author_url}
+ Par {link label=$item.author href=$item.author_url target="_blank"}
+ {elseif $item.author}
+ Par {$item.author}
+ {/if}
+ {if $item.plugin && $item.plugin.version}— Version {$item.plugin.version}{/if}
+
+
+
+ {if $item.enabled && $item.url && !$item.web}
+ {linkbutton shape="right" label="Ouvrir" href=$item.url}
+ {/if}
+ {if $item.config_url && $item.enabled}
+ {linkbutton label="Configurer" href=$item.config_url shape="settings"}
+ {/if}
+ {if $item.type === 'module'}
+ {linkbutton label="Modifier le code" href="edit.php?module=%s"|args:$item.name shape="edit"}
+ {/if}
+ {if $item.readme}
+ {linkbutton label="Documentation" href="details.php?type=%s&name=%s&readme"|args:$item.type:$item.name shape="help"}
+ {/if}
+
+
+
+
+
+ {if $item.enabled && !$item.web}
+ {button type="submit" label="Désactiver" shape="eye-off" name="disable[%s]"|args:$item.type value=$item.name}
+ {elseif !$item.enabled}
+ {button type="submit" label="Activer" shape="eye" name="enable[%s]"|args:$item.type value=$item.name}
+ {/if}
+
+ {if empty($hide_details)}
+ {linkbutton shape="menu" label="Détails" href=$item.details_url}
+ {if $item.restrict_section}
+
+ Accès limité
+ {display_permissions section=$item.restrict_section level=$item.restrict_level}
+
+ {/if}
+ {/if}
+
+
+
+ {/if}
+
+
\ No newline at end of file
diff --git a/src/templates/config/ext/_nav.tpl b/src/templates/config/ext/_nav.tpl
new file mode 100644
index 0000000..f492ebc
--- /dev/null
+++ b/src/templates/config/ext/_nav.tpl
@@ -0,0 +1,43 @@
+
+ {if !empty($url_plugins)}
+
+ {linkbutton shape="help" href=$url_plugins label="Trouver d'autres extensions à installer" target="_blank"}
+
+ {/if}
+
+ {if $ext}
+ {if $current === 'edit'}
+
+ {linkbutton shape="help" label="Comment modifier et développer des modules" href="!static/doc/modules.html" target="_dialog"}
+
+ {linkbutton shape="export" label="Exporter ce module" href="?module=%s&export"|args:$module.name}
+
+ {linkmenu label="Ajouter…" shape="plus" right=true}
+ {linkbutton shape="upload" label="Depuis mon ordinateur" target="_dialog" href="!common/files/upload.php?p=%s"|args:$parent_path_uri}
+ {linkbutton shape="folder" label="Dossier" target="_dialog" href="!docs/new_dir.php?path=%s&no_redir"|args:$parent_path_uri}
+ {linkbutton shape="text" label="Fichier texte" target="_dialog" href="!docs/new_file.php?path=%s&ext="|args:$parent_path_uri}
+ {/linkmenu}
+
+ {elseif $current === 'details' && $module}
+
+ {linkbutton label="Exporter ce module" href="edit.php?module=%s&export"|args:$module.name shape="export"}
+
+ {/if}
+
+ {else}
+
+ {/if}
+
\ No newline at end of file
diff --git a/src/templates/config/ext/delete.tpl b/src/templates/config/ext/delete.tpl
new file mode 100644
index 0000000..4822f2a
--- /dev/null
+++ b/src/templates/config/ext/delete.tpl
@@ -0,0 +1,29 @@
+{include file="_head.tpl" title="Désinstaller une extension" current="config"}
+
+{if $plugin}
+ {include file="common/delete_form.tpl"
+ legend="Supprimer une extension"
+ confirm="Cocher cette case pour confirmer la suppression de toutes les données liées à cette extension"
+ warning="Êtes-vous sûr de vouloir supprimer l'extension « %s » ?"|args:$plugin.label
+ alert="Attention, cela supprimera toutes les données liées à l'extension !"}
+{elseif $mode == 'data'}
+ {include file="common/delete_form.tpl"
+ legend="Supprimer les données d'une extension"
+ confirm="Cocher cette case pour confirmer la suppression de toutes les données liées à cette extension"
+ warning="Êtes-vous sûr de vouloir supprimer les données de l'extension « %s » ?"|args:$module.label
+ alert="Attention, cela supprimera toutes les données liées à l'extension"}
+{elseif $mode == 'reset'}
+ {include file="common/delete_form.tpl"
+ legend="Supprimer les modifications d'un module"
+ confirm="Cocher cette case pour confirmer la suppression des modifications"
+ warning="Êtes-vous sûr de vouloir supprimer les modifications apportées au module « %s » ?"|args:$module.label
+ alert="Le module reviendra à son état initial."}
+{else}
+ {include file="common/delete_form.tpl"
+ legend="Supprimer une extension"
+ confirm="Cocher cette case pour confirmer la suppression de cette extension"
+ warning="Êtes-vous sûr de vouloir supprimer l'extension « %s » ?"|args:$module.label
+ alert="Attention, cela supprimera toutes les données liées à l'extension, ainsi que l'extension elle-même."}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/ext/details.tpl b/src/templates/config/ext/details.tpl
new file mode 100644
index 0000000..f6d59e0
--- /dev/null
+++ b/src/templates/config/ext/details.tpl
@@ -0,0 +1,129 @@
+{include file="_head.tpl" title="Extension — %s"|args:$ext.label current="config"}
+
+{include file="config/_menu.tpl" current="ext"}
+{include file="./_nav.tpl" current=$mode ext=$ext}
+
+{if $mode === 'readme'}
+
+ {$content|raw|markdown}
+
+{elseif $mode === 'disk' && $module}
+
+ getDataSize();
+ $code_size = $module->getCodeSize();
+ $files_size = $module->getFilesSize();
+ $total = $data_size + $code_size + $files_size;
+ ?>
+
+
+ Total
+ {$total|size_in_bytes}
+
+
+
+ Données seules
+ {$data_size|size_in_bytes}
+
+ {if $data_size}
+ {linkbutton href="!config/advanced/sql.php?table=module_data_%s"|args:$ext.name shape="table" label="Voir les données brutes"}
+ {/if}
+ {if $data_size && $module->canDeleteData()}
+ {linkbutton shape="delete" label="Supprimer les données" href="delete.php?module=%s&mode=data"|args:$ext.name target="_dialog"}
+ {/if}
+
+
+
+ Code source
+ {$code_size|size_in_bytes}
+
+ {if $code_size && $ext.module->hasDist()}
+ {linkbutton label="Supprimer toutes les modifications" href="delete.php?module=%s&mode=reset"|args:$ext.name shape="delete" target="_dialog"}
+ {/if}
+
+
+
+ Fichiers stockés
+ {$files_size|size_in_bytes}
+
+
+ Utilisation de l'espace disque
+
+
+{else}
+ {if !$ext.enabled}
+ Cette extension est désactivée.
+ {/if}
+
+
+ {include file="./_details.tpl" item=$ext hide_details=true}
+ {csrf_field key=$csrf_key}
+
+
+
+ {if $module}
+ {if !$module.enabled && $module->canDelete()}
+
+ {linkbutton label="Supprimer ce module" href="delete.php?module=%s&mode=delete"|args:$module.name shape="delete" target="_dialog"}
+
+ {/if}
+ {else}
+ {if !$plugin.enabled && $plugin->exists()}
+
+ {linkbutton label="Supprimer les données" href="delete.php?plugin=%s"|args:$plugin.name shape="delete" target="_dialog"}
+
+ {/if}
+ {/if}
+
+
+ {if $ext.restrict_section || count($access_details)}
+
+
Comment accéder à cette extension ?
+
+ {if $ext.restrict_section}
+
+ {display_permissions section=$ext.restrict_section level=$ext.restrict_level}
+ Seuls les membres ayant accès à la section
+ « =Entities\Users\Category::PERMISSIONS[$ext->restrict_section]['label']?> »
+ en
+
+ {if $ext.restrict_level === Users\Session::ACCESS_READ}lecture
+ {elseif $ext.restrict_level === Users\Session::ACCESS_WRITE}lecture et écriture
+ {elseif $ext.restrict_level === Users\Session::ACCESS_ADMIN}administration
+ {/if}
+
+ pourront accéder à cette extension.
+
+ {/if}
+
+
+ {foreach from=$access_details item="label"}
+ {$label|raw}
+ {/foreach}
+
+
+
+ {/if}
+
+
+
Comment fonctionne cette extension ?
+ {if $ext.module}
+
Cette extension est un module , elle est donc modifiable .
+
Un module est composé de code HTML et Brindille, facile à maîtriser et adapter à ses besoins.
+ {if $ext.module->hasDist()}
+
Vous pouvez à tout moment revenir à la version d'origine en cas de problème.
+ {/if}
+ {linkbutton shape="help" label="Comment modifier et développer des modules" href="!static/doc/modules.html" target="_dialog"}
+ {else}
+
Cette extension est un plugin installé sur notre serveur.
+ {if !ENABLE_TECH_DETAILS}
+
Son code n'est pas modifiable par votre organisation pour des raisons de sécurité.
+ {else}
+
Son code PHP peut être modifié si vous avez accès au serveur et des connaissances en programmation.
+
{linkbutton shape="help" href=$url_help_plugins label="Documentation des plugins" target="_blank"}
+ {/if}
+ {/if}
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/ext/diff.tpl b/src/templates/config/ext/diff.tpl
new file mode 100644
index 0000000..99c4ab4
--- /dev/null
+++ b/src/templates/config/ext/diff.tpl
@@ -0,0 +1,7 @@
+{include file="_head.tpl" title="Différences" current="config" hide_title=true}
+
+Différences avec le fichier d'origine
+
+{diff old=$dist new=$local}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/ext/edit.tpl b/src/templates/config/ext/edit.tpl
new file mode 100644
index 0000000..b7043ba
--- /dev/null
+++ b/src/templates/config/ext/edit.tpl
@@ -0,0 +1,73 @@
+{include file="_head.tpl" title="Code source — %s"|args:$module.label current="config"}
+
+{include file="config/_menu.tpl" current="ext"}
+{include file="./_nav.tpl" current="edit" ext=$module}
+
+{form_errors}
+
+
+
+ {if $path}
+
+ {icon shape="left"}
+ {link href="?module=%s"|args:$module.name label="Retour"}
+
+
+
+
+
+ {/if}
+ {foreach from=$list item="file"}
+
+
+ {if $file.dir}
+ {icon shape="folder"}
+ {/if}
+
+
+ {if $file.dir}
+ {link href="?module=%s&p=%s"|args:$module.name:$file.path label=$file.name}
+ {elseif $file.editable}
+ {link href=$file.edit_url label=$file.name target="_dialog"}
+ {else}
+ {link href=$file.open_url label=$file.name target="_dialog" data-mime=$file.type}
+ {/if}
+
+
+ {if $file.local}
+ {$file.modified|relative_date}
+ {else}
+ (non modifié)
+ {/if}
+
+
+ {if $file.editable}
+ {linkbutton label="Éditer" shape="edit" target="_dialog" href=$file.edit_url}
+ {/if}
+
+
+ {if $file.local && $file.dist && $file.editable}
+ {linkbutton label="Différences" href="diff.php?module=%s&p=%s"|args:$module.name,$file.path shape="menu" target="_dialog"}
+ {/if}
+
+
+ {if $file.local && $file.dist}
+ {linkbutton label="Supprimer les modifications" href="%s&trash=no"|args:$file.delete_url shape="delete" target="_dialog"}
+ {elseif $file.local && $file.dir}
+ {linkbutton label="Supprimer ce dossier" href=$file.delete_url shape="trash" target="_dialog"}
+ {elseif $file.local}
+ {linkbutton label="Supprimer ce fichier" href=$file.delete_url shape="trash" target="_dialog"}
+ {/if}
+
+
+ {/foreach}
+
+
+
+{if $module->hasDist() && $module->hasLocal()}
+
+ {linkbutton label="Supprimer toutes les modifications" href="delete.php?module=%s&mode=reset"|args:$module.name shape="delete" target="_dialog"}
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/ext/import.tpl b/src/templates/config/ext/import.tpl
new file mode 100644
index 0000000..dca8b13
--- /dev/null
+++ b/src/templates/config/ext/import.tpl
@@ -0,0 +1,34 @@
+{include file="_head.tpl" title="Importer un module" current="config"}
+
+{form_errors}
+
+{if !$_GET.ok}
+
+
Attention, faites-vous confiance à la personne qui vous a transmis ce module ?
+
+ Importer un module de source inconnue peut présenter des risques pour les données de votre association.
+ Un module écrit par une personne mal intentionnée pourrait voler les données de votre association, ou modifier ou supprimer des données.
+
+
+
+ {linkbutton shape="right" href="?ok=1" label="Je comprends les risques, continuer"}
+
+{else}
+
+
+ Importer un module d'extension
+
+ {input type="file" required=true label="Fichier ZIP du module" name="zip" accept=".zip,application/zip"}
+ {if $exists}
+ {input type="checkbox" name="overwrite" value=1 label="Écraser mes modifications existantes" required=true}
+ {/if}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" shape="right" label="Importer ce module" name="import" class="main"}
+
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/ext/index.tpl b/src/templates/config/ext/index.tpl
new file mode 100644
index 0000000..c0d667a
--- /dev/null
+++ b/src/templates/config/ext/index.tpl
@@ -0,0 +1,31 @@
+{include file="_head.tpl" title="Extensions" current="config"}
+
+{include file="config/_menu.tpl" current="ext"}
+
+{include file="./_nav.tpl" current=$current ext=null}
+
+Les extensions apportent des fonctionnalités supplémentaires, et peuvent être activées selon vos besoins.
+
+{form_errors}
+
+
+
+ {foreach from=$list item="item"}
+ {include file="./_details.tpl" item=$item}
+ {/foreach}
+
+ {csrf_field key=$csrf_key}
+
+
+
+ La mention Modifiable indique que cette extension est un module que vous pouvez modifier.
+
+
+
+ {linkbutton shape="help" label="Comment modifier et développer des modules" href="!static/doc/modules.html" target="_dialog"}
+ {linkbutton shape="plus" label="Créer un module" href="new.php" target="_dialog"}
+ {linkbutton shape="import" label="Importer un module" href="import.php" target="_dialog"}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/ext/new.tpl b/src/templates/config/ext/new.tpl
new file mode 100644
index 0000000..d612668
--- /dev/null
+++ b/src/templates/config/ext/new.tpl
@@ -0,0 +1,47 @@
+{include file="_head.tpl" title="Créer un nouveau module" current="config"}
+
+
+ Les modules permettent aux personnes ayant quelques compétences en programmation de rajouter des fonctionnalités.
+ {linkbutton shape="help" label="Comment modifier et développer des modules" href="!static/doc/modules.html" target="_dialog"}
+
+
+{form_errors}
+
+
+
+ Informations du module
+
+ {input type="text" name="label" required="true" label="Nom du module" help="Par exemple « Reçu personnalisé »"}
+ {input type="text" name="name" required="true" label="Nom unique du module" pattern="[a-z][a-z0-9]*(_[a-z0-9]+)*" help="Ne peut contenir que des lettres minuscules sans accent, des chiffres, et des tirets bas."}
+ {input type="textarea" cols="50" rows="3" name="description" label="Description"}
+ {input type="text" name="author" label="Nom de l'auteur⋅e"}
+ {input type="url" name="author_url" label="Adresse du site de l'auteur⋅e"}
+
+
+
+
+ Configuration
+
+ {input type="checkbox" name="menu" label="Afficher dans le menu" value=1}
+ {input type="checkbox" name="home_button" label="Afficher un bouton sur l'accueil" value=1}
+ {*input type="select" name="web" label="Type de module" options=$types required=true*}
+ {input type="select_groups" name="restrict" options=$sections label="Restreindre l'accès aux membres ayant accès à…"}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" shape="right" label="Créer ce module" name="create" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/fields/delete.tpl b/src/templates/config/fields/delete.tpl
new file mode 100644
index 0000000..3383f81
--- /dev/null
+++ b/src/templates/config/fields/delete.tpl
@@ -0,0 +1,10 @@
+{include file="_head.tpl" title="Supprimer un champ" current="config"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ce champ ?"
+ confirm="Cocher cette case pour supprimer le champ, cela effacera de manière permanente cette donnée de toutes les fiches membres."
+ warning="Êtes-vous sûr de vouloir supprimer le champ « %s » ?"|args:$field.label
+ alert="Attention, ce champ ainsi que les données qu'il contient seront supprimés de toutes les fiches membres existantes."
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/fields/edit.tpl b/src/templates/config/fields/edit.tpl
new file mode 100644
index 0000000..281bca6
--- /dev/null
+++ b/src/templates/config/fields/edit.tpl
@@ -0,0 +1,102 @@
+exists() ? 'Modifier un champ' : 'Ajouter un champ';
+?>
+{include file="_head.tpl" current="config" title=$title}
+
+{include file="config/_menu.tpl" current="fields"}
+
+{form_errors}
+
+
+
+ {$title}
+
+ {if !$field->isPreset() && !$field->exists()}
+ {input type="select" name="type" options=$field::TYPES source=$field label="Type" default="text" help="Il ne sera plus possible de modifier le type une fois le champ créé." required=true}
+ {else}
+ Le type et l'identifiant ne sont pas modifiables.
+ {input type="select" name="type" options=$field::TYPES source=$field label="Type" disabled=true}
+ {/if}
+ {input type="text" name="label" label="Libellé" required=true source=$field}
+
+
+
+
+ Identifiant unique
+
+ Cet identifiant est utilisé dans la base de données de Paheko pour identifier ce champ.
+
+
+ {if !$field->isPreset() && !$field->exists()}
+ {input type="text" name="name" pattern="[a-z](_?[a-z0-9]+)*" label="Identifiant" required=true source=$field help="Ne peut comporter que des lettres minuscules et des tirets bas. Par exemple pour un champ demandant l'adresse, on peut utiliser 'adresse_postale'. Ce nom ne peut plus être modifié ensuite."}
+ {else}
+ {input type="text" name="name" disabled=true label="Identifiant" source=$field}
+ {/if}
+
+
+
+ Préférences
+
+ {input type="checkbox" name="list_table" value=1 label="Afficher dans la liste des membres" source=$field}
+
+
+ {input type="checkbox" name="required" value=1 label="Champ obligatoire" help="Si coché, une fiche membre ne pourra pas être enregistrée si ce champ n'est pas renseigné." source=$field}
+ {input type="text" name="default_value" source=$field label="Valeur par défaut" help="Si renseigné, le champ aura cette valeur par défaut lors de l'ajout d'un nouveau membre"}
+
+
+ {input type="text" name="help" label="Texte d'aide" help="Apparaîtra dans les formulaires de manière identique à ce texte." source=$field}
+
+
+ {input type="textarea" required=true name="sql" class="full-width" rows=3 source=$field label="Code SQL utilisée pour calculer ce champ" disabled=$field->isPreset()}
+
+ Les champs calculés utilisent du code SQL. Ils sont des colonnes virtuelles de la vue (VIEW
) des membres.
+
+
+
+
+
+
+ Options possibles
+
+ Attention renommer ou supprimer une option n'affecte pas ce qui a déjà été enregistré dans les fiches des membres.
+ Attention changer l'ordre des options peut avoir des effets indésirables.
+
+
+ {if $field.options}
+ {foreach from=$field.options item="option"}
+ {input type="text" name="options[]" default=$option}
+ {/foreach}
+ {/if}
+ {input type="text" name="options[]"}
+
+
+
+
+ Accès
+
+ Le membre lui-même peut…
+{if !$field->isNumber()}
+ Indiquer ici si le membre pourra voir ou modifier cette information dans sa section "Mes infos personnelles" .
+ {input type="radio" name="user_access_level" value=$session::ACCESS_WRITE label="Voir et modifier ce champ" source=$field}
+{/if}
+ {input type="radio" name="user_access_level" value=$session::ACCESS_READ label="Seulement voir ce champ" source=$field default=$session::ACCESS_READ}
+ {input type="radio" name="user_access_level" value=$session::ACCESS_NONE label="Rien, cette information ne doit pas être visible par le membre" source=$field}
+ Attention : conformément à la réglementation (RGPD), quel que soit votre choix, le membre pourra voir le contenu de ce champ en effectuant un export de ses données personnelles (s'il a le droit de se connecter).
+ Un autre membre peut voir ce champ…
+ {input type="radio" name="management_access_level" value=$session::ACCESS_READ label="S'il a accès à la gestion des membres (en lecture, écriture, ou administration)" source=$field default=$session::ACCESS_READ}
+ {input type="radio" name="management_access_level" value=$session::ACCESS_WRITE label="Seulement s'il a accès en écriture à la gestion des membres" source=$field}
+ {input type="radio" name="management_access_level" value=$session::ACCESS_ADMIN label="Seulement s'il a accès en administration à la gestion des membres" source=$field}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {linkbutton label="Annuler" shape="left" href="./" target="_parent"}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/fields/index.tpl b/src/templates/config/fields/index.tpl
new file mode 100644
index 0000000..0edcc05
--- /dev/null
+++ b/src/templates/config/fields/index.tpl
@@ -0,0 +1,89 @@
+{include file="_head.tpl" current="config" title="Fiche des membres"}
+
+{include file="config/_menu.tpl" current="users" sub_current="fields"}
+
+{if $_GET.msg == 'SAVED_ORDER'}
+
+ L'ordre a bien été enregistré.
+
+{elseif $_GET.msg == 'SAVED'}
+
+ Les modifications ont bien été enregistrées.
+
+{elseif $_GET.msg == 'DELETED'}
+
+ Le champ a bien été supprimé.
+
+{/if}
+
+{form_errors}
+
+
+
+
+
+ Ordre
+ Libellé
+ Liste des membres
+ Obligatoire ?
+ Accès membre
+ Accès gestion
+
+
+
+
+ {foreach from=$fields item="field"}
+
+
+ {button shape="menu"}
+
+
+ {$field.label}
+ {if $field.list_table}Oui{/if}
+ {if $field.required}Obligatoire{/if}
+
+ {if $field.user_access_level === $session::ACCESS_NONE}
+ {icon shape="eye-off" title="Caché"}
+ {elseif $field.user_access_level === $session::ACCESS_READ}
+ {icon shape="eye" title="Visible"}
+ {else}
+ {icon shape="eye" title="Visible"}
+ {icon shape="edit" title="Modifiable"}
+ {/if}
+
+
+ {display_permissions section="users" level=$field.management_access_level}
+ {if $field.management_access_level === $session::ACCESS_READ}
+ Lecture
+ {elseif $field.management_access_level === $session::ACCESS_WRITE}
+ Écriture
+ {elseif $field.management_access_level === $session::ACCESS_ADMIN}
+ Administration
+ {/if}
+
+
+ {if $field->canDelete()}
+ {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$field.id target="_dialog"}
+ {/if}
+ {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$field.id target="_dialog"}
+ {button shape="up" title="Déplacer vers le haut" class="up"}
+ {button shape="down" title="Déplacer vers le bas" class="down"}
+
+
+ {/foreach}
+
+
+
+
+ Cliquer et glisser-déposer sur une ligne pour en changer l'ordre.
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer l'ordre" shape="right"}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/fields/new.tpl b/src/templates/config/fields/new.tpl
new file mode 100644
index 0000000..99b2d09
--- /dev/null
+++ b/src/templates/config/fields/new.tpl
@@ -0,0 +1,37 @@
+{include file="_head.tpl" current="config" title="Ajouter un champ aux fiches des membres"}
+
+{include file="config/_menu.tpl" current="fields"}
+
+{form_errors}
+
+Avant de demander une information personnelle à vos membres… en avez-vous vraiment besoin ?
+ La loi demande à minimiser au strict minimum les données collectées. Pensez également aux risques de sécurité : si vous demandez la date de naissance complète, cela pourrait être utilisé pour de l'usurpation d'identité, il serait donc plus sage de ne demander que le mois et l'année de naissance, si ces données sont nécessaires afin d'avoir l'âge de la personne.
+
+
+
+
+ Ajouter un nouveau champ
+
+
+ {input type="radio-btn" name="preset" value="" label="Champ personnalisé" required=true help="Permet de créer n'importe quel type de champ : texte, nombre, choix multiple, case à cocher, fichier, etc." default=""}
+
+
+ Champs prédéfinis :
+
+ {foreach from=$presets key="key" item="preset"}
+ {if !$preset.disabled}
+ {input type="radio-btn" name="preset" value=$key label=$preset.label required=true disabled=$preset.disabled help=$preset.install_help}
+ {/if}
+ {/foreach}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {linkbutton label="Annuler" shape="left" href="./" target="_parent"}
+ {button type="submit" name="add" label="Ajouter" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/index.tpl b/src/templates/config/index.tpl
new file mode 100644
index 0000000..74ef28f
--- /dev/null
+++ b/src/templates/config/index.tpl
@@ -0,0 +1,115 @@
+{include file="_head.tpl" title="Configuration" current="config"}
+
+{include file="config/_menu.tpl" current="index"}
+
+{if isset($_GET['ok']) && !$form->hasErrors()}
+
+ La configuration a bien été enregistrée.
+
+{/if}
+
+{form_errors}
+
+
+
+
+ Informations
+
+ Version installée
+ Paheko {$paheko_version}
+
+ Le développement et le support de Paheko ne sont possibles que grâce à votre soutien !
+ {linkbutton href="https://kd2.org/soutien.html" label="Faire un don pour soutenir le développement" target="_blank" shape="export"} :-)
+
+ {if $new_version}
+
+ Une nouvelle version {$new_version} est disponible !
+ {if ENABLE_UPGRADES}
+ {linkbutton shape="export" href="upgrade.php" label="Mettre à jour"}
+ {else}
+ {linkbutton shape="export" href=$paheko_website label="Télécharger la mise à jour" target="_blank"}
+ {/if}
+
+ {/if}
+ {if PDF_COMMAND == 'prince'}
+
+ Les PDF sont générés à l'aide du génial logiciel Prince . Merci à eux.
+
+ {/if}
+ {if ENABLE_TECH_DETAILS}
+ Informations système
+
+ Version PHP : {$php_version}
+ Version SQLite : {$sqlite_version}
+ Heure du serveur : {$server_time|date}
+ Chiffrement GnuPG : {if $has_gpg_support}disponible, module activé{else}non, module PHP gnupg non installé ?{/if}
+ {linkbutton shape="settings" label="Configuration du serveur" href="server/"}
+
+ {/if}
+ Espace disque
+
+ {linkbutton shape="gallery" label="Voir l'espace disque utilisé" href="disk_usage.php"}
+
+
+
+
+
+ Informations sur l'association
+
+ {input type="text" name="org_name" required=true source=$config label="Nom"}
+ {input type="email" name="org_email" required=true source=$config label="Adresse e-mail de contact" help="Cette adresse est aussi utilisée comme adresse d'expédition des messages collectifs."}
+ {input type="textarea" name="org_address" source=$config label="Adresse postale"}
+ {input type="tel" name="org_phone" source=$config label="Numéro de téléphone"}
+ {input type="textarea" cols="50" rows="2" name="org_infos" required=false source=$config label="Informations diverses" help="Ce champ sera utilisé sur les reçus. Il peut être utile de faire figurer ici le numéro de SIRET par exemple."}
+
+
+
+
+ Site web
+
+ Cette option permet d'activer ou désactiver la visibilité publique du site web intégré à Paheko.
+ En désactivant le site public, les visiteurs seront automatiquement redirigés vers la page de connexion.
+ Vous pourrez toujours y publier des informations, mais celles-ci ne seront visibles que pour les membres connectés.
+
+
+ {input type="radio" name="site_disabled" value=0 source=$config label="Activer le site web public"}
+ {input type="radio" name="site_disabled" value=1 source=$config label="Désactiver le site web"}
+
+
+
Si vous avez déjà un site web à une autre adresse, vous pouvez l'indiquer ici :
+
+ {input type="url" name="org_web" source=$config label="Site web externe"}
+
+
+
+
+
+ Localisation
+
+ {input type="text" name="currency" required=true source=$config label="Monnaie" help="Inscrire ici la devise utilisée : €, CHF, XPF, etc." size="3"}
+ {input type="select" name="country" required=true source=$config label="Pays" options=$countries}
+
+
+
+
+ {csrf_field key="config"}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/server/index.tpl b/src/templates/config/server/index.tpl
new file mode 100644
index 0000000..8180724
--- /dev/null
+++ b/src/templates/config/server/index.tpl
@@ -0,0 +1,81 @@
+{include file="_head.tpl" title="Configuration du serveur" current="config"}
+
+{include file="config/_menu.tpl" current=null}
+
+{if $_GET.msg === 'OK'}
+L'opération a été réalisée.
+{/if}
+
+{form_errors}
+
+Stockage des fichiers
+
+
+
+ Taille des fichiers dans la base de données : {$db_size|size_in_bytes:false}
+{if FILE_STORAGE_BACKEND === 'SQLite'}
+ Paheko stocke le contenu des fichiers et leurs méta-données dans la base de données.
+{elseif FILE_STORAGE_BACKEND === 'FileSystem'}
+ Paheko stocke les méta-données des fichiers dans la base de données.
+ Paheko stocke le contenu des fichiers dans le répertoire =FILE_STORAGE_CONFIG?>
+ Reconstruire les méta-données à partir du système de fichiers
+
+ {button shape="reload" label="Scanner le répertoire" name="scan" type="submit"}
+
+
+ Permet de mettre à jour les méta-données si vous avez réalisé des modifications externes à Paheko dans les fichiers
+ (suppression, renommage, déplacement, etc.).
+
+
+ En cliquant sur ce bouton, Paheko va scanner le répertoire de stockage des fichiers,
+ supprimer les méta-données fichiers qui n'existent plus sur le disque, et ajouter celles des fichiers qui sont apparus.
+
+ {if !$db_size}
+ Importer dans la base de données
+
+ {button shape="import" label="Copier VERS la base de données" name="import" type="submit"}
+
+
+ En cliquant sur ce bouton, le contenu des fichiers sera recopié à l'intérieur de la base de données .
+ Utile pour migrer vers un stockage en base de données.
+
+ {else}
+ Exporter vers le répertoire de stockage
+
+ {button shape="export" label="Copier DEPUIS la base de données" name="export" type="submit"}
+
+
+
+ Les fichiers seront effacés de la base de données. Tout fichier local existant sera écrasé.
+
+
+
+ En cliquant sur ce bouton, les fichiers seront créés dans le répertoire de stockage, à partir des informations de la base de données .
+ Utile pour migrer vers un stockage en répertoire local.
+
+ {/if}
+{/if}
+
+
+{csrf_field key=$csrf_key}
+
+
+Configuration de Paheko
+
+
+ {foreach from=$constants key="key" item="value"}
+
+ {$key}
+
+ {if $value === true}TRUE
+ {elseif $value === false}TRUE
+ {elseif $value === null}NULL
+ {elseif is_array($value)}
+ =var_export($value)?>
+ {else}{$value}
{/if}
+
+
+ {/foreach}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/upgrade.tpl b/src/templates/config/upgrade.tpl
new file mode 100644
index 0000000..46e76a3
--- /dev/null
+++ b/src/templates/config/upgrade.tpl
@@ -0,0 +1,89 @@
+{include file="_head.tpl" title="Mise à jour" current="config"}
+
+{include file="config/_menu.tpl" current="index"}
+
+{form_errors}
+
+
+
+{if !count($releases)}
+ Aucune mise à jour n'est disponible.
+{elseif $downloaded && $verified === false}
+ Le fichier d'installation est corrompu.
+{elseif $downloaded}
+
+ Mise à jour vers {$version}
+ {if $verified === true}
+
+ Le fichier d'installation a été correctement vérifié.
+
+ {else}
+
+ L'intégrité du fichier d'installation n'a pas pu être vérifié automatiquement.
+ {if !$can_verify}
+ (Cela est probablement dû au fait que votre installation ne dispose pas du module GnuPG .)
+ {/if}
+
+ {/if}
+
+ {$diff.delete|count} fichiers seront supprimés
+
+ {foreach from=$diff.delete key="file" item="path"}
+ {$file}
+ {/foreach}
+
+
+
+ {$diff.create|count} fichiers seront rajoutés
+
+ {foreach from=$diff.create key="file" item="path"}
+ {$file}
+ {/foreach}
+
+
+
+ {$diff.update|count} fichiers seront modifiés
+
+ Si vous aviez bidouillé ces fichiers, les modifications seront écrasées.
+
+
+ {foreach from=$diff.update key="file" item="path"}
+ {$file}
+ {/foreach}
+
+
+
+ {input type="checkbox" name="upgrade" value=$version label="Je confirme vouloir procéder à la mise à jour" help="Cette action peut casser votre installation !"}
+
+
+
+ N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:$website target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour !
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="next" label="Effectuer la mise à jour" shape="right" class="main"}
+
+{else}
+
+ Mise à jour
+
+ {foreach from=$releases key="version" item="release"}
+ {input type="radio" name="download" value=$version label=$version}
+ {if $version == $latest}
+
+ Dernière version stable, conseillée.
+
+ {/if}
+ {/foreach}
+
+
+
+ N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:$website target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour !
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="next" label="Télécharger" shape="right" class="main"}
+
+{/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/users/field_selector.tpl b/src/templates/config/users/field_selector.tpl
new file mode 100644
index 0000000..f50d0a5
--- /dev/null
+++ b/src/templates/config/users/field_selector.tpl
@@ -0,0 +1,54 @@
+{include file="_head.tpl" title="Sélectionner un champ"}
+
+
+
+ {foreach from=$list item="label" key="key"}
+
+
+ {$label}
+
+
+ Sélectionner
+
+
+ {/foreach}
+
+
+
+{literal}
+
+{/literal}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/config/users/index.tpl b/src/templates/config/users/index.tpl
new file mode 100644
index 0000000..aa027c9
--- /dev/null
+++ b/src/templates/config/users/index.tpl
@@ -0,0 +1,64 @@
+{include file="_head.tpl" title="Préférences membres" current="config"}
+
+{include file="config/_menu.tpl" current="users" sub_current=null}
+
+{if isset($_GET['ok']) && !$form->hasErrors()}
+
+ La configuration a bien été enregistrée.
+
+{/if}
+
+
+{form_errors}
+
+
+
+
+ Préférences des membres
+
+ {input type="select" name="default_category" source=$config options=$users_categories required=true label="Catégorie par défaut des nouveaux membres"}
+
+
+
+
+ Champs spéciaux des fiches de membres
+
+ {input type="select" name="login_field" default=$login_field options=$login_fields_list required=true label="Champ utilisé comme identifiant de connexion" help="Ce champ des fiches membres sera utilisé comme identifiant pour se connecter à l'administration de l'association."}
+ Ce champ doit être unique : il ne peut pas y avoir deux membres ayant la même valeur dans ce champ.
+ {input type="list" name="name_fields" required=true label="Champs utilisés pour définir l'identité des membres" help="Ces champs des fiches membres seront utilisés comme identité (nom) du membre dans les emails, les fiches, les pages, etc." target="!config/users/field_selector.php" multiple=true default=$name_fields}
+ Il est possible d'utiliser plusieurs champs, par exemple en choisissant les champs Nom et Prénom , l'identité des membres apparaîtra comme Nom Prénom . Dans ce cas l'ordre des champs dans l'identité est déterminé selon l'ordre des champs dans la fiche membre.
+
+
+
+
+ Journaux d'activité
+
+ Les actions de création, modification ou suppression dans la base de données peuvent être enregistrées pour chaque membre.
+ Cela permet de garder une trace, pour savoir qui à fait quoi.
+
+
+
+ {input type="select" options=$log_retention_options source=$config name="log_retention" required=true label="Durée de conservation des journaux d'activité" help="Après ce délai, les journaux seront supprimés."}
+
+
+
+
+ Sécurité
+
+ {input type="select" name="auto_logout" source=$config required=true label="Déconnecter automatiquement les membres inactifs après…" options=$logout_delay_options}
+
+ Permet de déconnecter automatiquement un membre s'il garde la gestion de l'association ouverte, sans interagir.
+ Utile par exemple pour éviter de laisser une session ouverte sur un ordinateur partagé. Ce réglage ne s'applique pas aux membres ayant coché la case "Rester connecté⋅e".
+
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/docs/_nav.tpl b/src/templates/docs/_nav.tpl
new file mode 100644
index 0000000..31d24ee
--- /dev/null
+++ b/src/templates/docs/_nav.tpl
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/src/templates/docs/action_delete.tpl b/src/templates/docs/action_delete.tpl
new file mode 100644
index 0000000..31a9ff3
--- /dev/null
+++ b/src/templates/docs/action_delete.tpl
@@ -0,0 +1,10 @@
+{include file="_head.tpl" title="Supprimer %d fichiers"|args:$count current="docs"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ces fichiers ?"
+ warning="Êtes-vous sûr de vouloir mettre %d fichiers à la corbeille ?"|args:$count
+ csrf_key=$csrf_key
+ extra=$extra
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/docs/action_move.tpl b/src/templates/docs/action_move.tpl
new file mode 100644
index 0000000..d24ca91
--- /dev/null
+++ b/src/templates/docs/action_move.tpl
@@ -0,0 +1,53 @@
+{include file="_head.tpl" title="Ajouter/supprimer des écritures à un projet" current="acc/accounts"}
+
+{form_errors}
+
+
+
+
+ {foreach from=$breadcrumbs item="name" key="path"}
+ {button label=$name type="submit" name="current" value=$path}
+ {/foreach}
+
+
+
+
+
+ {if $parent}
+
+ {button shape="left" label="Retour au dossier parent" type="submit" name="current" value=$parent}
+
+ {/if}
+
+ {foreach from=$directories item="dir"}
+
+ {button shape="folder" label=$dir.name type="submit" name="current" value=$dir.path}
+
+ {foreachelse}
+ Aucun sous-dossier ici.
+ {/foreach}
+
+
+ {{%n fichier sélectionné.}{%n fichiers sélectionnés.} n=$count}
+ {button shape="right" label="Déplacer vers \"%s\""|args:$current_path_name type="submit" name="move" value=$current_path}
+
+
+
+
+ {csrf_field key=$csrf_key}
+
+ {if isset($extra)}
+ {foreach from=$extra key="key" item="value"}
+ {if is_array($value)}
+ {foreach from=$value key="subkey" item="subvalue"}
+
+ {/foreach}
+ {else}
+
+ {/if}
+ {/foreach}
+ {/if}
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/docs/action_zip.tpl b/src/templates/docs/action_zip.tpl
new file mode 100644
index 0000000..dc7ed6a
--- /dev/null
+++ b/src/templates/docs/action_zip.tpl
@@ -0,0 +1,37 @@
+{include file="_head.tpl" title="Télécharger des fichiers" current="acc/docs"}
+
+{form_errors}
+
+
+
+ Télécharger {$count} fichiers en ZIP…
+
+
+ Vous allez télécharger un fichier ZIP de {$size|size_in_bytes}.
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="zip" label="Télécharger" shape="right" class="main"}
+
+ {if isset($extra)}
+ {foreach from=$extra key="key" item="value"}
+ {if is_array($value)}
+ {foreach from=$value key="subkey" item="subvalue"}
+
+ {/foreach}
+ {else}
+
+ {/if}
+ {/foreach}
+ {/if}
+
+
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/docs/index.tpl b/src/templates/docs/index.tpl
new file mode 100644
index 0000000..28df9b1
--- /dev/null
+++ b/src/templates/docs/index.tpl
@@ -0,0 +1,269 @@
+path;
+?>
+{include file="_head.tpl" title="Documents" current="docs" hide_title=true upload_here=$upload_here}
+
+
+ {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
+ {size_meter
+ tag="aside"
+ total=$quota.max
+ value=$quota.used
+ text="%s libres"|args:$quota.left_bytes
+ more="%s%% utilisé (%s sur %s)"|args:$quota.percent:$quota.used_bytes:$quota.max_bytes
+ href="!config/disk_usage.php"
+ title="Cliquer pour les détails de l'espace disque"}
+ {else}
+ {size_meter
+ tag="aside"
+ total=$quota.max
+ value=$quota.used
+ text="%s libres"|args:$quota.left_bytes
+ more="%s%% utilisé (%s sur %s)"|args:$quota.percent:$quota.used_bytes:$quota.max_bytes}
+ {/if}
+ {include file="./_nav.tpl"}
+
+
+
+
+
+ {input type="text" name="q" size=25 placeholder="Rechercher un document" title="Rechercher dans les documents"}
+ {button shape="search" type="submit" title="Rechercher"}
+
+ {if !$context_specific_root}
+ {if $gallery}
+ {linkbutton shape="menu" label="Afficher en liste" href="?path=%s&gallery=0"|args:$dir_uri}
+ {else}
+ {linkbutton shape="gallery" label="Afficher en galerie" href="?path=%s&gallery=1"|args:$dir_uri}
+ {/if}
+ {/if}
+ {if $dir->canCreateDirHere() || $dir->canCreateHere()}
+ {linkmenu label="Ajouter…" shape="plus" right=true}
+ {if $dir->canCreateHere()}
+ {linkbutton shape="upload" label="Depuis mon ordinateur" target="_dialog" href="!common/files/upload.php?p=%s"|args:$dir_uri}
+ {if $dir->canCreateDirHere()}
+ {linkbutton shape="folder" label="Dossier" target="_dialog" href="!docs/new_dir.php?path=%s"|args:$dir_uri}
+ {/if}
+ {linkbutton shape="text" label="Fichier texte" target="_dialog" href="!docs/new_file.php?path=%s"|args:$dir_uri}
+ {if WOPI_DISCOVERY_URL}
+ {linkbutton shape="document" label="Document" target="_dialog" href="!docs/new_doc.php?ext=odt&path=%s"|args:$dir_uri}
+ {linkbutton shape="table" label="Tableur" target="_dialog" href="!docs/new_doc.php?ext=ods&path=%s"|args:$dir_uri}
+ {linkbutton shape="gallery" label="Présentation" target="_dialog" href="!docs/new_doc.php?ext=odp&path=%s"|args:$dir_uri}
+ {/if}
+ {/if}
+ {/linkmenu}
+ {/if}
+
+
+
+ {if $context == File::CONTEXT_TRANSACTION}
+ {if $context_ref}
+ Écriture #{$context_ref}
+ {else}
+ Fichiers joints aux écritures comptables
+ {/if}
+ {elseif $context == File::CONTEXT_USER}
+ {if $context_ref}
+ Fichiers joints à la fiche du membre : {$user_name}
+ {else}
+ Fichiers joints aux fiches des membres
+ {/if}
+ {elseif $parent_uri}
+ {$dir->name}
+ {else}
+ Documents
+ {/if}
+
+
+
+
+{if $parent_uri}
+
+ {if $context_ref}
+ {linkbutton href="?path=%s"|args:$parent_uri label="Retour au dossier parent" shape="left"}
+ {if $context == File::CONTEXT_TRANSACTION}
+ {linkbutton href="!acc/transactions/details.php?id=%d"|args:$context_ref|local_url label="Détails de l'écriture" shape="menu"}
+ {elseif $context == File::CONTEXT_USER}
+ {linkbutton href="!users/details.php?id=%d"|args:$context_ref|local_url label="Fiche du membre" shape="user"}
+ {/if}
+ {else}
+
+ {foreach from=$breadcrumbs item="name" key="bc_path"}
+ {$name}
+ {/foreach}
+
+ {if count($breadcrumbs) > 1}
+ {linkbutton href="?path=%s"|args:$parent_uri label="Retour au dossier parent" shape="left"}
+ {/if}
+ {/if}
+
+{/if}
+
+{if $list->count()}
+
+
+ canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
+ }
+ else {
+ $can_check = $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_WRITE);
+ }
+ ?>
+
+ {include file="common/dynamic_list_head.tpl" check=$can_check class=$class}
+
+ {foreach from=$list->iterate() item="item"}
+ {if !$context_ref && $context === File::CONTEXT_TRANSACTION}
+
+ {if $can_check}
+
+ {input type="checkbox" name="check[]" value=$item.path}
+
+ {/if}
+ #{$item.id}
+ {$item.label}
+ {$item.date|date_short}
+ {$item.reference}
+ {$item.year}
+
+ {linkbutton href="!docs/?path=%s"|args:$item.path label="Fichiers" shape="menu"}
+ {linkbutton href="!acc/transactions/details.php?id=%d"|args:$item.id label="Écriture" shape="search"}
+
+
+ {elseif !$context_ref && $context === File::CONTEXT_USER}
+
+ {if $can_check}
+
+ {input type="checkbox" name="check[]" value=$item.path}
+
+ {/if}
+ {$item.number}
+ {$item.identity}
+
+ {linkbutton href="!docs/?path=%s"|args:$item.path label="Fichiers" shape="menu"}
+ {linkbutton href="!users/details.php?id=%d"|args:$item.id label="Fiche membre" shape="user"}
+
+
+ {else}
+ {if $item->isDir()}
+
+ {if $can_check && $item->canDelete()}
+
+ {input type="checkbox" name="check[]" value=$item.path}
+
+ {/if}
+ {icon shape="folder"}
+ {$item.name}
+
+ {if $dir->canCreateHere() || $item->canDelete()}
+ {linkmenu label="Modifier…" shape="edit"}
+ {if $item->canRename()}
+ {linkbutton href="!common/files/rename.php?p=%s"|args:$item->path_uri() label="Renommer" shape="minus" target="_dialog"}
+ {/if}
+ {if $item->canDelete()}
+ {linkbutton href="!common/files/delete.php?p=%s"|args:$item->path_uri() label="Supprimer" shape="trash" target="_dialog"}
+ {/if}
+ {/linkmenu}
+ {/if}
+
+
+ {else}
+
+ {if $item->canDelete()}
+
+ {input type="checkbox" name="check[]" value=$item.path}
+
+ {/if}
+ {if $gallery && $item->hasThumbnail()}
+ {$item->link($session, '150px', false)|raw}
+ {else}
+
+ {$item->link($session, 'icon', false)|raw}
+
+ {/if}
+
+ {$item->link($session, null, false)|raw}
+
+ {$item.size|size_in_bytes}
+ {$item.modified|relative_date_short:true}
+
+ {linkbutton href=$item->url(true) label="Télécharger" shape="download" title="Télécharger"}
+ {if $item->canShare()}
+ {linkbutton href="!common/files/share.php?p=%s"|args:$item->path_uri() label="Partager" shape="export" target="_dialog" title="Partager"}
+ {/if}
+ {if $item->canRename() || $item->canDelete() || ($item->canWrite() && $item->editorType())}
+ {linkmenu label="Modifier…" shape="edit" right=true}
+ {assign var="can_write" value=$item->canWrite()}
+ {if $can_write && $item->editorType()}
+ {linkbutton href="!common/files/edit.php?p=%s"|args:$item->path_uri() label="Éditer" shape="edit" target="_dialog" data-dialog-class="fullscreen"}
+ {/if}
+ {if $item->canRename()}
+ {linkbutton href="!common/files/rename.php?p=%s"|args:$item->path_uri() label="Renommer" shape="reload" target="_dialog"}
+ {/if}
+ {if $item->canDelete()}
+ {linkbutton href="!common/files/delete.php?p=%s"|args:$item->path_uri() label="Supprimer" shape="trash" target="_dialog"}
+ {/if}
+ {if !(FILE_VERSIONING_POLICY === 'none' || $config.file_versioning_policy === 'none') && $can_write}
+ {linkbutton shape="history" href="!common/files/history.php?p=%s"|args:$item->path_uri() label="Historique" target="_dialog"}
+ {/if}
+ {/linkmenu}
+ {/if}
+
+
+ {/if}
+ {/if}
+ {/foreach}
+
+
+
+ {if $can_check}
+
+
+
+
+ Pour les fichiers sélectionnés :
+
+
+ — Choisir une action à effectuer —
+ {if $context == File::CONTEXT_DOCUMENTS}
+ Déplacer
+ {/if}
+ Supprimer
+ Télécharger dans un fichier ZIP
+
+
+ {button type="submit" value="OK" shape="right" label="Valider"}
+
+
+
+
+ {/if}
+
+
+ {$list->getHTMLPagination()|raw}
+
+
+{else}
+ Il n'y a aucun fichier dans ce dossier.
+{/if}
+
+{if $dir->path == $dir->context()}
+
+
+ Adresse WebDAV :
+ {copy_button label=$dir->webdav_root_url()}
+
+
+ {linkbutton shape="help" href=HELP_PATTERN_URL|args:"webdav" label="Accéder aux documents avec WebDAV" target="_dialog"}
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/docs/new_dir.tpl b/src/templates/docs/new_dir.tpl
new file mode 100644
index 0000000..fb9bab3
--- /dev/null
+++ b/src/templates/docs/new_dir.tpl
@@ -0,0 +1,18 @@
+{include file="_head.tpl" title="Créer un dossier"}
+
+{form_errors}
+
+
+
+ Créer un dossier
+
+ {input type="text" minlength="1" name="name" required="required" label="Nom du dossier à créer"}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="create" label="Créer le dossier" shape="plus" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/docs/new_doc.tpl b/src/templates/docs/new_doc.tpl
new file mode 100644
index 0000000..851951c
--- /dev/null
+++ b/src/templates/docs/new_doc.tpl
@@ -0,0 +1,33 @@
+{include file="_head.tpl" title="Créer un document"}
+
+{form_errors}
+
+
+
+ Créer un document
+
+ {input type="text" minlength="1" name="name" required="required" label="Nom du document à créer"}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="create" label=$submit_name shape="plus" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/docs/new_file.tpl b/src/templates/docs/new_file.tpl
new file mode 100644
index 0000000..7692060
--- /dev/null
+++ b/src/templates/docs/new_file.tpl
@@ -0,0 +1,38 @@
+{include file="_head.tpl" title="Créer un fichier texte"}
+
+{form_errors}
+
+
+
+ Créer un fichier texte
+
+ {input type="text" minlength="1" name="name" required="required" label="Nom du fichier à créer"}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="create" label="Créer le fichier" shape="plus" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
diff --git a/src/templates/docs/search.tpl b/src/templates/docs/search.tpl
new file mode 100644
index 0000000..d95aea6
--- /dev/null
+++ b/src/templates/docs/search.tpl
@@ -0,0 +1,29 @@
+{include file="_head.tpl" title="Rechercher dans les fichiers" current="docs"}
+
+
+
+ Rechercher un fichier
+
+
+ {button type="submit" name="search" label="Chercher" shape="search" class="main"}
+
+
+
+
+{if $query}
+
+ {$results_count} fichiers trouvés pour « {$query} »
+
+
+
+ {foreach from=$results item="result"}
+
+
+
+ {$result.snippet|escape|restore_snippet_markup}
+
+ {/foreach}
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/docs/trash.tpl b/src/templates/docs/trash.tpl
new file mode 100644
index 0000000..1bf3e72
--- /dev/null
+++ b/src/templates/docs/trash.tpl
@@ -0,0 +1,55 @@
+{include file="_head.tpl" title="Fichiers supprimés" current="docs" hide_title=true}
+
+
+ {include file="./_nav.tpl" context="trash"}
+ Fichiers supprimés
+
+
+
+ Les fichiers supprimés occupent actuellement {$size|size_in_bytes} .
+ Les fichiers sont supprimés automatiquement après 30 jours.
+
+
+{form_errors}
+
+
+{if $list->count()}
+ {include file="common/dynamic_list_head.tpl" check=true}
+
+ {foreach from=$list->iterate() item="item"}
+
+
+ {input type="checkbox" name="check[]" value=$item->path}
+
+
+ {if $item.type == 2}
+ {icon shape="folder"}
+ {/if}
+
+ {$item.name}
+ {$item.parent}
+ {$item.trash|date_short:true}
+ {$item.size|size_in_bytes}
+
+
+
+ {/foreach}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="restore" label="Restaurer les fichiers sélectionnés" shape="reset"}
+ {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
+ {button type="submit" name="delete" label="Supprimer définitivement les fichiers sélectionnés" shape="delete"}
+ {/if}
+
+
+ {$list->getHTMLPagination()|raw}
+
+{else}
+
+ Il n'y a aucun fichier supprimé.
+
+{/if}
+
\ No newline at end of file
diff --git a/src/templates/docs/trash_delete.tpl b/src/templates/docs/trash_delete.tpl
new file mode 100644
index 0000000..9305431
--- /dev/null
+++ b/src/templates/docs/trash_delete.tpl
@@ -0,0 +1,11 @@
+{include file="_head.tpl" title="Supprimer %d fichiers"|args:$count current="docs"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ces fichiers ?"
+ warning="Êtes-vous sûr de vouloir supprimer définitivement %d fichiers ?"|args:$count
+ confirm="Cocher cette case pour confirmer la suppression"
+ csrf_key=$csrf_key
+ extra=$extra
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/emails/login_changed.tpl b/src/templates/emails/login_changed.tpl
new file mode 100644
index 0000000..0450661
--- /dev/null
+++ b/src/templates/emails/login_changed.tpl
@@ -0,0 +1,14 @@
+{assign var="subject" value="Votre identifiant de connexion a été modifié"}
+
+Vos informations de connexion ont été modifiées.
+
+Votre nouvel identifiant de connexion est le suivant :
+
+{$new_login}
+
+Vous pouvez utiliser cet identifiant pour vous connecter
+à votre association à l'adresse suivante :
+
+{$admin_url}
+
+Ce message est envoyé automatiquement lorsque votre identifiant est modifié.
\ No newline at end of file
diff --git a/src/templates/emails/password_changed.tpl b/src/templates/emails/password_changed.tpl
new file mode 100644
index 0000000..86c79ff
--- /dev/null
+++ b/src/templates/emails/password_changed.tpl
@@ -0,0 +1,16 @@
+{assign var="subject" value="Mot de passe modifié"}
+
+Le mot de passe de votre compte a bien été modifié, conformément à votre demande.
+
+La demande émanait de l'adresse IP :
+{$ip}
+
+Si vous n'avez pas demandé à changer votre mot de passe, merci de nous le signaler.
+
+Pour rappel, votre identifiant de connexion est :
+{$login}
+
+Pour vous reconnecter, utilisez cette adresse :
+{$admin_url}
+
+Ce message est envoyé automatiquement lorsque votre mot de passe est modifié.
\ No newline at end of file
diff --git a/src/templates/emails/password_recovery.tpl b/src/templates/emails/password_recovery.tpl
new file mode 100644
index 0000000..4333cb8
--- /dev/null
+++ b/src/templates/emails/password_recovery.tpl
@@ -0,0 +1,9 @@
+{assign var="subject" value="Mot de passe perdu ?"}
+
+Vous avez oublié votre mot de passe ? Pas de panique !
+
+Il vous suffit de cliquer sur le lien ci-dessous pour modifier votre mot de passe :
+
+{$recovery_url}
+
+Si vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé.
diff --git a/src/templates/emails/verify_email.tpl b/src/templates/emails/verify_email.tpl
new file mode 100644
index 0000000..d9a80f7
--- /dev/null
+++ b/src/templates/emails/verify_email.tpl
@@ -0,0 +1,6 @@
+{assign var="subject" value="Confirmez votre adresse e-mail"}
+
+Pour vérifier votre adresse e-mail pour notre association,
+merci de bien vouloir cliquer sur le lien ci-dessous :
+
+{$verify_url}
diff --git a/src/templates/error.tpl b/src/templates/error.tpl
new file mode 100644
index 0000000..1431bdf
--- /dev/null
+++ b/src/templates/error.tpl
@@ -0,0 +1,91 @@
+
+
+
+
+ {if empty($title)}Erreur{else}{$title}{/if}
+
+
+
+
+
+
+{if empty($title)}Erreur{else}{$title}{/if}
+
+
+ {if $html_error}
+ {$html_error|raw}
+ {else}
+ {$error|escape|nl2br}
+ {/if}
+
+
+
+ « Retour
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/index.html b/src/templates/index.html
new file mode 100644
index 0000000..9a31a28
--- /dev/null
+++ b/src/templates/index.html
@@ -0,0 +1 @@
+404 Not Found Not Found The requested URL was not found on this server.
\ No newline at end of file
diff --git a/src/templates/index.tpl b/src/templates/index.tpl
new file mode 100644
index 0000000..7abe05d
--- /dev/null
+++ b/src/templates/index.tpl
@@ -0,0 +1,70 @@
+{include file="_head.tpl" title="Bonjour %s !"|args:$logged_user->name() current="home"}
+
+{$banner|raw}
+
+
+
+ {button id="homescreen-btn" label="Installer comme application sur l'écran d'accueil" class="hidden" shape="plus"}
+
+
+
+
+
+
+{if !$has_extensions}
+
+
Besoin d'autres fonctionnalités ?
+
Découvrez ces extensions dans le menu Configuration , onglet Extensions :
+
+
+
+ {foreach from=$buttons item="button"}
+ {$button|raw}
+ {/foreach}
+
+
+
+{elseif !empty($buttons)}
+
+
+ {foreach from=$buttons item="button"}
+ {$button|raw}
+ {/foreach}
+
+
+{/if}
+
+{if $homepage}
+
+ {$homepage|raw}
+
+{/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/install.tpl b/src/templates/install.tpl
new file mode 100644
index 0000000..f815c80
--- /dev/null
+++ b/src/templates/install.tpl
@@ -0,0 +1,39 @@
+{include file="_head.tpl" title="Démarrer avec Paheko" menu=false}
+
+
+ Bienvenue dans Paheko !
+ Veuillez remplir les informations suivantes pour démarrer la gestion de votre association.
+
+
+{form_errors}
+
+
+
+
+ Informations sur l'association
+
+ {input type="select" required=true label="Pays (pour la comptabilité)" options=$countries default="FR" help="Ce choix permet de configurer les règles comptables en fonction du pays de l'association." name="country"}
+ {input type="text" label="Nom de l'association" required=true name="name"}
+
+
+
+{if $require_admin_account}
+
+ Création du compte administrateur
+
+ {input type="text" label="Nom et prénom" required=true name="user_name"}
+ {input type="email" label="Adresse E-Mail" required=true name="user_email"}
+
+ {include file="users/_password_form.tpl" field="password" required=true}
+
+{/if}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Commencer à gérer mon association" shape="right" class="main"}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/legal.tpl b/src/templates/legal.tpl
new file mode 100644
index 0000000..3a46b36
--- /dev/null
+++ b/src/templates/legal.tpl
@@ -0,0 +1,48 @@
+{include file="_head.tpl" title="Mentions légales" layout="public" custom_css="/content.css"}
+
+
+
+ {linkbutton shape="left" href=$www_url label="Retour au site"}
+
+
Mentions légales
+
+ Nom :
+ {$config.org_name}
+
+
+ Adresse :
+ {if !$config.org_address}
+ Non renseignée
+ {else}
+ {$config.org_address|escape|nl2br}
+ {/if}
+
+
+ Téléphone :
+ {if $config.org_phone}
+ {$config.org_phone|protect_contact:'tel'|raw}
+ {else}
+ Non renseigné
+ {/if}
+
+
+ Adresse e-mail :
+ {if $config.org_email}
+ {$config.org_email|protect_contact|raw}
+ {else}
+ Non renseigné
+ {/if}
+
+
Toute demande d'accès, modification ou suppression de données personnelles doit être adressée à notre association.
+
+ Hébergeur :
+ {if LEGAL_HOSTING_DETAILS}
+ =LEGAL_HOSTING_DETAILS?>
+ {else}
+ {$config.org_name}
+ {$config.org_address|escape|nl2br}
+ {/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/login.tpl b/src/templates/login.tpl
new file mode 100644
index 0000000..42e5758
--- /dev/null
+++ b/src/templates/login.tpl
@@ -0,0 +1,80 @@
+{include file="_head.tpl" title="Connexion" current="login"}
+
+{form_errors}
+
+{if $changed}
+
+ Votre mot de passe a bien été modifié.
+ Vous pouvez maintenant l'utiliser pour vous reconnecter.
+
+{elseif isset($_GET['logout'])}
+
+ Vous avez bien été déconnecté.
+
+{/if}
+
+
+ Le navigateur que vous utilisez n'est pas supporté. Des fonctionnalités peuvent ne pas fonctionner.
+ Merci d'utiliser un navigateur web moderne comme Firefox ou Vivaldi .
+
+
+
+ {if $app_token}
+ Une application tiers demande à accéder aux fichiers de l'association. Connectez-vous pour pouvoir confirmer l'accès.
+ {/if}
+
+
+
+ {if $ssl_enabled}
+ {icon shape="lock"} Connexion sécurisée
+ {else}
+ {icon shape="unlock"} Connexion non-sécurisée
+ {/if}
+
+
+ {input type=$id_field.type label=$id_field.label required=true name="id"}
+ {input type="password" name="password" label="Mot de passe" required=true}
+ {if !$app_token}
+ {input type="checkbox" name="permanent" value="1" label="Rester connecté⋅e" help="recommandé seulement sur ordinateur personnel"}
+ {/if}
+
+
+
+ {if $captcha}
+
+ Vérification de sécurité
+
+
+ Merci de recopier en chiffres (par exemple 1234 ) le nombre suivant :(obligatoire)
+ {$captcha.spellout}
+ {input name="c_answer" type="text" maxlength=4 label=null required=true}
+ Cette vérification est demandée après plusieurs tentatives de connexion infructueuses.
+
+
+ {/if}
+
+
+ {csrf_field key="login"}
+ {button type="submit" name="login" label="Se connecter" shape="right" class="main"}
+ {if !DISABLE_EMAIL && !$app_token}
+ {linkbutton href="!password.php" label="Mot de passe perdu ?" shape="help"}
+ {linkbutton href="!password.php?new" label="Première connexion ?" shape="user"}
+ {/if}
+
+
+
+ Suggestion : mettez cette page dans vos favoris pour la retrouver facilement :-)
+ (Sur ordinateur appuyez sur Ctrl + D . Aide : Firefox , Chrome )
+
+
+
+
+{literal}
+
+{/literal}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/login_app.tpl b/src/templates/login_app.tpl
new file mode 100644
index 0000000..e784e9e
--- /dev/null
+++ b/src/templates/login_app.tpl
@@ -0,0 +1,34 @@
+{include file="_head.tpl" title="Accès par une application tiers" layout="public"}
+
+{if $app_token == 'ok'}
+ L'application a bien été autorisée.
+
+ Vous pourrez fermer cette fenêtre quand l'application aura terminé l'autorisation.
+{else}
+ Une application tiers demande à accéder aux documents de l'association.
+ {form_errors}
+
+
+ Confirmer l'accès
+ Autoriser l'application à accéder aux documents ?
+
+
L'application pourra :
+
+ {if $permissions.read}Lire les fichiers {/if}
+ {if $permissions.write}Modifier les fichiers {/if}
+ {if $permissions.delete}Supprimer les fichiers {/if}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" label="Autoriser l'accès" shape="right" class="main" name="confirm"}
+
+
+ {button type="submit" label="Annuler" shape="left" name="cancel"}
+
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/login_otp.tpl b/src/templates/login_otp.tpl
new file mode 100644
index 0000000..96702ab
--- /dev/null
+++ b/src/templates/login_otp.tpl
@@ -0,0 +1,21 @@
+{include file="_head.tpl" title="Connexion — double facteur" current="login"}
+
+{form_errors}
+
+
+
+
+ Authentification à double facteur
+
+ {input type="text" class="otp" minlength=6 maxlength=6 label="Code TOTP" name="code" help="Entrez ici le code donné par l'application d'authentification double facteur." required=true}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="login" label="Se connecter" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/me/_nav.tpl b/src/templates/me/_nav.tpl
new file mode 100644
index 0000000..fb90c29
--- /dev/null
+++ b/src/templates/me/_nav.tpl
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/src/templates/me/edit.tpl b/src/templates/me/edit.tpl
new file mode 100644
index 0000000..8791ca3
--- /dev/null
+++ b/src/templates/me/edit.tpl
@@ -0,0 +1,30 @@
+{include file="_head.tpl" title="Mes informations personnelles" current="me"}
+
+{include file="./_nav.tpl" current="me"}
+
+{form_errors}
+
+
+
+
+ Informations personnelles
+
+ {foreach from=$fields item="field"}
+ {edit_user_field field=$field user=$user context="user_edit"}
+ {/foreach}
+
+
+
+
+ Changer mon mot de passe
+ {link href="!me/security.php" label="Modifier mon mot de passe ou autres informations de sécurité"}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/me/export.tpl b/src/templates/me/export.tpl
new file mode 100644
index 0000000..c782183
--- /dev/null
+++ b/src/templates/me/export.tpl
@@ -0,0 +1,63 @@
+
+
+
+ Données utilisateur
+
+
+
+
+Données utilisateur
+Ce document contient une copie de toutes les données détenues sur vous par {$config.org_name}, conformément à la réglementation.
+
+
+
+Profil
+
+{include file="users/_details.tpl" data=$user show_message_button=false context="export"}
+
+
+
+Inscriptions aux activités et cotisations
+
+
+
+
+ {foreach from=$services_list->getHeaderColumns() key="key" item="column"}
+ {$column.label}
+ {/foreach}
+
+
+
+
+
+ {foreach from=$services_list->iterate() item="row"}
+
+ {$row.label}
+ {$row.date|date_short}
+ {$row.expiry|date_short}
+ {$row.fee}
+ {if $row.paid}Oui {else}Non {/if}
+ {$row.amount|raw|money_currency}
+
+ {/foreach}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/me/index.tpl b/src/templates/me/index.tpl
new file mode 100644
index 0000000..ea147bc
--- /dev/null
+++ b/src/templates/me/index.tpl
@@ -0,0 +1,40 @@
+{include file="_head.tpl" title="Mes informations personnelles" current="me"}
+
+{include file="./_nav.tpl" current="me"}
+
+{if $ok !== null}
+
+ Les modifications ont bien été enregistrées.
+
+{/if}
+
+
+ {linkbutton href="!me/edit.php" label="Modifier mes informations" shape="edit"}
+
+
+
+{if $user->isChild() || count($children)}
+
+
+ {if $user->isChild()}
+ Membre responsable
+ {$parent_name}
+ {elseif count($children)}
+ Membres rattachés
+ {foreach from=$children item="child"}
+ {$child.name}
+ {/foreach}
+ {/if}
+
+
+{/if}
+
+{include file="users/_details.tpl" data=$user show_message_button=false context="user"}
+
+
+ {linkbutton href="!me/export.php" label="Télécharger toutes les données détenues sur moi" shape="download"}
+
+
+{$snippets|raw}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/me/preferences.tpl b/src/templates/me/preferences.tpl
new file mode 100644
index 0000000..3c646e9
--- /dev/null
+++ b/src/templates/me/preferences.tpl
@@ -0,0 +1,37 @@
+{include file="_head.tpl" title="Mes préférences" current="me"}
+
+{include file="./_nav.tpl" current="preferences"}
+
+{if $ok !== null}
+
+ Les modifications ont bien été enregistrées.
+
+{/if}
+
+
+
+
+ Mes préférences
+
+ {input type="select" name="dark_theme" label="Thème" required=true source=$preferences options=$themes_options default=false}
+ {input type="select" name="force_handheld" label="Taille d'écran" required=true source=$preferences options=$handheld_options default=false}
+ {input type="select" name="page_size" label="Nombre d'éléments par page dans les listes" required=true source=$preferences options=$page_size_options default=100 help="Par exemple dans la liste des membres."}
+ {if $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)}
+ {input type="select" name="folders_gallery" label="Affichage des listes de documents" required=true source=$preferences options=$folders_options default=true}
+ {/if}
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
+ Affichage de la comptabilité
+ {input type="radio-btn" name="accounting_expert" value=0 label="Simplifié" default=0 source=$preferences help="Conseillé. Pour les novices en comptabilité, affiche notamment les comptes de banque tels qu'ils apparaissent sur les relevés bancaires."}
+ {input type="radio-btn" name="accounting_expert" value=1 label="Expert" source=$preferences help="Si vous avez une bonne expérience de la comptabilité en partie double. Affiche les journaux de compte au sens de la comptabilité en partie double."}
+ {/if}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/me/security.tpl b/src/templates/me/security.tpl
new file mode 100644
index 0000000..0c1dede
--- /dev/null
+++ b/src/templates/me/security.tpl
@@ -0,0 +1,116 @@
+{include file="_head.tpl" title="Mes informations de connexion et sécurité" current="me"}
+
+{include file="./_nav.tpl" current="security"}
+
+{if $ok}
+
+ Changements enregistrés.
+
+{/if}
+
+{form_errors}
+
+{if $edit}
+
+
+ {if $edit == 'password'}
+
+ Changer mon mot de passe
+ {include file="users/_password_form.tpl" required=true}
+
+ {elseif $edit == 'otp'}
+
+ Confirmez l'activation de l'authentification à double facteur TOTP en l'utilisant une première fois.
+
+
+ Pour renforcer la sécurité de votre connexion en cas de vol de votre mot de passe, vous pouvez activer l'authentification à double facteur. Cela nécessite d'installer une application comme Aegis ou Google Authenticator sur votre téléphone.
+
+
+ Confirmer l'activation de l'authentification à double facteur (2FA)
+
+
+ Votre clé secrète est :
+ {input name="otp_secret" default=$otp.secret_display type="text" readonly="readonly" copy=true onclick="this.select();"}
+ Recopiez la clé secrète ou scannez le QR code pour configurer votre application TOTP, puis utilisez celle-ci pour générer un code d'accès et confirmer l'activation.
+ {input name="otp_code" type="text" class="otp" minlength=6 maxlength=6 label="Code TOTP" help="Entrez ici le code donné par l'application d'authentification double facteur." required=true}
+
+
+ {elseif $edit == 'otp_disable'}
+
+ Confirmez la désactivation de l'authentification à double facteur TOTP.
+
+
+ {elseif $edit == 'pgp_key'}
+
+ Chiffrer les e-mails qui me sont envoyés avec PGP/GnuPG
+ En inscrivant ici votre clé publique, tous les e-mails qui vous seront envoyés seront chiffrés (cryptés) avec cette clé : messages collectifs, messages envoyés par les membres, rappels de cotisation, procédure de récupération de mot de passe, etc.
+
+ {input name="pgp_key" source=$user label="Ma clé publique PGP" type="textarea" cols=90 rows=5 required=true help="Laisser vide pour désactiver le chiffrement."}
+ {if $pgp_fingerprint}L'empreinte de la clé est : {$pgp_fingerprint}
{/if}
+
+
+ Attention : en inscrivant ici votre clé PGP, les emails de récupération de mot de passe perdu vous seront envoyés chiffrés
+ et ne pourront donc être lus si vous n'avez pas le le mot de passe protégeant la clé privée correspondante.
+
+
+ {/if}
+
+
+ Confirmation
+
+ {input type="password" name="password_check" label="Mot de passe actuel" help="Entrez votre mot de passe actuel pour confirmer les changements." autocomplete="current-password" required=true}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="confirm" label="Confirmer" shape="right" class="main"}
+
+
+
+{else}
+
+ Identifiant de connexion
+ {$id_field.label}
+ {input type=$id_field.type readonly="readonly" copy=true default=$id name=""}
+ Mot de passe
+ {if $can_change_password}
+ {linkbutton href="?edit=password" label="Modifier le mot de passe" shape="edit"}
+ {else}
+ Vous n'avez pas le droit de modifier votre mot de passe. Vous devez contacter un administrateur pour qu'il change votre mot de passe.
+ {/if}
+ Authentification à deux facteurs
+
+ {if $user.otp_secret}
+ {icon shape="check"} Activée
+ {linkbutton href="?edit=otp_disable" label="Désactiver" shape="delete"}
+ {else}
+ Désactivée
+ {linkbutton href="?edit=otp" label="Activer" shape="check"}
+ {/if}
+
+ Permet de protéger votre compte en cas de vol de votre mot de passe, en utilisant votre téléphone pour générer un code à usage unique.
+ {if $can_use_pgp}
+ Chiffrer les e-mails qui me sont envoyés avec PGP
+
+ {if !$user.pgp_key}
+ Désactivé
+ {linkbutton href="?edit=pgp_key" label="Configurer" shape="edit"}
+ {else}
+ {icon shape="check"} Activé
+ {linkbutton href="?edit=pgp_key" label="Modifier" shape="edit"}
+ {/if}
+
+ Permet de chiffrer les messages qui vous sont envoyés par e-mail, notamment les messages de récupération de mot de passe, pour empêcher un attaquant de prendre contrôle de votre compte si votre adresse e-mail est piratée.
+ {/if}
+ Déconnecter toutes mes sessions
+ {{Vous n'avez actuellement qu'une seule session ouverte (celle-ci).}{Vous avez actuellement %n sessions ouvertes (y compris celle-ci).} n=$sessions_count}
+ {linkbutton href="!logout.php?all" label="Me déconnecter de toutes les sessions" shape="logout"}
+ Journal de connexion
+ Permet de voir les tentatives de connexion, les modifications de mot de passe, etc.
+ {linkbutton href="!users/log.php" label="Voir mon journal de connexion" shape="menu"}
+
+{/if}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/me/services.tpl b/src/templates/me/services.tpl
new file mode 100644
index 0000000..22819a7
--- /dev/null
+++ b/src/templates/me/services.tpl
@@ -0,0 +1,82 @@
+
+{include file="_head.tpl" title="Mes activités & cotisations" current="me/services"}
+
+
+ Mes activités et cotisations
+ {foreach from=$services item="service"}
+
+ {$service.label}
+ {if $service.archived} (activité passée) {/if}
+ {if $service.status == -1 && $service.end_date} — expirée
+ {elseif $service.status == -1} — en retard
+ {elseif $service.status == 1 && $service.end_date} — en cours
+ {elseif $service.status == 1} — à jour {/if}
+ {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
+ {if !$service.paid} — À payer ! {/if}
+
+ {foreachelse}
+
+ Vous n'êtes inscrit à aucune activité ou cotisation.
+
+ {/foreach}
+
+
+Dettes et créances
+
+{if !count($accounts)}
+Aucune dette ou créance n'est associée à votre profil.
+{else}
+
+
+
+
+ Montant
+ Compte
+
+
+
+
+ {foreach from=$accounts item="account"}
+
+ {$account.balance|raw|money_currency}
+ {$account.label}
+
+ {if $account.position == Account::LIABILITY}Nous vous devons {$account.balance|raw|money_currency}.
+ {else}Vous nous devez {$account.balance|raw|money_currency}. {/if}
+
+
+ {/foreach}
+
+
+{/if}
+
+{if $list->count()}
+
+ Historique des inscriptions
+
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {$row.label}
+ {$row.fee}
+ {$row.date|date_short}
+ {$row.expiry|date_short}
+ {if $row.paid}Oui {else}Non {/if}
+ {$row.amount|raw|money_currency}
+
+
+
+ {/foreach}
+
+
+
+
+ {$list->getHTMLPagination()|raw}
+{/if}
+
+{$snippets|raw}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/optout.tpl b/src/templates/optout.tpl
new file mode 100644
index 0000000..60d1946
--- /dev/null
+++ b/src/templates/optout.tpl
@@ -0,0 +1,75 @@
+{include file="_head.tpl" title="Désinscription" layout="public"}
+
+{if $verify === true}
+
+ Votre adresse e-mail a bien été vérifiée, merci !
+
+
+
+ {if $config.site_asso || !$config.site_disabled}
+ site_asso ?? $www_url; ?>
+ {linkbutton href=$url label="Retour au site" shape="left"}
+ {else}
+ {linkbutton href=$admin_url label="Connexion" shape="left"}
+ {/if}
+
+
+{elseif $verify === false}
+
+ Erreur de vérification de votre adresse e-mail.
+
+{elseif $ok}
+
+ Vous avez été bien désinscrit.
+ Vous ne recevrez plus de messages de notre part.
+
+
+
+ Vous pouvez vous réinscrire à tout moment en cliquant à nouveau sur le lien de désinscription présent à la fin de nos e-mails.
+ {linkbutton href="?un=%s"|args:$code label="Me réinscrire" shape="reload"}
+
+{elseif $resub_ok}
+
+ Un e-mail vous a été envoyé, merci de cliquer sur le lien dans le message reçu pour confirmer.
+
+{elseif $email.optout}
+
+
+ Votre adresse e-mail est déjà désinscrite. Pour demander à vous réinscrire, renseignez le formulaire ci-dessous.
+
+
+ {form_errors}
+
+
+
+
+
+ {input type="email" required=true name="email" label="Adresse e-mail"}
+ {input type="checkbox" name="confirm_resub" value="1" required=true label="Oui, je veux à nouveau recevoir les messages de « %s »"|args:$config.org_name}
+
+
+
+
+ {csrf_field key="optout"}
+ {button type="submit" name="resub" label="Réinscrire mon adresse e-mail" shape="right" class="main"}
+
+
+{else}
+
+ {form_errors}
+
+
+
+
+ En cliquant sur ce bouton vous confirmez ne plus vouloir recevoir aucun message de notre part.
+
+
+
+ {csrf_field key="optout"}
+ {button type="submit" name="optout" label="Me désinscrire" shape="right" class="main"}
+
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/password.tpl b/src/templates/password.tpl
new file mode 100644
index 0000000..06d2a71
--- /dev/null
+++ b/src/templates/password.tpl
@@ -0,0 +1,44 @@
+{include file="_head.tpl" title=$title}
+
+{if $sent}
+
+ {if $new}
+ Un e-mail vous a été envoyé, cliquez sur le lien dans cet e-mail pour choisir votre mot de passe.
+ {else}
+ Un e-mail vous a été envoyé, cliquez sur le lien dans cet e-mail pour modifier votre mot de passe.
+ {/if}
+
+
+ Si le message n'apparaît pas dans les prochaines minutes, vérifiez le dossier Spam ou Indésirables.
+
+
+{else}
+
+ {form_errors}
+
+
+
+
+ {if $new}Envoyer un e-mail pour choisir son mot de passe{else}Envoyer un e-mail pour modifier son mot de passe{/if}
+
+ Inscrivez ici votre identifiant.
+ {if $new}
+ Vous recevrez un e-mail à l'adresse renseignée dans votre fiche membre, avec un lien vous permettant de créer votre mot de passe.
+ {else}
+ Nous vous enverrons un e-mail à l'adresse renseignée dans votre fiche membre, avec un lien vous permettant de modifier votre mot de passe.
+ {/if}
+
+
+ {input type=$id_field.type label=$id_field.label required=true name="id"}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="recover" label="Envoyer" shape="right" class="main"}
+
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/password_change.tpl b/src/templates/password_change.tpl
new file mode 100644
index 0000000..fbe0a4d
--- /dev/null
+++ b/src/templates/password_change.tpl
@@ -0,0 +1,22 @@
+{include file="_head.tpl" title="Changement de mot de passe"}
+
+
+{form_errors}
+
+
+
+
+ Choisir un nouveau mot de passe
+ {include file="users/_password_form.tpl" required=true}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="change" label="Modifier mon mot de passe" shape="right" class="main"}
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/_nav.tpl b/src/templates/services/_nav.tpl
new file mode 100644
index 0000000..fe6b964
--- /dev/null
+++ b/src/templates/services/_nav.tpl
@@ -0,0 +1,53 @@
+{if !$dialog}
+
+
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current != 'reminders'}
+ {linkbutton href="!services/user/add.php" label="Inscrire à une activité" shape="plus"}
+ {elseif $current == 'reminders'}
+ {linkbutton href="!services/reminders/new.php" label="Nouveau rappel automatique" shape="plus"}
+ {/if}
+
+
+
+
+ {if !empty($has_archived_services)}
+
+ {link href="!services/" label="Activités courantes"}
+ {link href="!services/?archived=1" label="Activités archivées"}
+
+ {/if}
+
+ {if isset($current_service)}
+
+ {/if}
+
+ {if isset($current_fee)}
+
+ {/if}
+
+
+{/if}
\ No newline at end of file
diff --git a/src/templates/services/_service_form.tpl b/src/templates/services/_service_form.tpl
new file mode 100644
index 0000000..d7abcb7
--- /dev/null
+++ b/src/templates/services/_service_form.tpl
@@ -0,0 +1,73 @@
+{form_errors}
+
+
+
+
+ {$legend}
+
+ {input name="label" type="text" required=1 label="Libellé" source=$service}
+ {input name="description" type="textarea" label="Description" source=$service}
+
+ {if $service && $service->exists()}
+ {input type="checkbox" name="archived" value=1 label="Archiver cette activité" source=$service}
+ Si coché, les inscrits ne recevront plus de rappels, l'activité ne sera plus visible sur la fiche des membres, il ne sera plus possible d'y inscrire des membres.
+ {/if}
+
+ Durée de validité (obligatoire)
+
+ {if $service && $service->exists()}
+ Attention, une modification de la durée renseignée ici ne modifie pas la date d'expiration des activités déjà enregistrées.
+ {/if}
+
+ {input name="period" type="radio-btn" value="0" label="Pas de durée (activité ou cotisation ponctuelle)" default=$period help="Pour un événement, un concert, un cours ponctuel, etc."}
+ {input name="period" type="radio-btn" value="1" label="En nombre de jours" default=$period help="Par exemple une cotisation valide un an à partir de la date d'inscription"}
+
+
+ {input name="duration" type="number" step="1" label="Nombre de jours" size="5" source=$service required=true default=365}
+
+
+ {input name="period" type="radio-btn" value="2" label="Période définie (date à date)" default=$period help="Par exemple pour une cotisation qui serait valable pour l'année civile en cours, quelle que soit la date d'inscription."}
+
+
+ {input type="date" name="start_date" label="Date de début" source=$service required=true}
+ {input type="date" name="end_date" label="Date de fin" source=$service required=true}
+
+
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/services/delete.tpl b/src/templates/services/delete.tpl
new file mode 100644
index 0000000..da47c03
--- /dev/null
+++ b/src/templates/services/delete.tpl
@@ -0,0 +1,13 @@
+{include file="_head.tpl" title="Supprimer une activité" current="users/services"}
+
+{include file="services/_nav.tpl" current="index"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer cette activité ?"
+ confirm_label=$confirm_label
+ confirm_text=$confirm_text
+ warning="Êtes-vous sûr de vouloir supprimer l'activité « %s » et toutes les inscriptions ?"|args:$service.label
+ error="Attention, cela supprimera également les tarifs, les inscriptions des membres à cette activité, ainsi que les rappels associés !"
+ info="Les écritures comptables liées à l'historique des membres inscrits à cette activité ne seront pas supprimées, et la comptabilité demeurera inchangée."}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/details.tpl b/src/templates/services/details.tpl
new file mode 100644
index 0000000..74c6a32
--- /dev/null
+++ b/src/templates/services/details.tpl
@@ -0,0 +1,83 @@
+{include file="_head.tpl" title="%s — Liste des membres inscrits"|args:$service.label current="users/services"}
+
+{include file="services/_nav.tpl" current="index" current_service=$service service_page=$type}
+
+canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
+?>
+
+
+
+
+
+
+ Nombre de membres trouvés
+
+ {$list->count()}
+ (N'apparaît ici que l'inscription la plus récente de chaque membre.)
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {exportmenu}
+ {/if}
+
+ {if $can_action}
+ Membres des catégories cachées
+ {input type="checkbox" label="Afficher aussi les inscriptions des membres appartenant à des catégories cachées" name="hidden" value="1" onchange="this.form.submit()" default=$include_hidden_categories role="button"}
+ {/if}
+
+
+
+{if $can_action}
+
+{/if}
+
+{if !$list->count()}
+ Il n'y a aucun résultat.
+{else}
+ {include file="common/dynamic_list_head.tpl" check=$can_action}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {if $can_action}
+ {input type="checkbox" name="selected[]" value=$row.id_user}
+ {/if}
+
+ {$row.identity}
+
+ {if $row.status == 1 && $row.end_date}
+ En cours
+ {elseif $row.status == 1}
+ À jour
+ {elseif $row.status == -1 && $row.end_date}
+ Terminée
+ {elseif $row.status == -1}
+ En retard
+ {else}
+ Pas d'expiration
+ {/if}
+
+ {if $row.paid}Oui {else}Non {/if}
+ {$row.expiry|date_short}
+ {$row.fee}
+ {$row.date|date_short}
+
+ {linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user/?id=%d"|args:$row.id_user}
+ {linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user}
+
+
+ {/foreach}
+
+
+ {if $can_action}
+ {include file="users/_list_actions.tpl" colspan=7 export=false hide_delete=true}
+ {/if}
+
+
+
+ {$list->getHTMLPagination()|raw}
+{/if}
+
+{if $can_action}
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/edit.tpl b/src/templates/services/edit.tpl
new file mode 100644
index 0000000..916e71b
--- /dev/null
+++ b/src/templates/services/edit.tpl
@@ -0,0 +1,7 @@
+{include file="_head.tpl" title="Modifier une activité" current="users/services"}
+
+{include file="services/_nav.tpl" current="index"}
+
+{include file="services/_service_form.tpl" legend="Modifier une activité"}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/fees/_fee_form.tpl b/src/templates/services/fees/_fee_form.tpl
new file mode 100644
index 0000000..3d673e3
--- /dev/null
+++ b/src/templates/services/fees/_fee_form.tpl
@@ -0,0 +1,117 @@
+
+
+{form_errors}
+
+
+
+
+ {$legend}
+
+ {input name="label" type="text" required=true label="Libellé" source=$fee}
+ {input name="description" type="textarea" label="Description" source=$fee}
+
+ Montant de la cotisation
+ {input name="amount_type" type="radio" value="0" label="Gratuite ou prix libre" default=$amount_type}
+ {input name="amount_type" type="radio" value="1" label="Montant fixe ou prix libre conseillé" default=$amount_type}
+
+
+ {input name="amount" type="money" label="Montant" source=$fee required=true}
+
+
+ {input name="amount_type" type="radio" value="2" label="Montant variable" default=$amount_type}
+
+
+ {input name="formula" type="textarea" label="Formule de calcul" source=$fee required=true}
+
+ Le résultat doit être un nombre entier incluant les centimes. Exemple : 950 pour représenter 9,50 .
+ {linkbutton shape="help" href=$help_pattern_url|args:"formule-calcul-activite" target="_dialog" label="Aide sur les formules de calcul"}
+
+
+
+ Comptabilité
+ {input name="accounting" type="checkbox" value="1" label="Enregistrer en comptabilité" default=$accounting_enabled}
+ Laissez cette case décochée si vous n'utilisez pas Paheko pour la comptabilité. Il ne sera pas possible de suivre le montant des règlements effectués pour ce tarif.
+
+
+
+
+ Enregistrer en comptabilité
+ Chaque règlement d'un membre lié à ce tarif sera enregistré dans la comptabilité, permettant de suivre le montant des règlements effectués.
+ {if !count($years)}
+ Il n'y a aucun exercice ouvert dans la comptabilité, il n'est donc pas possible d'enregistrer les activités dans la comptabilité. Merci de commencer par créer un exercice .
+ {else}
+
+ Exercice (obligatoire)
+
+
+ -- Sélectionner un exercice
+ {foreach from=$years item="year"}
+ {$year.label} — {$year.start_date|date_short} au {$year.end_date|date_short}
+ {/foreach}
+
+
+ {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$targets,$fee.id_year name="account" label="Compte de recettes à utiliser" default=$account required=true}
+ {if count($projects) > 0}
+ {input type="select" options=$projects name="id_project" label="Projet analytique" default=$fee.id_project required=false default_empty="— Aucun —"}
+ {/if}
+
+ {/if}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/services/fees/delete.tpl b/src/templates/services/fees/delete.tpl
new file mode 100644
index 0000000..9f2a9f2
--- /dev/null
+++ b/src/templates/services/fees/delete.tpl
@@ -0,0 +1,13 @@
+{include file="_head.tpl" title="Supprimer un tarif" current="users/services"}
+
+{include file="services/_nav.tpl" current="index"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ce tarif ?"
+ confirm_label=$confirm_label
+ confirm_text=$confirm_text
+ warning="Êtes-vous sûr de vouloir supprimer le tarif « %s » ?"|args:$fee.label
+ error="Attention, cela supprimera également les inscriptions des membres à ce tarif !"
+ info="Les écritures comptables liées à l'historique des membres ayant réglé ce tarif ne seront pas supprimées, et la comptabilité demeurera inchangée."}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/fees/details.tpl b/src/templates/services/fees/details.tpl
new file mode 100644
index 0000000..36c9e17
--- /dev/null
+++ b/src/templates/services/fees/details.tpl
@@ -0,0 +1,71 @@
+{include file="_head.tpl" title="Tarif : %s — Liste des membres inscrits"|args:$fee.label current="users/services"}
+
+{include file="services/_nav.tpl" current="index" current_service=$service service_page="index" current_fee=$fee fee_page=$type}
+
+canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
+?>
+
+
+
+
+
+
+ Nombre de membres trouvés
+
+ {$list->count()}
+ (N'apparaît ici que l'inscription la plus récente de chaque membre.)
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {exportmenu}
+ {/if}
+
+
+ {if $can_action}
+ Membres des catégories cachées
+ {input type="checkbox" label="Afficher aussi les inscriptions des membres appartenant à des catégories cachées" name="hidden" value="1" onchange="this.form.submit()" default=$include_hidden_categories role="button"}
+ {/if}
+
+
+
+{if $can_action}
+
+{/if}
+
+{include file="common/dynamic_list_head.tpl" check=$can_action}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {if $can_action}
+ {input type="checkbox" name="selected[]" value=$row.id_user}
+ {/if}
+ {link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity}
+ {if $row.paid}Oui {else}Non {/if}
+ {if null === $row.paid_amount}— {else}{$row.paid_amount|raw|money_currency}{/if}
+ {$row.date|date_short}
+
+ {linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user/?id=%d"|args:$row.id_user}
+ {linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user}
+
+
+ {/foreach}
+
+
+
+ {if $can_action}
+ {include file="users/_list_actions.tpl" colspan=5 export=false hide_delete=true}
+ {/if}
+
+
+
+{if $can_action}
+
+{/if}
+
+{$list->getHTMLPagination()|raw}
+
+
+ Les lignes indiquant — comme montant payé signifient qu'aucune écriture comptable n'a été associée à cette inscription. De ce fait, le montant restant à payer ne peut être calculé.
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/fees/edit.tpl b/src/templates/services/fees/edit.tpl
new file mode 100644
index 0000000..3c6c60b
--- /dev/null
+++ b/src/templates/services/fees/edit.tpl
@@ -0,0 +1,7 @@
+{include file="_head.tpl" title="%s — Modifier le tarif"|args:$fee.label current="users/services"}
+
+{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"}
+
+{include file="services/fees/_fee_form.tpl" legend="Modifier un tarif" submit_label="Enregistrer"}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/fees/index.tpl b/src/templates/services/fees/index.tpl
new file mode 100644
index 0000000..a75b349
--- /dev/null
+++ b/src/templates/services/fees/index.tpl
@@ -0,0 +1,46 @@
+{include file="_head.tpl" title="%s — Tarifs"|args:$service.label current="users/services"}
+
+{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"}
+
+
+{if $list->count()}
+ {include file="common/dynamic_list_head.tpl"}
+ {foreach from=$list->iterate() item="row"}
+
+ {$row.label}
+
+ {if $row.formula}
+ Formule
+ {elseif $row.amount}
+ {$row.amount|money_currency|raw}
+ {else}
+ -
+ {/if}
+
+ {$row.nb_users_ok}
+ {$row.nb_users_expired}
+ {$row.nb_users_unpaid}
+
+ {linkbutton shape="users" label="Liste des inscrits" href="!services/fees/details.php?id=%d"|args:$row.id}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {linkbutton shape="edit" label="Modifier" href="!services/fees/edit.php?id=%d"|args:$row.id}
+ {linkbutton shape="delete" label="Supprimer" href="!services/fees/delete.php?id=%d"|args:$row.id}
+ {/if}
+
+
+ {/foreach}
+
+
+
+ {$list->getHTMLPagination()|raw}
+{else}
+
+ Il n'y a aucun tarif enregistré. Créez un premier tarif pour l'activité « {$service.label} » pour pouvoir y inscrire des membres.
+
+{/if}
+
+{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {include file="services/fees/_fee_form.tpl" legend="Ajouter un tarif" submit_label="Ajouter" csrf_key="fee_add" fee=null amount_type=0 account=null}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/import.tpl b/src/templates/services/import.tpl
new file mode 100644
index 0000000..e8157d2
--- /dev/null
+++ b/src/templates/services/import.tpl
@@ -0,0 +1,48 @@
+{include file="_head.tpl" title="Importer des inscriptions" current="users"}
+
+{include file="services/_nav.tpl" current="import" service=null fee=null}
+
+{form_errors}
+
+{if $_GET.msg == 'OK'}
+
+ L'import s'est bien déroulé.
+
+{/if}
+
+
+
+{if $csv->loaded()}
+
+ {include file="common/_csv_match_columns.tpl"}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="cancel" value="1" label="Annuler" shape="left"}
+ {button type="submit" name="import" label="Importer" shape="right" class="main"}
+
+
+{else}
+
+
+ Ce formulaire permet d'importer les inscriptions des membres aux activités.
+
+
+
+ Importer depuis un fichier
+
+ {input type="file" name="file" label="Fichier à importer" required=true accept="csv"}
+ {include file="common/_csv_help.tpl" csv=$csv}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="load" label="Charger le fichier" shape="right" class="main"}
+
+{/if}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/index.tpl b/src/templates/services/index.tpl
new file mode 100644
index 0000000..e3601c9
--- /dev/null
+++ b/src/templates/services/index.tpl
@@ -0,0 +1,48 @@
+{include file="_head.tpl" title="Activités et cotisations" current="users/services"}
+
+{include file="services/_nav.tpl" current="index" service=null fee=null}
+
+{if isset($_GET['CREATE'])}
+ Vous devez déjà créer une activité pour pouvoir utiliser cette fonction.
+{/if}
+
+{if $list->count()}
+ {include file="common/dynamic_list_head.tpl"}
+ {foreach from=$list->iterate() item="row"}
+
+ {$row.label}
+
+ {if $row.duration}
+ {$row.duration} jours
+ {elseif $row.start_date}
+ {$row.start_date|date_short} au {$row.end_date|date_short}
+ {else}
+ ponctuelle
+ {/if}
+
+ {$row.nb_users_ok}
+ {$row.nb_users_expired}
+ {$row.nb_users_unpaid}
+
+ {linkbutton shape="menu" label="Tarifs" href="!services/fees/?id=%d"|args:$row.id}
+ {linkbutton shape="users" label="Liste des inscrits" href="!services/details.php?id=%d"|args:$row.id}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {linkbutton shape="edit" label="Modifier" href="!services/edit.php?id=%d"|args:$row.id}
+ {linkbutton shape="delete" label="Supprimer" href="!services/delete.php?id=%d"|args:$row.id}
+ {/if}
+
+
+ {/foreach}
+
+
+
+ {$list->getHTMLPagination()|raw}
+{else}
+ Il n'y a aucune activité enregistrée.
+{/if}
+
+{if empty($show_archived_services) && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {include file="services/_service_form.tpl" legend="Ajouter une activité" service=null period=0}
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/reminders/_form.tpl b/src/templates/services/reminders/_form.tpl
new file mode 100644
index 0000000..f899981
--- /dev/null
+++ b/src/templates/services/reminders/_form.tpl
@@ -0,0 +1,119 @@
+{form_errors}
+
+
+
+
+ {$legend}
+
+ {input type="select" name="id_service" options=$services_list label="Activité associée au rappel" required=1 source=$reminder}
+
+ Délai d'envoi obligatoire
+ {input type="radio" name="delay_type" value=0 default=$delay_type label="Le jour de l'expiration de l'activité"}
+
+ {input type="radio" name="delay_type" value=1 default=$delay_type}
+ {input type="number" name="delay_before" min=1 max=999 default=$delay_before size=4}
+ jours avant expiration
+
+
+ {input type="radio" name="delay_type" value=2 default=$delay_type}
+ {input type="number" name="delay_after" min=1 max=999 size=4 default=$delay_after}
+ jours après expiration
+
+
+ {if !$reminder->exists()}
+ not_before_date ?? null) === null; ?>
+ {input type="radio" name="yes_before" value=1 default=$yes_before prefix_title="Envoyer ce rappel…" prefix_required=true label="À tous les membres" help="Même si leur inscription a expiré il y a longtemps, sauf s'ils ont déjà reçu un rappel pour cette activité"}
+ {input type="radio" name="yes_before" value=0 default=$yes_before label="Seulement aux membres dont l'inscription n'a pas encore expiré" help="Seuls les inscriptions expirant dans le futur seront concernées"}
+ {else}
+ Restriction d'envoi
+ {if $reminder.not_before_date}
+ Aucun rappel ne sera envoyé aux inscriptions expirant avant le {$reminder.not_before_date|date_short}
+ {else}
+ Aucune restriction. Tous les membres recevront ce rappel, selon le délai choisi.
+ {/if}
+ {/if}
+
+ {input type="text" name="subject" required=1 source=$reminder label="Sujet du message envoyé"}
+ {input type="textarea" name="body" required=1 source=$reminder label="Texte du message envoyé" cols="90" rows="15"}
+
+ Il est possible d'utiliser les mots-clés suivant dans le corps du mail, ils seront remplacés lors de l'envoi :
+ {literal}
+
+
+ {{$label}}
+ Nom de l'activité concernée par le rappel
+
+
+ {{$fee_label}}
+ Nom du tarif utilisé lors de la dernière inscription du membre à cette activité
+
+
+ {{$id_user}}
+ ID du membre concerné par le rappel
+
+
+ {{$identity}}
+ Nom du membre
+
+
+ {{$email}}
+ Adresse e-mail utilisée pour l'envoi du rappel au membre
+
+
+ {{$nb_days}}
+ Nombre de jours restants avant (ou après) expiration de l'inscription
+
+
+ {{$reminder_date}}
+ Date d'envoi du rappel
+
+
+ {{$expiry_date}}
+ Date d'expiration de l'inscription
+
+
+ {{$user_amount}}
+ Montant dû par le membre pour se réinscrire à cette activité
+
+
+ {{$delay}}
+ Nombre de jours défini dans le rappel
+
+
+ {{$config.org_name}}
+ Nom de l'association
+
+
+ {{$config.org_address}}
+ Adresse postale de l'association
+
+
+ {{$site_url}}
+ Adresse du site web de l'association
+
+
+ Note : il est aussi possible d'utiliser les champs de la fiche membre, par exemple {{$nom}} pour le nom du membre.
+ {/literal}
+
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/services/reminders/delete.tpl b/src/templates/services/reminders/delete.tpl
new file mode 100644
index 0000000..177b1b3
--- /dev/null
+++ b/src/templates/services/reminders/delete.tpl
@@ -0,0 +1,10 @@
+{include file="_head.tpl" title="Supprimer un rappel automatique" current="users/services"}
+
+{include file="services/_nav.tpl" current="reminders"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ce rappel automatique ?"
+ warning="Êtes-vous sûr de vouloir supprimer le rappel « %s » ?"|args:$reminder.subject
+ confirm="Cocher cette case pour supprimer aussi l'historique des messages envoyés."}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/reminders/details.tpl b/src/templates/services/reminders/details.tpl
new file mode 100644
index 0000000..59f3e5b
--- /dev/null
+++ b/src/templates/services/reminders/details.tpl
@@ -0,0 +1,63 @@
+{include file="_head.tpl" title="Liste des rappels envoyés" current="users/services"}
+
+{include file="services/_nav.tpl" current="reminders"}
+
+
+
+ {$reminder.subject}
+ {link href="?id=%d&list=pending"|args:$reminder.id label="Rappels à envoyer"}
+ {link href="?id=%d&list=sent"|args:$reminder.id label="Rappels déjà envoyés"}
+
+
+
+
+ Rappel : {$reminder.subject}
+ Activité : {$service.label}
+ Délai d'envoi : {if $reminder.delay > 0}{$reminder.delay} jours après l'expiration{elseif $reminder.delay < 0}{$reminder.delay|abs} jours avant l'expiration{else}le jour de l'expiration{/if}
+ {if $current_list === 'sent'}
+ Nombre de rappels envoyés
+
+ {$list->count()}
+
+ {elseif $current_list === 'pending'}
+ Nombre de rappels à envoyer
+
+ {$list->count()}
+
+ {/if}
+
+
+{if $list->count()}
+ {if $current_list === 'pending'}
+ Note : cette liste ne prend pas en compte les membres qui ont une adresse e-mail invalide, ou qui se sont désinscrit des envois de messages.
+ {/if}
+
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity}
+ {if $current_list === 'pending'}
+ {$row.expiry_date|date_short}
+ {/if}
+ {$row.reminder_date|date_short}
+
+ {if $current_list === 'pending'}
+ {linkbutton href="preview.php?id_user=%d&id_reminder=%d"|args:$row.id_user:$reminder.id shape="eye" label="Prévisualiser" target="_dialog"}
+ {/if}
+
+
+ {/foreach}
+
+
+
+{elseif $current_list === 'pending'}
+ Il n'y a aucun message à envoyer pour ce rappel.
+{else}
+ Il n'y a aucun message envoyé pour ce rappel.
+{/if}
+
+{$list->getHTMLPagination()|raw}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/reminders/edit.tpl b/src/templates/services/reminders/edit.tpl
new file mode 100644
index 0000000..d8e3cf2
--- /dev/null
+++ b/src/templates/services/reminders/edit.tpl
@@ -0,0 +1,7 @@
+{include file="_head.tpl" title="Modifier un rappel automatique" current="users/services"}
+
+{include file="services/_nav.tpl" current="reminders"}
+
+{include file="services/reminders/_form.tpl" legend="Modifier un rappel automatique" default_subject=null default_body=null}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/reminders/index.tpl b/src/templates/services/reminders/index.tpl
new file mode 100644
index 0000000..1a19d47
--- /dev/null
+++ b/src/templates/services/reminders/index.tpl
@@ -0,0 +1,51 @@
+{include file="_head.tpl" title="Gestion des rappels automatiques" current="users/services"}
+
+{include file="services/_nav.tpl" current="reminders"}
+
+
+ Les rappels automatiques sont envoyés aux membres disposant d'une adresse e-mail selon le délai défini.
+ Il est possible de définir plusieurs rappels pour une même activité.
+ {if USE_CRON}
+ Les rappels sont envoyés automatiquement chaque jour.
+ {/if}
+
+
+{if empty($list)}
+ Aucun rappel automatique n'est configuré.
+{else}
+
+
+ Activité
+ Délai de rappel
+ Sujet
+
+
+
+ {foreach from=$list item="reminder"}
+
+
+ {$reminder.service_label}
+
+
+ {if $reminder.delay == 0}le jour de l'expiration
+ {else}
+ {$reminder.delay|abs}
+ {if abs($reminder.delay) > 1}jours{else}jour{/if}
+ {if $reminder.delay > 0}après{else}avant{/if}
+ expiration
+ {/if}
+
+ {$reminder.subject}
+
+ {linkbutton shape="history" label="Liste des rappels envoyés" href="!services/reminders/details.php?id=%d&list=sent"|args:$reminder.id}
+ {linkbutton shape="mail" label="Liste des rappels à envoyer" href="!services/reminders/details.php?id=%d&list=pending"|args:$reminder.id}
+ {linkbutton shape="edit" label="Modifier" href="!services/reminders/edit.php?id=%d"|args:$reminder.id}
+ {linkbutton shape="delete" label="Supprimer" href="!services/reminders/delete.php?id=%d"|args:$reminder.id}
+
+
+ {/foreach}
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/reminders/new.tpl b/src/templates/services/reminders/new.tpl
new file mode 100644
index 0000000..608c23b
--- /dev/null
+++ b/src/templates/services/reminders/new.tpl
@@ -0,0 +1,8 @@
+{include file="_head.tpl" title="Ajouter un rappel automatique" current="users/services"}
+
+{include file="services/_nav.tpl" current="reminders"}
+
+{include file="services/reminders/_form.tpl" legend="Ajouter un rappel automatique"
+ delay_type=0 delay_before=15 delay_after=5}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/reminders/preview.tpl b/src/templates/services/reminders/preview.tpl
new file mode 100644
index 0000000..a1a0f6f
--- /dev/null
+++ b/src/templates/services/reminders/preview.tpl
@@ -0,0 +1,7 @@
+{include file="_head.tpl" title="Prévisualisation" current="users/services"}
+
+
+{$body|escape|nl2br}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/reminders/user.tpl b/src/templates/services/reminders/user.tpl
new file mode 100644
index 0000000..23d8b9a
--- /dev/null
+++ b/src/templates/services/reminders/user.tpl
@@ -0,0 +1,32 @@
+{include file="_head.tpl" title="Rappels envoyés à un membre" current="users/services"}
+
+{include file="users/_nav_user.tpl" id=$user_id current="reminders"}
+
+{if $list->count()}
+
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {$row.label}
+ {if $row.delay > 0}{$row.delay} jours après l'expiration{elseif $row.delay < 0}{$row.delay|abs} jours avant l'expiration{else}le jour de l'expiration{/if}
+ {$row.date|date_short}
+
+ {linkbutton shape="menu" label="Inscriptions après ce rappel" href="!services/user/?id=%d&after=%s"|args:$user_id,$row.date}
+
+
+ {/foreach}
+
+
+
+
+ {$list->getHTMLPagination()|raw}
+
+
+{else}
+
+ Aucun rappel n'a été envoyé à ce membre.
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/user/_choice_form.tpl b/src/templates/services/user/_choice_form.tpl
new file mode 100644
index 0000000..ffa062e
--- /dev/null
+++ b/src/templates/services/user/_choice_form.tpl
@@ -0,0 +1,63 @@
+
+
+ Activité (obligatoire)
+
+ {foreach from=$grouped_services item="service"}
+
+ {input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null}
+
+
+
{$service.label}
+
+ {if $service.duration}
+ {$service.duration} jours
+ {elseif $service.start_date}
+ du {$service.start_date|date_short} au {$service.end_date|date_short}
+ {else}
+ ponctuelle
+ {/if}
+
+ {if $service.description}
+
+ {$service.description|escape|nl2br}
+
+ {/if}
+
+
+
+ {foreachelse}
+ Aucune activité trouvée
+ {/foreach}
+
+
+
+{foreach from=$grouped_services item="service"}
+fees)) { continue; } ?>
+
+ Tarif (obligatoire)
+ {foreach from=$service.fees key="service_id" item="fee"}
+
+ {input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null}
+
+
+
{$fee.label}
+
+ {if !$fee.user_amount}
+ prix libre ou gratuit
+ {elseif $fee.user_amount && $fee.formula}
+ {$fee.user_amount|raw|money_currency} (montant calculé)
+ {elseif $fee.user_amount}
+ {$fee.user_amount|raw|money_currency}
+ {/if}
+
+ {if $fee.description}
+
+ {$fee.description|escape|nl2br}
+
+ {/if}
+
+
+
+ {/foreach}
+
+{/foreach}
\ No newline at end of file
diff --git a/src/templates/services/user/_service_user_form.tpl b/src/templates/services/user/_service_user_form.tpl
new file mode 100644
index 0000000..1871c78
--- /dev/null
+++ b/src/templates/services/user/_service_user_form.tpl
@@ -0,0 +1,154 @@
+
+
+
+
+
+ Inscrire à une activité
+
+
+ {if $create && $users}
+
+ Membres à inscrire
+
+
+
+ {{%n membre sélectionné.}{%n membres sélectionnés.} n=$users|count}
+
+ {foreach from=$users key="id" item="name"}
+
+
+
+ {if !empty($allow_users_edit)}
+ {button shape="delete" onclick="this.parentNode.parentNode.remove();" title="Supprimer de la liste"}
+ {/if}
+
+
+ {$name}
+
+
+ {/foreach}
+
+
+
+ {elseif $create && $copy_service}
+ Recopier depuis l'activité
+ {$copy_service.label}
+ {if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}
+ {elseif $create && $copy_fee}
+ Recopier depuis le tarif
+ {$copy_fee->service()->label} — {$copy_fee.label}
+ {if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}
+ {/if}
+
+ Activité (obligatoire)
+
+ {foreach from=$grouped_services item="service"}
+
+ {input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null source=$service_user}
+
+
+
{$service.label}
+
+ {if $service.duration}
+ {$service.duration} jours
+ {elseif $service.start_date}
+ du {$service.start_date|date_short} au {$service.end_date|date_short}
+ {else}
+ ponctuelle
+ {/if}
+
+ {if $service.description}
+
+ {$service.description|escape|nl2br}
+
+ {/if}
+
+
+
+ {foreachelse}
+ Aucune activité trouvée
+ {/foreach}
+
+
+
+ {foreach from=$grouped_services item="service"}
+ fees)) { continue; } ?>
+
+ Tarif (obligatoire)
+ {foreach from=$service.fees key="service_id" item="fee"}
+
+ {input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null data-project=$fee.id_project source=$service_user }
+
+
+
{$fee.label}
+
+ {if $fee.user_amount && $fee.formula}
+ {$fee.user_amount|raw|money_currency} (montant calculé)
+ {elseif $fee.formula}
+ montant calculé, variable selon les membres
+ {elseif $fee.user_amount}
+ {$fee.user_amount|raw|money_currency}
+ {else}
+ prix libre ou gratuit
+ {/if}
+
+ {if $fee.description}
+
+ {$fee.description|escape|nl2br}
+
+ {/if}
+
+
+
+ {/foreach}
+
+ {/foreach}
+
+
+
+
+
+
+
+ Détails
+
+ {input type="date" name="date" required=1 default=$today source=$service_user label="Date d'inscription"}
+ {input type="date" name="expiry_date" source=$service_user label="Date d'expiration de l'inscription"}
+ {input type="checkbox" name="paid" value="1" source=$service_user default="1" label="Marquer cette inscription comme payée"}
+ Décocher cette case pour pouvoir suivre les règlements de personnes qui payent en plusieurs fois. Il sera possible de cocher cette case lorsque le solde aura été réglé.
+
+
+
+ {if $create}
+
+ {input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"}
+
+
+ {if !empty($users)}
+ Une écriture sera créée pour chaque membre inscrit.
+ {/if}
+
+ {input type="money" name="amount" label="Montant réglé par le membre" required=true help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."}
+ {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=0"|args:$account_targets name="account_selector" label="Compte de règlement" required=true}
+ {input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
+ {input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
+ {input type="textarea" name="notes" label="Remarques"}
+ {if count($projects) > 0}
+ {input type="select" options=$projects name="id_project" label="Projet analytique" required=false default_empty="— Aucun —"}
+ {/if}
+
+
+ {/if}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
\ No newline at end of file
diff --git a/src/templates/services/user/add.tpl b/src/templates/services/user/add.tpl
new file mode 100644
index 0000000..219b225
--- /dev/null
+++ b/src/templates/services/user/add.tpl
@@ -0,0 +1,62 @@
+{include file="_head.tpl" title="Inscrire à une activité" current="membres/services"}
+
+{include file="services/_nav.tpl" current="save" fee=null service=null}
+
+{form_errors}
+
+
+
+
+ Inscrire à une activité
+
+ {input type="radio-btn" name="choice" value="1" label="Sélectionner des membres" default=1}
+ {input type="radio-btn" name="choice" value="2" label="Recopier depuis une activité" help="Utile si vous avez une cotisation par année civile par exemple : copie les membres inscrits l'année précédente dans la nouvelle année."}
+ {input type="radio-btn" name="choice" value="3" label="Tous les membres d'une catégorie"}
+
+
+
+
+ Inscrire des membres
+
+ {input type="list" name="users" required=true label="Membres à inscrire" target="!users/selector.php" multiple=true}
+
+
+
+
+ Recopier depuis une activité
+
+ {input type="select_groups" name="copy" label="Activité à recopier" options=$services required=true default=0}
+ {input type="checkbox" name="copy_only_paid" value="1" label="Ne recopier que les membres dont l'inscription est payée"}
+
+
+
+
+ Tous les membres d'une catégorie
+
+ {input type="select" name="category" label="Catégorie à inscrire" options=$categories required=true}
+
+
+
+
+
+ {button type="submit" name="next" label="Continuer" shape="right" class="main"}
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/user/delete.tpl b/src/templates/services/user/delete.tpl
new file mode 100644
index 0000000..b8f169a
--- /dev/null
+++ b/src/templates/services/user/delete.tpl
@@ -0,0 +1,9 @@
+{include file="_head.tpl" title="%s : Supprimer une inscription"|args:$user_name current="users/services"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer l'inscription ?"
+ warning="Êtes-vous sûr de vouloir supprimer l'inscription ?"
+ alert="Les écritures comptables liées à cette inscription ne seront pas supprimées, la comptabilité demeurera inchangée."
+ info="%s – à « %s — %s »"|args:$user_name,$service_name,$fee_name}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/user/edit.tpl b/src/templates/services/user/edit.tpl
new file mode 100644
index 0000000..9e6cfaf
--- /dev/null
+++ b/src/templates/services/user/edit.tpl
@@ -0,0 +1,7 @@
+{include file="_head.tpl" title="Modifier une inscription" current="users/services"}
+
+{form_errors}
+
+{include file="services/user/_service_user_form.tpl" create=false}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/user/index.tpl b/src/templates/services/user/index.tpl
new file mode 100644
index 0000000..55c050d
--- /dev/null
+++ b/src/templates/services/user/index.tpl
@@ -0,0 +1,99 @@
+{include file="_head.tpl" title="%s — Inscriptions aux activités et cotisations"|args:$user_name current="users/services"}
+
+{include file="users/_nav_user.tpl" id=$user_id current="services"}
+
+{form_errors}
+
+{if !$only}
+
+ Statut des inscriptions
+ {foreach from=$services item="service"}
+
+ {$service.label}
+ {if $service.archived} (activité passée) {/if}
+ {if $service.status == -1 && $service.end_date} — expirée
+ {elseif $service.status == -1} — en retard
+ {elseif $service.status == 1 && $service.end_date} — en cours
+ {elseif $service.status == 1} — à jour {/if}
+ {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
+ {if !$service.paid} — À payer ! {/if}
+
+ {foreachelse}
+
+ Ce membre n'est actuellement inscrit à aucune activité ou cotisation.
+
+ {/foreach}
+ {if !$only && !$after}
+ Nombre d'inscriptions pour ce membre
+
+ {$list->count()}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {exportmenu href="?id=%d"|args:$user_id}
+ {/if}
+
+ {/if}
+
+{/if}
+
+{if $only}
+ Cette liste ne montre qu'une seule inscription, liée à l'activité {$only_service.label}
+ {linkbutton shape="right" href="?id=%d"|args:$user_id label="Voir toutes les inscriptions"}
+
+{/if}
+
+{include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {$row.label} {if $row.archived}(archivée) {/if}
+ {$row.fee}
+ {$row.date|date_short}
+ {$row.expiry|date_short}
+ {if $row.paid}Oui {else}Non {/if}
+ {if $row.expected_amount}{$row.amount|raw|money_currency:false}
+ {if $row.amount}(sur {$row.expected_amount|raw|money_currency:false}) {/if}
+ {/if}
+
+
+ {if !$row.paid}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $row.id_account}
+ {linkbutton shape="plus" label="Nouveau règlement" href="payment.php?id=%d"|args:$row.id}
+ {/if}
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
+ {linkbutton shape="plus" label="Saisir une écriture liée"
+ href="!acc/transactions/new.php?u[%d]=%d&00=%d&t=1&l=Paiement%%20activité&ar=%s&set_year=%d"|args:$user_id:$row.id:$row.expected_amount:$row.account_code:$row.id_year target="_dialog"}
+ {/if}
+
+ {/if}
+
+ {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
+ {linkbutton shape="menu" label="Liste des écritures" href="!acc/transactions/service_user.php?id=%d&user=%d"|args:$row.id,$user_id}
+ {/if}
+
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
+ {if $row.paid}
+ {linkbutton shape="reset" label="Marquer comme non payé" href="?id=%d&su_id=%d&paid=0"|args:$user_id,$row.id}
+ {else}
+ {linkbutton shape="check" label="Marquer comme payé" href="?id=%d&su_id=%d&paid=1"|args:$user_id,$row.id}
+ {/if}
+
+ {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$row.id}
+ {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id}
+ {/if}
+
+
+
+ {foreachelse}
+
+ Aucune inscription trouvée.
+
+ {/foreach}
+
+
+
+
+{$list->getHTMLPagination()|raw}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/user/link.tpl b/src/templates/services/user/link.tpl
new file mode 100644
index 0000000..ade6af3
--- /dev/null
+++ b/src/templates/services/user/link.tpl
@@ -0,0 +1,22 @@
+{include file="_head.tpl" title="Lier une inscription à une écriture" current="acc/accounts"}
+
+{form_errors}
+
+
+
+
+ Lier à une écriture
+
+
+ {input type="number" label="Numéro de l'écriture" name="id_transaction" required=true}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/user/payment.tpl b/src/templates/services/user/payment.tpl
new file mode 100644
index 0000000..12e0e08
--- /dev/null
+++ b/src/templates/services/user/payment.tpl
@@ -0,0 +1,33 @@
+{include file="_head.tpl" title="Enregistrer un règlement" current="users/services"}
+
+{form_errors}
+
+
+
+
+ Enregistrer un règlement
+
+
+ Membre sélectionné
+ {$user_name}
+ Inscription
+ {input type="checkbox" name="paid" value="1" default=$su.paid label="Marquer cette inscription comme payée"}
+ {input type="date" name="date" label="Date" required=1 source=$su}
+ {input type="money" name="amount" label="Montant réglé par le membre" required=1}
+ {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$account_targets,$fee.id_year name="account_selector" label="Compte de règlement" required=1}
+ {input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
+ {input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
+ {if count($projects) > 0}
+ {input type="select" options=$projects name="id_project" label="Projet analytique" default=$fee.id_project required=false default_empty="— Aucun —"}
+ {/if}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/services/user/subscribe.tpl b/src/templates/services/user/subscribe.tpl
new file mode 100644
index 0000000..2604e1f
--- /dev/null
+++ b/src/templates/services/user/subscribe.tpl
@@ -0,0 +1,11 @@
+{include file="_head.tpl" title="Inscrire à une activité" current="users/services"}
+
+{if !$dialog}
+{include file="services/_nav.tpl" current="save" fee=null service=null}
+{/if}
+
+{form_errors}
+
+{include file="services/user/_service_user_form.tpl" create=true}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/static/upgrade_post.html b/src/templates/static/upgrade_post.html
new file mode 100644
index 0000000..fc455bc
--- /dev/null
+++ b/src/templates/static/upgrade_post.html
@@ -0,0 +1,21 @@
+
+
+
+ Mise à jour en cours
+
+
+
+
+
+Une mise à jour est en cours
+Ne fermez pas cette fenêtre !
+Si vous fermez cette fenêtre, les données du formulaire seront perdues.
+Attendez que la mise à jour soit terminée dans la fenêtre ci-dessous pour cliquer sur le bouton Recharger :
+Recharger
+
+
+
+
diff --git a/src/templates/users/_details.tpl b/src/templates/users/_details.tpl
new file mode 100644
index 0000000..a61d316
--- /dev/null
+++ b/src/templates/users/_details.tpl
@@ -0,0 +1,96 @@
+attachmentsDirectory();
+
+$id_fields = DF::getNameFields();
+$email_button = 0;
+$fields = DF::getInstance()->all();
+?>
+
+
+ {foreach from=$fields key="key" item="field"}
+ user_access_level) {
+ continue;
+ }
+
+ // Skip files from export
+ if ($context === 'export' && $field->type === 'file') {
+ continue;
+ }
+
+ $value = $user->$key ?? null;
+ ?>
+ {$field.label}
+
+ {* Skip according to management access rules *}
+ {if $context === 'manage' && !$session->canAccess(Session::SECTION_USERS, $field.management_access_level)}
+ **Caché**
+
+ {/if}
+
+
+ {if $field.type == 'checkbox'}
+ {if $value}
+ {icon shape="check"} Oui
+ {else}
+ {icon shape="uncheck"} Non
+ {/if}
+ {elseif $field.type == 'file'}
+ user_access_level === Session::ACCESS_WRITE || $context === 'manage');
+ ?>
+ {include file="common/files/_context_list.tpl" path="%s/%s"|args:$user_files_path:$key}
+ {elseif empty($value)}
+ (Non renseigné)
+ {elseif $field.type == 'email'}
+ {$value}
+ {if !DISABLE_EMAIL && $show_message_button && !$email_button++}
+ {linkbutton href="!users/message.php?id=%d"|args:$data.id label="Envoyer un message" shape="mail"}
+ {/if}
+ {elseif $field.type == 'multiple'}
+
+ {foreach from=$field.options key="b" item="name"}
+ {if (int)$value & (0x01 << (int)$b)}
+ {$name}
+ {/if}
+ {/foreach}
+
+ {else}
+ {if in_array($key, $id_fields)}{/if}
+ {user_field field=$field value=$value user_id=$user.id}
+ {if in_array($key, $id_fields)} {/if}
+ {/if}
+
+ {if $field.type == 'email' && $value}
+
+ Statut e-mail
+
+ {if $email.optout}
+ {icon shape="alert"} Ne souhaite plus recevoir de messages
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
+
+ {linkbutton target="_dialog" label="Rétablir les envois à cette adresse" href="!users/mailing/verify.php?address=%s"|args:$value shape="check"}
+ {/if}
+ {elseif $email.invalid}
+ {icon shape="alert"} Adresse invalide
+ {linkbutton href="!users/mailing/rejected.php?hl=%d#e_%1\$d"|args:$email.id label="Détails de l'erreur" shape="help"}
+ {elseif $email && $email->hasReachedFailLimit()}
+ {icon shape="alert"} Trop d'erreurs
+ {linkbutton href="!users/mailing/rejected.php?hl=%d#e_%1\$d"|args:$email.id label="Détails de l'erreur" shape="help"}
+ {elseif $email.verified}
+ {icon shape="check" class="confirm"} Adresse vérifiée
+ {else}
+ {* Adresse non vérifiée *}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
+ {linkbutton target="_dialog" label="Désinscrire de tous les envois" href="!users/mailing/block.php?address=%s"|args:$value shape="delete"}
+ {/if}
+ {/if}
+
+ {/if}
+ {/foreach}
+
diff --git a/src/templates/users/_import_list.tpl b/src/templates/users/_import_list.tpl
new file mode 100644
index 0000000..b4560e6
--- /dev/null
+++ b/src/templates/users/_import_list.tpl
@@ -0,0 +1,23 @@
+{foreach from=$list item="user"}
+ exists() && $user->isModified(); ?>
+ {$user->name()}
+
+
+ {if $user->exists() && $user->isModified()}
+ {foreach from=$user->diff() key="key" item="diff"}
+ {$csv->getColumnLabel($key)}
+
+ {user_field name=$key value=$diff[0]}
+ {user_field name=$key value=$diff[1]}
+
+ {/foreach}
+ {else}
+ {foreach from=$user->asDetailsArray() key="key" item="value"}
+ {$csv->getColumnLabel($key)}
+
+ {user_field name=$key value=$value}
+
+ {/foreach}
+ {/if}
+
+{/foreach}
diff --git a/src/templates/users/_list_actions.tpl b/src/templates/users/_list_actions.tpl
new file mode 100644
index 0000000..a3ced8d
--- /dev/null
+++ b/src/templates/users/_list_actions.tpl
@@ -0,0 +1,30 @@
+
+
+
+
+ Pour les membres cochés :
+ {csrf_field key="membres_action"}
+
+ — Choisir une action à effectuer —
+ Changer de catégorie
+ Inscrire à une activité
+ {if empty($hide_delete)}
+ Supprimer les membres
+ Supprimer les fichiers du membre
+ {/if}
+ {if !isset($export) || $export != false}
+
+ CSV
+ LibreOffice
+ {if CALC_CONVERT_COMMAND}
+ Excel
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/users/_log_list.tpl b/src/templates/users/_log_list.tpl
new file mode 100644
index 0000000..aa8fccc
--- /dev/null
+++ b/src/templates/users/_log_list.tpl
@@ -0,0 +1,45 @@
+{include file="common/dynamic_list_head.tpl"}
+
+{foreach from=$list->iterate() item="row"}
+
+ {$row.created|date_short:true}
+ {if !$row.identity}*{else}{$row.identity}{/if}
+
+ {if $row.type == Log::LOGIN_FAIL || $row.type == Log::LOGIN_PASSWORD_CHANGE}
+ {icon shape="alert"}
+ {/if}
+
+
+ {$row.type_label}
+
+
+ {if $row.type == Log::LOGIN_FAIL && $row.details.otp}
+ Code OTP erroné
+ {elseif $row.type == Log::LOGIN_SUCCESS && $row.details.otp}
+ (avec code OTP)
+ {/if}
+ {if $row.type == Log::LOGIN_FAIL || $row.type == Log::LOGIN_SUCCESS || $row.type == Log::LOGIN_RECOVER}
+ {$row.details.user_agent}
+ {elseif $row.type == Log::LOGIN_AS}
+ "{$row.details.admin}" s'est connecté à la place du membre
+ {elseif $row.entity_url}
+ {link href=$row.entity_url label=$row.entity_name}
+ {elseif $row.entity_name}
+ {$row.entity_name}
+ {/if}
+ {if isset($row.details.id) && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
+ (ID = {$row.details.id})
+ {/if}
+
+ {$row.ip_address}
+
+
+
+{/foreach}
+
+
+
+
+{$list->getHTMLPagination()|raw}
+
+Note : les heures correspondent au fuseau horaire du serveur (=ini_get('date.timezone')?>).
\ No newline at end of file
diff --git a/src/templates/users/_nav.tpl b/src/templates/users/_nav.tpl
new file mode 100644
index 0000000..27de271
--- /dev/null
+++ b/src/templates/users/_nav.tpl
@@ -0,0 +1,16 @@
+
+ {if $current == 'index' && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+
+ {exportmenu right=true}
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/src/templates/users/_nav_user.tpl b/src/templates/users/_nav_user.tpl
new file mode 100644
index 0000000..14a0c9d
--- /dev/null
+++ b/src/templates/users/_nav_user.tpl
@@ -0,0 +1,19 @@
+
+
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current == 'details'}
+ {linkbutton href="edit.php?id=%d"|args:$id shape="edit" label="Modifier" accesskey="M"}
+ {/if}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && $logged_user.id != $id && $current == 'details'}
+ {linkbutton href="delete.php?id=%d"|args:$id shape="delete" label="Supprimer" target="_dialog" accesskey="S"}
+ {/if}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current == 'services'}
+ {linkbutton href="!services/user/subscribe.php?user=%d"|args:$id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="K"}
+ {/if}
+
+
+
+ {link href="!users/details.php?id=%d"|args:$id label="Fiche membre" accesskey="F"}
+ {link href="!services/user/?id=%d"|args:$id label="Inscriptions aux activités" accesskey="I"}
+ {link href="!services/reminders/user.php?id=%d"|args:$id label="Rappels envoyés" accesskey="R"}
+
+
\ No newline at end of file
diff --git a/src/templates/users/_password_form.tpl b/src/templates/users/_password_form.tpl
new file mode 100644
index 0000000..8b92ef4
--- /dev/null
+++ b/src/templates/users/_password_form.tpl
@@ -0,0 +1,32 @@
+fieldsBySystemUse('password'));
+$password_length = User::MINIMUM_PASSWORD_LENGTH;
+$suggestion = Utils::suggestPassword();
+$required = $required ?? $field->required;
+?>
+
+
+
+ Astuce : un mot de passe de quatre mots choisis au hasard dans le dictionnaire est plus sûr et plus simple à retenir qu'un mot de passe composé de 10 lettres et chiffres.
+
+
+ Pas d'idée ? Voici une suggestion choisie au hasard :
+ {input type="text" readonly=true title="Cliquer pour utiliser cette suggestion comme mot de passe" default=$suggestion autocomplete="off" copy=true name="suggest"}
+
+
+ {input type="password" name="password" required=$required label="Mot de passe" help="Minimum %d caractères"|args:$password_length autocomplete="off" minlength=$password_length}
+
+ {input type="password" name="password_confirmed" required=$required label="Encore le mot de passe (vérification)" help="Minimum %d caractères"|args:$password_length autocomplete="off" minlength=$password_length}
+
+
+
\ No newline at end of file
diff --git a/src/templates/users/action.tpl b/src/templates/users/action.tpl
new file mode 100644
index 0000000..f5b9929
--- /dev/null
+++ b/src/templates/users/action.tpl
@@ -0,0 +1,48 @@
+{include file="_head.tpl" title="Action collective sur les membres" current="membres"}
+
+{form_errors}
+
+{if $action == 'delete'}
+ {include file="common/delete_form.tpl"
+ legend="Supprimer %d membres ?"|args:$count
+ warning="Êtes-vous sûr de vouloir supprimer ces membres ?"
+ alert="Cette action est irréversible et effacera toutes les données personnelles et les inscriptions aux activités de ces membres."
+ extra=$extra
+ info="Alternativement, il est aussi possible de déplacer les membres qui ne font plus partie de l'association dans une catégorie (par exemple \"Anciens membres\"), plutôt que de les supprimer."}
+{elseif $action == 'delete_files'}
+ {include file="common/delete_form.tpl"
+ legend="Supprimer les fichiers de %d membres ?"|args:$count
+ warning="Êtes-vous sûr de vouloir supprimer les fichiers de ces %d membres ?"|args:$count
+ alert="Cette action est irréversible."
+ extra=$extra}
+{else}
+
+ {foreach from=$list item="id"}
+
+ {/foreach}
+
+
+ {{%n membre sélectionné.}{%n membres sélectionnés} n=$count}
+
+
+ {if $action == 'move'}
+
+
+ Changer la catégorie des membres sélectionnés
+
+ {input type="select" name="new_category_id" label="Nouvelle catégorie" options=$categories required=true default_empty=""}
+
+
+
+
+ {csrf_field key=$csrf_key}
+
+ {button type="submit" name="confirm" label="Modifier la catégorie" shape="right" class="main"}
+
+
+ {/if}
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/delete.tpl b/src/templates/users/delete.tpl
new file mode 100644
index 0000000..647e4f8
--- /dev/null
+++ b/src/templates/users/delete.tpl
@@ -0,0 +1,11 @@
+{include file="_head.tpl" title="Supprimer un membre" current="users"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ce membre ?"
+ warning=$warning
+ alert="Cette action est irréversible et effacera toutes les données et l'historique de ce membre."
+ info="Alternativement, il est aussi possible de déplacer le membre dans une catégorie « Anciens membres », plutôt que de le supprimer complètement."
+ csrf_key=$csrf_key
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/details.tpl b/src/templates/users/details.tpl
new file mode 100644
index 0000000..586f779
--- /dev/null
+++ b/src/templates/users/details.tpl
@@ -0,0 +1,114 @@
+{include file="_head.tpl" title="%s (%s)"|args:$user->name():$category.name current="users"}
+
+{include file="users/_nav_user.tpl" id=$user.id current="details"}
+
+
+ Activités et cotisations
+ {foreach from=$services item="service"}
+
+ {$service.label}
+ {if $service.archived} (activité passée) {/if}
+ {if $service.status == -1 && $service.end_date} — terminée
+ {elseif $service.status == -1} — en retard
+ {elseif $service.status == 1 && $service.end_date} — en cours
+ {elseif $service.status == 1} — à jour {/if}
+ {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
+ {if !$service.paid} — À payer ! {/if}
+
+ {foreachelse}
+
+ Ce membre n'est actuellement inscrit à aucune activité ou cotisation.
+
+ {/foreach}
+
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
+ {linkbutton href="!services/user/subscribe.php?user=%d"|args:$user.id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="V"}
+ {/if}
+
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)}
+ {if !empty($transactions_linked)}
+ Écritures comptables liées
+ {$transactions_linked} écritures comptables liées à ce membre
+ {/if}
+ {if !empty($transactions_created)}
+ Écritures comptables créées
+ {$transactions_created} écritures comptables créées par ce membre
+ {/if}
+ {/if}
+ {if $user->isChild()}
+ Membre responsable
+ {link href="?id=%d"|args:$user.id_parent label=$parent_name}
+ {if count($siblings)}
+ Autres membres rattachés à {$parent_name}
+ {foreach from=$siblings item="sibling"}
+ {link href="?id=%d"|args:$sibling.id label=$sibling.name}
+ {/foreach}
+ {/if}
+ {elseif count($children)}
+ Membres rattachés
+ {foreach from=$children item="child"}
+ {link href="?id=%d"|args:$child.id label=$child.name}
+ {/foreach}
+ {/if}
+
+
+
+
+ {if $user.date_updated}
+ Fiche modifiée le
+ {$user.date_updated|date_long:true}
+
+ {linkbutton shape="history" label="Historique" href="!users/log.php?history=%d"|args:$user.id}
+
+ {/if}
+ Catégorie
+ {$category.name}
+ Droits
+ {display_permissions permissions=$category}
+ Dernière connexion
+ {if empty($user.date_login)}Jamais{else}{$user.date_login|date_short:true}{/if}
+
+ {linkbutton shape="menu" label="Journal d'audit" href="!users/log.php?id=%d"|args:$user.id}
+
+ Mot de passe
+
+ {if empty($user.password)}
+ Pas de mot de passe configuré
+ {else}
+ {icon shape="check"} Oui
+ {if !empty($user.otp_secret)}
+ ({icon shape="lock"} avec second facteur)
+ {else}
+ ({icon shape="unlock"} sans second facteur)
+ {/if}
+ {/if}
+
+
+ {if $logged_user.id == $user.id}
+ {linkbutton shape="settings" label="Modifier mon mot de passe" href="!me/security.php"}
+ {elseif $user.password}
+ {linkbutton shape="settings" label="Modifier le mot de passe" href="edit_security.php?id=%d"|args:$user.id target="_dialog"}
+ {else}
+ {linkbutton shape="settings" label="Définir un mot de passe" href="edit_security.php?id=%d"|args:$user.id target="_dialog"}
+ {/if}
+
+ {if !LOCAL_LOGIN
+ && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)
+ && $user.id != $logged_user.id
+ && $user.id_category != $logged_user.id_category
+ && $category.perm_config < $session::ACCESS_ADMIN}
+
+
+ {csrf_field key=$csrf_key}
+ {button name="login_as" type="submit" shape="login" label="Se connecter à sa place"}
+
+
+ {/if}
+
+
+
+{include file="users/_details.tpl" data=$user show_message_button=true context="manage"}
+
+{$snippets|raw}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/edit.tpl b/src/templates/users/edit.tpl
new file mode 100644
index 0000000..fca1630
--- /dev/null
+++ b/src/templates/users/edit.tpl
@@ -0,0 +1,44 @@
+{include file="_head.tpl" title="%s — Modifier le membre"|args:$user->name() current="users"}
+
+
+ {linkbutton href="details.php?id=%d"|args:$user.id label="Retour à la fiche membre" shape="left"}
+
+
+{form_errors}
+
+
+
+
+
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {if $user.id != $logged_user.id}
+ {input type="select" name="id_category" label="Catégorie du membre" required=true source=$user options=$categories}
+ {else}
+ Vous ne pouvez pas modifier votre catégorie de membre, il faut qu'un autre administrateur le fasse pour vous.
+ {/if}
+ {/if}
+
+ {if !$user->is_parent}
+ {input type="list" name="id_parent" label="Rattacher à un membre" target="!users/selector.php?no_children=1" help="Permet de regrouper les personnes d'un même foyer par exemple. Sélectionner ici le membre responsable." default=$user->getParentSelector() can_delete=true}
+ {/if}
+
+
+
+
+
+ Fiche du membre
+
+ {foreach from=$fields item="field"}
+ {edit_user_field field=$field user=$user context="admin_edit"}
+ {/foreach}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/edit_security.tpl b/src/templates/users/edit_security.tpl
new file mode 100644
index 0000000..8c1f11b
--- /dev/null
+++ b/src/templates/users/edit_security.tpl
@@ -0,0 +1,40 @@
+{include file="_head.tpl" title="%s — Modifier le mot de passe"|args:$user->name() current="users"}
+
+{form_errors}
+
+
+
+
+
+
+
+ {if $user.password}Changer le mot de passe{else}Définir un mot de passe{/if}
+ {include file="users/_password_form.tpl"}
+
+ {if $user.password}
+ {input type="checkbox" name="password_delete" label="Supprimer le mot de passe de ce membre" value=1}
+ {/if}
+
+
+
+ {if $user.otp_secret || $user.pgp_key}
+
+ Options de sécurité
+
+ {if $user.otp_secret}
+ {input type="checkbox" name="otp_delete" value="1" label="Désactiver l'authentification à double facteur TOTP"}
+ {/if}
+ {if $user.pgp_key}
+ {input type="checkbox" name="pgp_key" value="" label="Supprimer la clé PGP associée au membre"}
+ {/if}
+
+
+ {/if}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/import.tpl b/src/templates/users/import.tpl
new file mode 100644
index 0000000..ca80845
--- /dev/null
+++ b/src/templates/users/import.tpl
@@ -0,0 +1,122 @@
+{include file="_head.tpl" title="Importer des membres" current="users"}
+
+{include file="users/_nav.tpl" current="import"}
+
+{form_errors}
+
+{if $_GET.msg == 'OK'}
+
+ L'import s'est bien déroulé.
+
+{/if}
+
+
+
+{if $csv->ready()}
+ {if $report.has_logged_user}
+
+ Ce fichier comporte une modification de votre profil de membre.
+ Celle-ci a été ignorée afin d'empêcher que vous ne puissiez plus vous connecter.
+ Pour modifier vos informations de membre, utilisez la page {linkbutton shape="user" label="Mes informations personnelles" href="!me/"} ou demandez à un autre administrateur de modifier votre fiche.
+
+ {/if}
+
+
+ Aucun problème n'a été détecté.
+ Voici un résumé des changements qui seront apportés par cet import :
+
+
+ {if count($report.created)}
+
+
+ {{%n membre sera créé}{%n membres seront créés} n=$report.created|count}
+
+ Les membres suivants mentionnés dans le fichier seront ajoutés.
+ {include file="users/_import_list.tpl" list=$report.created}
+
+ {/if}
+
+ {if count($report.modified)}
+
+
+ {{%n membre sera modifié}{%n membres seront modifiés} n=$report.modified|count}
+
+ Les membres suivants mentionnés dans le fichier seront modifiés.
+ En rouge ce qui sera supprimé, en vert ce qui sera ajouté.
+ {include file="users/_import_list.tpl" list=$report.modified}
+
+ {/if}
+
+ {if count($report.unchanged)}
+ {{%n membre ne sera pas modifié}{%n membres ne seront pas modifiés} n=$report.unchanged|count}
+ {/if}
+
+ {if !count($report.modified) && !count($report.created)}
+
+ Aucune modification ne serait apportée par ce fichier à importer. Il n'est donc pas possible de terminer l'import.
+
+ {else}
+
+ En validant ce formulaire, ces changements seront appliqués.
+
+ {/if}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="cancel" value="1" label="Annuler" shape="left"}
+ {if count($report.modified) || count($report.created)}
+ {button type="submit" name="import" label="Importer" class="main" shape="right"}
+ {/if}
+
+{elseif $csv->loaded()}
+
+ {include file="common/_csv_match_columns.tpl"}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="cancel" value="1" label="Annuler" shape="left"}
+ {button type="submit" name="preview" label="Prévisualiser" shape="right" class="main"}
+
+
+{else}
+
+
+ Importer depuis un fichier
+
+ {input type="file" name="file" label="Fichier à importer" required=true accept="csv"}
+ {include file="common/_csv_help.tpl" csv=$csv}
+
+
+
+
+ Configuration de l'import
+
+ Mode d'import (obligatoire)
+
+ {input type="radio" name="mode" value="create" label="Créer tous les membres" required=true}
+ Tous les membres trouvés dans le fichier seront créés. Cela peut amener à avoir des membres en doublon si on réalise plusieurs imports du même fichier.
+
+ {input type="radio" name="mode" value="update" label="Mettre à jour en utilisant le numéro de membre" required=true}
+
+ Les membres présents dans le fichier qui mentionnent un numéro de membre seront mis à jour en utilisant ce numéro.
+ Si une ligne du fichier mentionne un numéro de membre qui n'existe pas ou n'a pas de numéro de membre, l'import échouera.
+
+
+ {input type="radio" name="mode" value="auto" label="Automatique : créer ou mettre à jour en utilisant le numéro de membre" required=true}
+
+ Met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné.
+
+
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="load" label="Charger le fichier" shape="right" class="main"}
+
+{/if}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/index.tpl b/src/templates/users/index.tpl
new file mode 100644
index 0000000..672e0cf
--- /dev/null
+++ b/src/templates/users/index.tpl
@@ -0,0 +1,111 @@
+{include file="_head.tpl" current="users"}
+
+{include file="users/_nav.tpl" current="index"}
+
+{if isset($_GET['sent'])}
+Le message a bien été envoyé.
+{/if}
+
+{if $_GET.msg == 'DELETE'}
+ Le membre a été supprimé.
+{elseif $_GET.msg == 'DELETE_MULTI'}
+ Les membres sélectionnés ont été supprimés.
+{elseif $_GET.msg == 'DELETE_FILES'}
+ Les fichiers des membres sélectionnés ont été supprimés.
+{elseif $_GET.msg == 'CATEGORY_CHANGED'}
+ Les membres sélectionnés ont bien été changés de catégorie.
+{/if}
+
+{if !empty($categories)}
+
+ Filtrer par catégorie
+
+
+
+
+{/if}
+
+
+
+ Rechercher un membre
+
+ {button type="submit" name="" title="Chercher" shape="search"}
+
+
+
+
+
+{if $list->count()}
+ {$list->getHTMLPagination()|raw}
+
+ {include file="common/dynamic_list_head.tpl" check=$can_edit}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {if $can_edit}
+ {input type="checkbox" name="selected[]" value=$row._user_id}
+ {/if}
+ {foreach from=$list->getHeaderColumns() key="key" item="value"}
+ $key; ?>
+ {if $key == 'number'}
+
+ {link href="details.php?id=%d"|args:$row._user_id label=$value}
+
+ {elseif $key == 'identity'}
+ {link href="details.php?id=%d"|args:$row._user_id label=$value}
+ {elseif $key == 'id_parent'}
+
+ {if $value}
+ {link href="details.php?id=%d"|args:$value label=$row._parent_name}
+ {/if}
+
+ {elseif $key == 'is_parent'}
+
+ {if $value}
+ Oui
+ {/if}
+
+ {else}
+
+ {user_field name=$key value=$value user_id=$row._user_id files_href="details.php?id=%d"|args:$row._user_id}
+
+ {/if}
+ {/foreach}
+
+
+ {linkbutton label="Fiche membre" shape="user" href="details.php?id=%d"|args:$row._user_id}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
+ {linkbutton label="Modifier" shape="edit" href="edit.php?id=%d"|args:$row._user_id}
+ {/if}
+
+
+ {/foreach}
+
+
+
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {include file="users/_list_actions.tpl" colspan=count($list->getHeaderColumns())+$can_edit+1}
+ {/if}
+
+
+
+ {$list->getHTMLPagination()|raw}
+{else}
+
+ Aucun membre trouvé.
+
+{/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/log.tpl b/src/templates/users/log.tpl
new file mode 100644
index 0000000..863e04b
--- /dev/null
+++ b/src/templates/users/log.tpl
@@ -0,0 +1,31 @@
+{if $params.history}
+ {include file="_head.tpl" title="Historique des modifications de la fiche membre"}
+{else}
+ {include file="_head.tpl" title="Journal d'audit du membre"}
+{/if}
+
+{if $params.id_user}
+ {include file="users/_nav_user.tpl" id=$params.id_user}
+{elseif $params.history}
+ {include file="users/_nav_user.tpl" id=$params.history}
+{else}
+ {include file="me/_nav.tpl" current="security"}
+{/if}
+
+{if !$params.history}
+
+ Cette page liste les tentatives de connexion, les modifications de mot de passe ou d'identifiant, et toutes les actions de création, suppression ou modification effectuées par ce membre.
+
+{/if}
+
+{if $list->count()}
+ {include file="users/_log_list.tpl"}
+{else}
+
+ Aucune activité trouvée.
+
+{/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/_nav.tpl b/src/templates/users/mailing/_nav.tpl
new file mode 100644
index 0000000..0db635d
--- /dev/null
+++ b/src/templates/users/mailing/_nav.tpl
@@ -0,0 +1,17 @@
+
+ {if $current === 'rejected'}
+
+ {elseif $current === 'index'}
+
+ {linkbutton shape="plus" label="Nouveau message" href="new.php" target="_dialog"}
+
+ {/if}
+
+
+
diff --git a/src/templates/users/mailing/block.tpl b/src/templates/users/mailing/block.tpl
new file mode 100644
index 0000000..de22075
--- /dev/null
+++ b/src/templates/users/mailing/block.tpl
@@ -0,0 +1,17 @@
+{include file="_head.tpl" title="Désinscription d'adresse" current="users/mailing"}
+
+
+
+ Désinscrire une adresse
+ Désinscrire l'adresse {$address} ?
+
+ Une fois cette adresse désinscrite, elle ne pourra plus recevoir aucun message de votre association (rappels, notifications, messages collectifs, etc.).
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="send" label="Désinscrire cette adresse" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/delete.tpl b/src/templates/users/mailing/delete.tpl
new file mode 100644
index 0000000..d038ba5
--- /dev/null
+++ b/src/templates/users/mailing/delete.tpl
@@ -0,0 +1,8 @@
+{include file="_head.tpl" title="Supprimer un envoi de message collectif" current="users/mailing"}
+
+{include file="common/delete_form.tpl"
+ legend="Supprimer ce message collectif ?"
+ warning="Êtes-vous sûr de vouloir supprimer le message « %s » ?"|args:$mailing.subject
+ info="La liste des destinataires sera également supprimée."}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/details.tpl b/src/templates/users/mailing/details.tpl
new file mode 100644
index 0000000..1f59db6
--- /dev/null
+++ b/src/templates/users/mailing/details.tpl
@@ -0,0 +1,55 @@
+{include file="_head.tpl" title="Message collectif : %s"|args:$mailing.subject current="users/mailing"}
+
+{include file="./_nav.tpl" current="details"}
+
+{if $sent}
+ L'envoi du message a bien commencé. Il peut prendre quelques minutes avant d'avoir été expédié à tous les destinataires.
+{/if}
+
+{form_errors}
+
+
+
+ {if $mailing.sent}
+ Envoyé le
+ {$mailing.sent|date_long:true}
+ {else}
+ Statut
+
+ Brouillon
+ {linkbutton shape="edit" label="Modifier" href="write.php?id=%d"|args:$mailing.id}
+ {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$mailing.id}
+ {if $mailing.body}
+ {button shape="right" label="Envoyer" class="main" name="send" type="submit"}
+ {/if}
+
+ Expéditeur
+
+ {$mailing->getFrom()}
+
+ {/if}
+ {if $mailing.target_type}
+ Cible
+
+ {$mailing->getTargetTypeLabel()} — {$mailing.target_label}
+
+ {/if}
+ Destinataires
+
+ {{%n destinataire}{%n destinataires} n=$mailing->countRecipients()}
+ {linkbutton shape="users" label="Voir la liste des destinataires" href="recipients.php?id=%d"|args:$mailing.id}
+
+ Sujet
+ {$mailing.subject}
+ Message
+ {$mailing.body}
+ Prévisualisation
+ {linkbutton shape="eye" label="Prévisualiser le message" href="?id=%d&preview"|args:$mailing.id target="_dialog"}
+ (Un destinataire sera choisi au hasard.)
+
+ Note : la prévisualisation peut différer du rendu final, selon le logiciel utilisé par vos destinataires pour lire leurs messages.
+
+ {csrf_field key=$csrf_key}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/index.tpl b/src/templates/users/mailing/index.tpl
new file mode 100644
index 0000000..03c7812
--- /dev/null
+++ b/src/templates/users/mailing/index.tpl
@@ -0,0 +1,46 @@
+{include file="_head.tpl" title="Messages collectifs" current="users/mailing"}
+
+
+
+ {linkbutton shape="plus" label="Nouveau message" href="new.php" target="_dialog"}
+
+
+
+
+{if $_GET.msg == 'DELETE'}
+ Le message a bien été supprimé.
+{/if}
+
+{if !$list->count()}
+ Aucun message collectif n'a été écrit.
+ {linkbutton shape="plus" label="Écrire un nouveau message" href="new.php" target="_dialog"}
+
+{else}
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {link href="details.php?id=%d"|args:$row.id label=$row.subject}
+ {$row.nb_recipients}
+ {if $row.sent}{$row.sent|relative_date:true}{else}Brouillon{/if}
+
+ {linkbutton shape="eye" label="Ouvrir" href="details.php?id=%d"|args:$row.id}
+ {if !$row.sent}
+ {linkbutton shape="edit" label="Modifier" href="write.php?id=%d"|args:$row.id}
+ {/if}
+ {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id target="_dialog"}
+
+
+ {/foreach}
+
+
+
+ {$list->getHTMLPagination()|raw}
+{/if}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/new.tpl b/src/templates/users/mailing/new.tpl
new file mode 100644
index 0000000..767e95d
--- /dev/null
+++ b/src/templates/users/mailing/new.tpl
@@ -0,0 +1,79 @@
+{include file="_head.tpl" title="Nouveau message collectif" current="users/mailing"}
+
+{form_errors}
+
+
+{if !$target_type}
+
+ Sujet du message
+
+ {input type="text" required="true" label="Sujet du message" name="subject" class="full-width"}
+
+
+
+ Qui doit recevoir ce message ?
+
+ {input type="radio-btn" name="target_type" value="field" label="Membres correspondant à une case à cocher (sauf ceux appartenant à une catégorie cachée)" required=true help="Par exemple les membres inscrits à la lettre d'information."}
+ {input type="radio-btn" name="target_type" value="all" label="Tous les membres (sauf ceux appartenant à une catégorie cachée)" required=true}
+ {input type="radio-btn" name="target_type" value="category" label="Membres d'une seule catégorie" required=true}
+ {input type="radio-btn" name="target_type" value="service" label="Membres inscrits à une activité, et à jour" required=true help="Les membres dont l'inscription a expiré ne recevront pas de message."}
+ {input type="radio-btn" name="target_type" value="search" label="Membres renvoyés par une recherche enregistrée" required=true}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="step2" label="Continuer" shape="right" class="main"}
+
+{elseif $target_type == 'field'}
+
+ Quel champ de la fiche membre ?
+
+ {foreach from=$list item="field"}
+ {input type="radio" name="target_value" value=$field.name label=$field.label help="%d membres"|args:$field.count}
+ {input type="hidden" name="labels[%s]"|args:$field.name default=$field.label}
+ {/foreach}
+
+
+{elseif $target_type == 'category'}
+
+ Quelle catégorie ?
+
+ {foreach from=$list item="cat"}
+ {input type="radio" name="target_value" value=$cat.id label=$cat.name help="%d membres"|args:$cat.count}
+ {input type="hidden" name="labels[%s]"|args:$cat.id default=$cat.name}
+ {/foreach}
+
+
+{elseif $target_type == 'service'}
+
+ Quelle activité ?
+
+ {foreach from=$list item="service"}
+ {input type="radio" name="target_value" value=$service.id label=$service.label help="%d membres"|args:$service.nb_users_ok}
+ {input type="hidden" name="labels[%s]"|args:$service.id default=$service.label}
+ {/foreach}
+
+
+{elseif $target_type == 'search'}
+
+ Quelle recherche utiliser ?
+
+ {foreach from=$list item="search"}
+ {input type="radio" name="target_value" value=$search.id label=$search.label help="%d membres"|args:$search.count}
+ {input type="hidden" name="labels[%s]"|args:$search.id default=$search.label}
+ {/foreach}
+
+
+{/if}
+{if $target_type}
+ Note : le nombre de membres affiché ne prend pas en compte les membres qui ne disposent pas d'adresse e-mail, ou qui se sont désinscrits. Le nombre de destinataires réels sera affiché avant envoi.
+
+ {input type="hidden" name="subject"}
+ {input type="hidden" name="target_type" default=$target_type}
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="step3" label="Créer" shape="right" class="main"}
+
+{/if}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/optout.tpl b/src/templates/users/mailing/optout.tpl
new file mode 100644
index 0000000..290cef8
--- /dev/null
+++ b/src/templates/users/mailing/optout.tpl
@@ -0,0 +1,40 @@
+{include file="_head.tpl" title="Désinscriptions" current="users/mailing"}
+
+{include file="./_nav.tpl" current="optout"}
+
+{if isset($_GET['sent'])}
+
+ Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message.
+
+{/if}
+
+{if !$list->count()}
+ Aucune adresse e-mail n'a demandé à être désinscrite pour le moment.
+{else}
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity}
+ {$row.email}
+ {$row.status}
+ {$row.sent_count}
+ {$row.last_sent|date}
+
+ {if $row.email && $row.optout}
+ {linkbutton target="_dialog" label="Rétablir" href="!users/mailing/verify.php?address=%s"|args:$row.email shape="check"}
+ {elseif $row.email && $row.target_type}
+ {linkbutton target="_dialog" label="Supprimer" href="!users/mailing/optout_delete.php?address=%s"|args:$row.email shape="delete"}
+ {/if}
+
+
+
+ {/foreach}
+
+
+
+ {$list->getHTMLPagination()|raw}
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/recipient_data.tpl b/src/templates/users/mailing/recipient_data.tpl
new file mode 100644
index 0000000..d3e93a1
--- /dev/null
+++ b/src/templates/users/mailing/recipient_data.tpl
@@ -0,0 +1,23 @@
+{include file="_head.tpl" title="Données du destinataire" current="users/mailing"}
+
+Vous pouvez copier la variable (colonne de gauche) dans le corps du message :
+ elle sera remplacée dans le message par le contenu (colonne à droite) spécifique à chaque destinataire.
+
+
+
+
+ Variable
+ Contenu
+
+
+
+ {foreach from=$data key="name" item="value"}
+
+ {ldelim}{ldelim}${$name}{rdelim}{rdelim}
+ {$value|escape|nl2br}
+
+ {/foreach}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/recipients.tpl b/src/templates/users/mailing/recipients.tpl
new file mode 100644
index 0000000..7a00669
--- /dev/null
+++ b/src/templates/users/mailing/recipients.tpl
@@ -0,0 +1,54 @@
+{include file="_head.tpl" title="Destinataires du message collectif : %s"|args:$mailing.subject current="users/mailing"}
+
+{include file="./_nav.tpl" current="details"}
+
+
+ {linkbutton shape="left" label="Retour au message" href="details.php?id=%d"|args:$mailing.id}
+ {exportmenu}
+
+
+{if $mailing.anonymous}
+
+ Les informations personnelles des destinataires ont été supprimées automatiquement après un délai de six mois, conformément au RGPD.
+
+{else}
+
+ Les informations personnelles des destinataires seront supprimées automatiquement après un délai de six mois, conformément au RGPD.
+
+{/if}
+
+{form_errors}
+
+ {include file="common/dynamic_list_head.tpl"}
+ {foreach from=$list->iterate() item="r"}
+
+ {$r.email}
+ {$r.name}
+
+ {if $r.status}
+ {$r.status}
+ {/if}
+
+
+ {if $r.has_extra_data}
+ {linkbutton shape="menu" label="Données" href="recipient_data.php?id=%d&r=%d"|args:$mailing.id:$r.id target="_dialog"}
+ {/if}
+ {if $r.id_user}
+ {linkbutton shape="user" label="Fiche membre" href="!users/details.php?id=%d"|args:$r.id_user}
+ {/if}
+ {if !$mailing.sent}
+ {button shape="delete" label="Supprimer" name="delete" value=$r.id type="submit"}
+ {/if}
+ {if !$mailing.anonymous && $r.email}
+ {linkbutton href="details.php?id=%d&preview=%d"|args:$mailing.id:$r.id label="Prévisualiser" shape="eye" target="_dialog"}
+ {/if}
+
+
+ {/foreach}
+
+
+ {csrf_field key=$csrf_key}
+ {$list->getHTMLPagination()|raw}
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/rejected.tpl b/src/templates/users/mailing/rejected.tpl
new file mode 100644
index 0000000..d872442
--- /dev/null
+++ b/src/templates/users/mailing/rejected.tpl
@@ -0,0 +1,74 @@
+{include file="_head.tpl" title="Adresses rejetées" current="users/mailing"}
+
+{include file="./_nav.tpl" current="rejected"}
+
+{if isset($_GET['sent'])}
+
+ Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message.
+
+{elseif isset($_GET['forced'])}
+
+ La file d'attente a été envoyée.
+
+{/if}
+
+
+
+ {if !$queue_count}
+ Il n'y a aucun message en attente d'envoi.
+ {else}
+ Il y a {$queue_count} messages dans la file d'attente, ils seront envoyés dans quelques instants.
+ {if !USE_CRON && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
+ {button shape="right" label="Forcer l'envoi des messages en attente" type="submit" name="force_queue"}
+ {/if}
+ {/if}
+
+
+
+{if !$list->count()}
+ Aucune adresse e-mail n'a été rejetée pour le moment. Cette page présentera les adresses e-mail invalides ou qui ont demandé à se désinscrire.
+{else}
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity}
+ {$row.email}
+ {$row.status}
+ {$row.sent_count}
+ {$row.fail_log|escape|nl2br}
+ {$row.last_sent|date}
+
+ {if $row.email && $row.last_sent < $limit_date}
+ email); ?>
+ {linkbutton target="_dialog" label="Rétablir" href="!users/mailing/verify.php?address=%s"|args:$email shape="check"}
+ {/if}
+
+
+
+ {/foreach}
+
+
+
+ {$list->getHTMLPagination()|raw}
+
+
+
Statuts possibles d'une adresse e-mail :
+
+ {*
+ Vérifiée
+ L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire.
+ *}
+ Invalide
+ L'adresse n'existe pas ou plus. Il n'est pas possible de lui envoyer des messages.
+ Trop d'erreurs
+ Le service destinataire a renvoyé une erreur temporaire plus de {$max_fail_count} fois. Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine. Cette adresse ne recevra plus de message.
+
+
+ Il est possible de rétablir la réception de messages pour les adresses invalides après un délai d'un mois, et les adresses désinscrites immédiatement, en cliquant sur le bouton "Rétablir" qui enverra un message de validation à la personne.
+
+
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/verify.tpl b/src/templates/users/mailing/verify.tpl
new file mode 100644
index 0000000..a207d01
--- /dev/null
+++ b/src/templates/users/mailing/verify.tpl
@@ -0,0 +1,28 @@
+{include file="_head.tpl" title="Vérification d'adresse" current="users/mailing"}
+
+
+
+ Demander la vérification de l'adresse
+ {if $email.optout}
+
+ Si le membre a cliqué par erreur sur le lien de désinscription, il est possible de rétablir l'envoi des messages.
+ Le membre recevra alors un message contenant un lien pour se réinscrire.
+
+ {elseif $email->hasReachedFailLimit()}
+
+ Si l'adresse du membre a rencontré trop d'erreurs (boîte mail pleine par exemple), il est possible de rétablir l'envoi des messages.
+ Le membre recevra alors un message contenant un lien pour valider son adresse.
+
+ {/if}
+
+ Attention, n'utiliser cette procédure qu'à la demande du membre.
+ En cas d'absence de consentement du membre, les messages aux autres membres pourront être bloqués par les serveurs destinataires.
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="send" label="Envoyer un message de vérification" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/mailing/write.tpl b/src/templates/users/mailing/write.tpl
new file mode 100644
index 0000000..eb7f663
--- /dev/null
+++ b/src/templates/users/mailing/write.tpl
@@ -0,0 +1,49 @@
+{include file="_head.tpl" title="Message collectif" current="users/mailing" hide_title=true}
+
+{form_errors}
+
+
+
+
+
+
+ {input type="textarea" name="content" cols=35 rows=25 required=true class="full-width"
+ data-attachments=0 data-savebtn=0 data-preview-url="!users/mailing/write.php?id=%s&preview"|local_url|args:$mailing.id data-format="markdown" placeholder="Contenu du message…" default=$mailing.body}
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
+
+
+
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/message.tpl b/src/templates/users/message.tpl
new file mode 100644
index 0000000..93d5714
--- /dev/null
+++ b/src/templates/users/message.tpl
@@ -0,0 +1,27 @@
+{include file="_head.tpl" title="Contacter un membre" current="membres"}
+
+{form_errors}
+
+
+
+ Message
+
+ Expéditeur
+ {input type="radio" name="sender" value="self" default="self" required=true label="Membre : %s"|args:$self->getNameAndEmail()}
+ {input type="radio" name="sender" value="org" default="self" required=true label='Association : "%s" <%s>'|args:$config.org_name:$config.org_email}
+ Destinataire
+ {$recipient->getNameAndEmail()}
+ {input type="text" name="subject" required=true label="Sujet" class="full-width"}
+ {input type="textarea" name="message" required=true label="Message" rows=15 class="full-width"}
+ {input type="checkbox" name="send_copy" value=1 label="Recevoir par e-mail une copie du message envoyé"}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="send" label="Envoyer" shape="mail" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/new.tpl b/src/templates/users/new.tpl
new file mode 100644
index 0000000..75b6f74
--- /dev/null
+++ b/src/templates/users/new.tpl
@@ -0,0 +1,42 @@
+{include file="_head.tpl" title="Ajouter un membre" current="users/new"}
+
+{form_errors}
+
+
+
+{if $is_duplicate}
+
+ Attention : un membre existe déjà avec ce nom.
+ {linkbutton shape="user" href="details.php?id=%d"|args:$is_duplicate label="Voir la fiche du membre existant" target="_dialog"}
+ {button shape="right" label="Ce n'est pas un doublon, créer ce membre" name="save" value="anyway" type="submit"}
+
+{/if}
+
+
+
+
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
+ {input type="select" name="id_category" label="Catégorie du membre" required=true options=$categories default=$default_category}
+ {/if}
+ {input type="list" name="id_parent" label="Rattacher à un membre" target="!users/selector.php?no_children=1" help="Permet de regrouper les personnes d'un même foyer par exemple. Sélectionner ici le membre responsable." can_delete=true}
+
+
+
+
+
+ Fiche du membre
+
+ {foreach from=$fields item="field"}
+ {edit_user_field context="admin_new" field=$field user=$user}
+ {/foreach}
+
+
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="save" label="Créer ce membre" shape="right" class="main"}
+
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/search.tpl b/src/templates/users/search.tpl
new file mode 100644
index 0000000..c921c08
--- /dev/null
+++ b/src/templates/users/search.tpl
@@ -0,0 +1,126 @@
+{include file="_head.tpl" title="Recherche de membre" current="users"}
+
+{include file="users/_nav.tpl" current="search"}
+
+
+
+{include file="common/search/advanced.tpl"}
+
+{if $list !== null}
+ {if $list->count() > 0}
+ {exportmenu form=true name="_dl_export" class="menu-btn-right"}
+ {/if}
+
+ {$list->count()} membres trouvés pour cette recherche.
+
+ {$list->getHTMLPagination(true)|raw}
+
+ {include file="common/dynamic_list_head.tpl" check=$is_admin use_buttons=true}
+
+ {foreach from=$list->iterate() item="row"}
+
+ {if $is_admin}
+ {input type="checkbox" name="selected[]" value=$row.id}
+ {/if}
+ {foreach from=$list->getHeaderColumns() key="name" item="label"}
+
+ {user_field name=$name value=$row->$name link_name_id=$row.id files_href="!users/details.php?id=%d"|args:$row.id}
+
+ {/foreach}
+
+ {linkbutton shape="user" label="Fiche membre" href="!users/details.php?id=%d"|args:$row.id}
+ {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
+ {linkbutton shape="edit" label="Modifier" href="!users/edit.php?id=%d"|args:$row.id}
+ {/if}
+
+
+ {/foreach}
+
+ {if $is_admin}
+ {include file="users/_list_actions.tpl" colspan=$list->countHeaderColumns()+1}
+ {/if}
+
+
+ {$list->getHTMLPagination(true)|raw}
+
+
+
+{elseif $count}
+
+ {exportmenu form=true name="_export" class="menu-btn-right"}
+
+
+ {$count} résultats trouvés pour cette recherche.
+
+ {if !empty($has_limit)}
+ Le nombre de résultats affichés est limité.
+ {/if}
+
+
+
+
+
+
+
+
+
+{elseif $count === 0}
+
+ Aucun résultat trouvé pour cette recherche.
+
+{/if}
+
+
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/users/selector.tpl b/src/templates/users/selector.tpl
new file mode 100644
index 0000000..b150ef0
--- /dev/null
+++ b/src/templates/users/selector.tpl
@@ -0,0 +1,63 @@
+{include file="_head.tpl" title="Sélectionner un compte"}
+
+
+
+
+
+
+
+
+{if $list}
+
+
+ {foreach from=$list->iterate() item="row"}
+
+
+ {$row.identity}
+
+
+ Sélectionner
+
+
+ {/foreach}
+
+
+{/if}
+
+{literal}
+
+{/literal}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/web/_attach.tpl b/src/templates/web/_attach.tpl
new file mode 100644
index 0000000..f3eaf2c
--- /dev/null
+++ b/src/templates/web/_attach.tpl
@@ -0,0 +1,83 @@
+{include file="_head.tpl" title="Inclure un fichier"}
+
+{form_errors}
+
+
+
+ Téléverser des fichiers
+
+ {input type="file" name="file[]" multiple=true label="Fichiers à envoyer" data-enhanced=1}
+
+
+ {csrf_field key=$csrf_key}
+ {button type="submit" name="upload" label="Envoyer" shape="upload" class="main"}
+
+
+
+
+
+
+ Insérer une image dans le texte
+
+
+ Légende (facultatif)
+
+
+
+ Alignement :
+
+
+
+
+
+
+ Annuler
+
+
+
+
+
+{if !empty($images)}
+
+{foreach from=$images item="file"}
+
+{/foreach}
+
+{/if}
+
+{if !empty($files)}
+
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/web/_history.tpl b/src/templates/web/_history.tpl
new file mode 100644
index 0000000..f570b33
--- /dev/null
+++ b/src/templates/web/_history.tpl
@@ -0,0 +1,70 @@
+
+
+
+
+ {if isset($versions)}
+ {if $versions->count()}
+ {include file="common/dynamic_list_head.tpl" list=$versions}
+ {foreach from=$versions->iterate() item="version"}
+
+ {$version.date|date_short:true}
+ {if !$version.author}Membre supprimé {else}{$version.author}{/if}
+
+ {$version.size} caractères
+
+
+ {if $version.changes < 0}
+ {$version.changes}
+ {else}
+ +{$version.changes}
+ {/if}
+
+
+ {linkbutton shape="menu" label="Modifications" href="?id=%d&history=%d"|args:$page.id:$version.id}
+ {linkbutton shape="reload" label="Restaurer" href="!web/edit.php?id=%d&restore=%d"|args:$page.id:$version.id}
+
+
+ {/foreach}
+
+
+ {else}
+ Aucun historique n'a été trouvé pour cette page.
+ {/if}
+ {elseif isset($version)}
+
+
+
+ {link href="?id=%d&history=%d&view=diff"|args:$page.id:$version.id label="Différences"}
+ {link href="?id=%d&history=%d&view=render"|args:$page.id:$version.id label="Visualisation"}
+ {link href="?id=%d&history=%d&view=raw"|args:$page.id:$version.id label="Texte brut"}
+
+
+
+ {if $view === 'render'}
+
+ {$page->preview($version.content)|raw}
+
+ {elseif $view === 'raw'}
+
+ {$version.content}
+
+ {else}
+ {diff old=$version.previous_content new=$version.content context=5 old_label="Ancienne version" new_label="Nouvelle version"}
+ {/if}
+ {/if}
+
\ No newline at end of file
diff --git a/src/templates/web/_list.tpl b/src/templates/web/_list.tpl
new file mode 100644
index 0000000..0ffc460
--- /dev/null
+++ b/src/templates/web/_list.tpl
@@ -0,0 +1,26 @@
+
+
+{include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="p"}
+
+ {link label=$p.title href="?id=%d"|args:$p.id}
+ {$p.published|relative_date}
+ {$p.modified|relative_date:true}
+
+ {linkbutton shape="image" label="Lire" href="./?id=%d"|args:$p.id}
+ {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)}
+ {linkbutton shape="edit" label="Éditer" href="edit.php?id=%d"|args:$p.id}
+ {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)}
+ {linkbutton shape="delete" label="Supprimer" target="_dialog" href="delete.php?id=%d"|args:$p.id}
+ {/if}
+ {/if}
+
+
+ {/foreach}
+
+
+
+{$list->getHTMLPagination()|raw}
\ No newline at end of file
diff --git a/src/templates/web/_page.tpl b/src/templates/web/_page.tpl
new file mode 100644
index 0000000..13f72bf
--- /dev/null
+++ b/src/templates/web/_page.tpl
@@ -0,0 +1,76 @@
+{if $excerpt && $page->requiresExcerpt() && !isset($_GET['full'])}
+ excerpt(); $long = true; ?>
+{else}
+ render(true); $long = false; ?>
+{/if}
+
+
+
+
+ {$page.title}
+ {if $can_edit}
+
+ {linkbutton href="edit.php?id=%s"|args:$page.id label="Éditer" shape="edit"}
+ {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)}
+ {linkbutton shape="delete" label="Supprimer" target="_dialog" href="delete.php?id=%d"|args:$page.id}
+ {/if}
+
+ {/if}
+
+
+
+ {if $page->isCategory()}
+ {icon shape="folder"} Catégorie
+ {else}
+ {icon shape="document"} Page
+ {/if}
+
+ {if $page.status == $page::STATUS_ONLINE}
+ {icon shape="eye"} En ligne
+ {else}
+ {icon shape="eye-off"} Brouillon
+ {/if}
+ Publié : {$page.published|relative_date:true}
+ Modifié : {$page.modified|relative_date:true}
+ {if $page->isOnline()}
+ {link href=$page->url() label=$page->url() target="_blank"}
+ {/if}
+
+
+ {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)}
+
+
+ {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN) && !$page->hasSubPages()}
+ {if !$page->isCategory()}
+ {linkbutton href="?id=%d&toggle_type"|args:$page.id label="Transformer en catégorie" shape="reset"}
+ {else}
+ {linkbutton href="?id=%s&toggle_type"|args:$page.id label="Transformer en page simple" shape="reset"}
+ {/if}
+ {/if}
+ {linkbutton shape="history" label="Historique" href="?id=%d&history=list"|args:$page.id}
+
+
+ {/if}
+
+
+ {if trim($page.content)}
+
+ {$text|raw}
+ {if $excerpt && $long}
+ {linkbutton href="?id=%d&full"|args:$page.id label="Lire la suite" shape="right"}
+ {/if}
+
+
+ {/if}
+
+ {if !($excerpt && $long)}
+ listAttachments(); ?>
+
+
+
Fichiers joints
+
+ {include file="common/files/_context_list.tpl" files=$files edit=$can_edit path=$page->dir_path() use_trash=false}
+
+ {/if}
+
+
\ No newline at end of file
diff --git a/src/templates/web/_selector.tpl b/src/templates/web/_selector.tpl
new file mode 100644
index 0000000..df195fa
--- /dev/null
+++ b/src/templates/web/_selector.tpl
@@ -0,0 +1,47 @@
+{include file="_head.tpl" title="Sélectionner la catégorie" current="web"}
+
+
+
+
+
+ {foreach from=$breadcrumbs item="page"}
+ {button label=$page.title type="submit" name="current" value=$page.id}
+ {/foreach}
+
+
+
+
+
+ {if $current_cat_id}
+
+ {button shape="left" label="Catégorie parente" type="submit" name="current" value=$parent_id|intval}
+
+ {/if}
+
+ {foreach from=$categories item="c"}
+ {button shape="folder" label=$c.title type="submit" name="current" value=$c.id}
+ {foreachelse}
+ Aucune sous-catégorie ici.
+ {/foreach}
+
+ {if $id_page !== $current_cat_id}
+
+ {button shape="right" label="Choisir la catégorie \"%s\""|args:$current_cat_title type="button" name="move" value=$current_cat_id|intval data-label=$current_cat_title}
+
+ {/if}
+
+
+
+{literal}
+
+{/literal}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/web/all.tpl b/src/templates/web/all.tpl
new file mode 100644
index 0000000..9982549
--- /dev/null
+++ b/src/templates/web/all.tpl
@@ -0,0 +1,35 @@
+{include file="_head.tpl" title="Toutes les pages du site web" current="web"}
+
+
+ {linkbutton shape="left" href="./" label="Retour à la gestion du site"}
+
+
+{if $list->count()}
+
+ {exportmenu name="_dl_export" class="menu-btn-right"}
+
+ {include file="common/dynamic_list_head.tpl"}
+
+ {foreach from=$list->iterate() item="p"}
+
+ {link label=$p.title href="./?id=%d"|args:$p.id}
+ {$p.path}
+ {if $p.draft}Brouillon {/if}
+ {$p.published|relative_date}
+ {$p.modified|relative_date:true}
+
+ {if $can_edit}
+ {linkbutton shape="edit" label="Éditer" href="edit.php?id=%d"|args:$p.id}
+ {/if}
+
+
+ {/foreach}
+
+
+
+ {$list->getHTMLPagination()|raw}
+{else}
+ Il n'y a aucune page dans le site.
+{/if}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/web/delete.tpl b/src/templates/web/delete.tpl
new file mode 100644
index 0000000..1238600
--- /dev/null
+++ b/src/templates/web/delete.tpl
@@ -0,0 +1,9 @@
+{include file="_head.tpl" title=$title current="web"}
+
+{include file="common/delete_form.tpl"
+ legend=$title
+ warning="Êtes-vous sûr de vouloir supprimer « %s » ?"|args:$page.title
+ alert=$alert
+}
+
+{include file="_foot.tpl"}
\ No newline at end of file
diff --git a/src/templates/web/edit.tpl b/src/templates/web/edit.tpl
new file mode 100644
index 0000000..a68cf09
--- /dev/null
+++ b/src/templates/web/edit.tpl
@@ -0,0 +1,67 @@
+{include file="_head.tpl" title="Édition : %s"|args:$page.title current="web" hide_title=true}
+
+{form_errors}
+
+{if $show_diff}
+ {diff old=$my_content new=$page->content old_label="Votre version" new_label="Version enregistrée"}
+{elseif $restored_version}
+
+ Attention, le texte a été restauré depuis une version précédente, vous risquez d'écraser des modifications.
+
+{/if}
+
+
+