init
authorJulien Moutinho <julm+garradin@autogeree.net>
Thu, 18 Sep 2014 22:04:19 +0000 (00:04 +0200)
committerJulien Moutinho <julm+garradin@autogeree.net>
Thu, 18 Sep 2014 22:04:19 +0000 (00:04 +0200)
325 files changed:
.htaccess [new file with mode: 0644]
COPYING [new file with mode: 0644]
README [new file with mode: 0644]
VERSION [new file with mode: 0644]
config.dist.php [new file with mode: 0644]
cron.php [new file with mode: 0644]
include/class.champs_membres.php [new file with mode: 0644]
include/class.compta_categories.php [new file with mode: 0644]
include/class.compta_comptes.php [new file with mode: 0644]
include/class.compta_comptes_bancaires.php [new file with mode: 0644]
include/class.compta_exercices.php [new file with mode: 0644]
include/class.compta_import.php [new file with mode: 0644]
include/class.compta_journal.php [new file with mode: 0644]
include/class.compta_stats.php [new file with mode: 0644]
include/class.config.php [new file with mode: 0644]
include/class.cotisations.php [new file with mode: 0644]
include/class.cotisations_membres.php [new file with mode: 0644]
include/class.db.php [new file with mode: 0644]
include/class.membres.php [new file with mode: 0644]
include/class.membres_categories.php [new file with mode: 0644]
include/class.membres_import.php [new file with mode: 0644]
include/class.plugin.php [new file with mode: 0644]
include/class.rappels.php [new file with mode: 0644]
include/class.rappels_envoyes.php [new file with mode: 0644]
include/class.sauvegarde.php [new file with mode: 0644]
include/class.squelette.php [new file with mode: 0644]
include/class.wiki.php [new file with mode: 0644]
include/data/0.4.0.sql [new file with mode: 0644]
include/data/0.4.3.sql [new file with mode: 0644]
include/data/0.6.0.sql [new file with mode: 0644]
include/data/categories_comptables.sql [new file with mode: 0644]
include/data/champs_membres.ini [new file with mode: 0644]
include/data/plan_comptable.json [new file with mode: 0644]
include/data/schema.sql [new file with mode: 0644]
include/index.html [new file with mode: 0644]
include/init.php [new file with mode: 0644]
include/lib.squelette_filtres.php [new file with mode: 0644]
include/lib.static_cache.php [new file with mode: 0644]
include/lib.template.php [new file with mode: 0644]
include/lib.utils.php [new file with mode: 0644]
include/libs/countries/countries_en.php [new file with mode: 0644]
include/libs/countries/countries_fr.php [new file with mode: 0644]
include/libs/diff/class.simplediff.php [new file with mode: 0644]
include/libs/garbage2xhtml/lib.garbage2xhtml.php [new file with mode: 0644]
include/libs/miniskel/class.miniskel.php [new file with mode: 0644]
include/libs/passphrase/lib.passphrase.french.php [new file with mode: 0644]
include/libs/svgplot/lib.svgpie.php [new file with mode: 0644]
include/libs/svgplot/lib.svgplot.php [new file with mode: 0644]
include/libs/template_lite/class.compiler.php [new file with mode: 0644]
include/libs/template_lite/class.config.php [new file with mode: 0644]
include/libs/template_lite/class.parser.php [new file with mode: 0644]
include/libs/template_lite/class.template.php [new file with mode: 0644]
include/libs/template_lite/class.tokenparser.php [new file with mode: 0644]
include/libs/template_lite/internal/compile.compile_config.php [new file with mode: 0644]
include/libs/template_lite/internal/compile.compile_custom_block.php [new file with mode: 0644]
include/libs/template_lite/internal/compile.compile_custom_function.php [new file with mode: 0644]
include/libs/template_lite/internal/compile.compile_if.php [new file with mode: 0644]
include/libs/template_lite/internal/compile.generate_compiler_debug_output.php [new file with mode: 0644]
include/libs/template_lite/internal/compile.include.php [new file with mode: 0644]
include/libs/template_lite/internal/compile.parse_is_expr.php [new file with mode: 0644]
include/libs/template_lite/internal/compile.section_start.php [new file with mode: 0644]
include/libs/template_lite/internal/debug.tpl [new file with mode: 0644]
include/libs/template_lite/internal/template.build_dir.php [new file with mode: 0644]
include/libs/template_lite/internal/template.config_loader.php [new file with mode: 0644]
include/libs/template_lite/internal/template.destroy_dir.php [new file with mode: 0644]
include/libs/template_lite/internal/template.fetch_compile_include.php [new file with mode: 0644]
include/libs/template_lite/internal/template.generate_debug_output.php [new file with mode: 0644]
include/libs/template_lite/plugins/block.capture.php [new file with mode: 0644]
include/libs/template_lite/plugins/block.strip.php [new file with mode: 0644]
include/libs/template_lite/plugins/block.textformat.php [new file with mode: 0644]
include/libs/template_lite/plugins/compiler.debug.php [new file with mode: 0644]
include/libs/template_lite/plugins/compiler.tplheader.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.counter.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.cycle.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.db_function_call.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.db_result_call.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_checkboxes.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_hidden.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_image.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_input.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_options.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_radios.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_select_date.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_select_time.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_table.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.html_textbox.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.in_array.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.mailto.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.math.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.popup.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.popup_init.php [new file with mode: 0644]
include/libs/template_lite/plugins/function.resize_image.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.bbcode2html.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.capitalize.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.cat.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.count_characters.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.count_paragraphs.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.count_sentences.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.count_words.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.date.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.date_format.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.debug_print_var.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.default.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.escape.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.indent.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.lower.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.regex_replace.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.replace.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.spacify.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.string_format.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.strip.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.truncate.php [new file with mode: 0644]
include/libs/template_lite/plugins/modifier.upper.php [new file with mode: 0644]
include/libs/template_lite/plugins/outputfilter.gzip.php [new file with mode: 0644]
include/libs/template_lite/plugins/outputfilter.trimwhitespace.php [new file with mode: 0644]
include/libs/template_lite/plugins/postfilter.showtemplatevars.php [new file with mode: 0644]
include/libs/template_lite/plugins/prefilter.jstrip.php [new file with mode: 0644]
include/libs/template_lite/plugins/prefilter.showinfoheader.php [new file with mode: 0644]
include/libs/template_lite/plugins/shared.escape_chars.php [new file with mode: 0644]
include/libs/template_lite/plugins/shared.make_timestamp.php [new file with mode: 0644]
include/libs/template_lite/tests/parser.php [new file with mode: 0644]
include/libs/template_lite/tests/tokenparser.php [new file with mode: 0644]
index.php [new file with mode: 0644]
plugins/index.html [new file with mode: 0644]
templates/admin/_foot.tpl [new file with mode: 0644]
templates/admin/_head.tpl [new file with mode: 0644]
templates/admin/compta/banques/ajouter.tpl [new file with mode: 0644]
templates/admin/compta/banques/index.tpl [new file with mode: 0644]
templates/admin/compta/banques/modifier.tpl [new file with mode: 0644]
templates/admin/compta/banques/supprimer.tpl [new file with mode: 0644]
templates/admin/compta/categories/ajouter.tpl [new file with mode: 0644]
templates/admin/compta/categories/index.tpl [new file with mode: 0644]
templates/admin/compta/categories/modifier.tpl [new file with mode: 0644]
templates/admin/compta/categories/supprimer.tpl [new file with mode: 0644]
templates/admin/compta/comptes/ajouter.tpl [new file with mode: 0644]
templates/admin/compta/comptes/index.tpl [new file with mode: 0644]
templates/admin/compta/comptes/journal.tpl [new file with mode: 0644]
templates/admin/compta/comptes/modifier.tpl [new file with mode: 0644]
templates/admin/compta/comptes/supprimer.tpl [new file with mode: 0644]
templates/admin/compta/exercices/ajouter.tpl [new file with mode: 0644]
templates/admin/compta/exercices/bilan.tpl [new file with mode: 0644]
templates/admin/compta/exercices/cloturer.tpl [new file with mode: 0644]
templates/admin/compta/exercices/compte_resultat.tpl [new file with mode: 0644]
templates/admin/compta/exercices/grand_livre.tpl [new file with mode: 0644]
templates/admin/compta/exercices/index.tpl [new file with mode: 0644]
templates/admin/compta/exercices/journal.tpl [new file with mode: 0644]
templates/admin/compta/exercices/modifier.tpl [new file with mode: 0644]
templates/admin/compta/exercices/supprimer.tpl [new file with mode: 0644]
templates/admin/compta/import.tpl [new file with mode: 0644]
templates/admin/compta/index.tpl [new file with mode: 0644]
templates/admin/compta/operations/index.tpl [new file with mode: 0644]
templates/admin/compta/operations/membre.tpl [new file with mode: 0644]
templates/admin/compta/operations/modifier.tpl [new file with mode: 0644]
templates/admin/compta/operations/recherche_sql.tpl [new file with mode: 0644]
templates/admin/compta/operations/saisir.tpl [new file with mode: 0644]
templates/admin/compta/operations/supprimer.tpl [new file with mode: 0644]
templates/admin/compta/operations/voir.tpl [new file with mode: 0644]
templates/admin/config/_menu.tpl [new file with mode: 0644]
templates/admin/config/donnees.tpl [new file with mode: 0644]
templates/admin/config/import.tpl [new file with mode: 0644]
templates/admin/config/index.tpl [new file with mode: 0644]
templates/admin/config/membres.tpl [new file with mode: 0644]
templates/admin/config/plugins.tpl [new file with mode: 0644]
templates/admin/config/site.tpl [new file with mode: 0644]
templates/admin/index.tpl [new file with mode: 0644]
templates/admin/install.tpl [new file with mode: 0644]
templates/admin/login.tpl [new file with mode: 0644]
templates/admin/membres/action.tpl [new file with mode: 0644]
templates/admin/membres/ajouter.tpl [new file with mode: 0644]
templates/admin/membres/cat_modifier.tpl [new file with mode: 0644]
templates/admin/membres/cat_supprimer.tpl [new file with mode: 0644]
templates/admin/membres/categories.tpl [new file with mode: 0644]
templates/admin/membres/cotisations.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/ajout.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/gestion/modifier.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/gestion/rappel_modifier.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/gestion/rappel_supprimer.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/gestion/rappels.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/gestion/supprimer.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/index.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/rappels.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/supprimer.tpl [new file with mode: 0644]
templates/admin/membres/cotisations/voir.tpl [new file with mode: 0644]
templates/admin/membres/fiche.tpl [new file with mode: 0644]
templates/admin/membres/import.tpl [new file with mode: 0644]
templates/admin/membres/index.tpl [new file with mode: 0644]
templates/admin/membres/message.tpl [new file with mode: 0644]
templates/admin/membres/message_collectif.tpl [new file with mode: 0644]
templates/admin/membres/modifier.tpl [new file with mode: 0644]
templates/admin/membres/recherche.tpl [new file with mode: 0644]
templates/admin/membres/recherche_sql.tpl [new file with mode: 0644]
templates/admin/membres/supprimer.tpl [new file with mode: 0644]
templates/admin/mes_cotisations.tpl [new file with mode: 0644]
templates/admin/mes_infos.tpl [new file with mode: 0644]
templates/admin/password.tpl [new file with mode: 0644]
templates/admin/wiki/_chercher_parent.tpl [new file with mode: 0644]
templates/admin/wiki/chercher.tpl [new file with mode: 0644]
templates/admin/wiki/creer.tpl [new file with mode: 0644]
templates/admin/wiki/editer.tpl [new file with mode: 0644]
templates/admin/wiki/historique.tpl [new file with mode: 0644]
templates/admin/wiki/page.tpl [new file with mode: 0644]
templates/admin/wiki/recent.tpl [new file with mode: 0644]
templates/admin/wiki/supprimer.tpl [new file with mode: 0644]
templates/error.tpl [new file with mode: 0644]
templates/index.html [new file with mode: 0644]
templates/index.tpl [new file with mode: 0644]
www/.htaccess [new file with mode: 0644]
www/_inc.php [new file with mode: 0644]
www/_route.php [new file with mode: 0644]
www/admin/.htaccess [new file with mode: 0644]
www/admin/_inc.php [new file with mode: 0644]
www/admin/compta/_inc.php [new file with mode: 0644]
www/admin/compta/banques/ajouter.php [new file with mode: 0644]
www/admin/compta/banques/index.php [new file with mode: 0644]
www/admin/compta/banques/modifier.php [new file with mode: 0644]
www/admin/compta/banques/supprimer.php [new file with mode: 0644]
www/admin/compta/categories/ajouter.php [new file with mode: 0644]
www/admin/compta/categories/index.php [new file with mode: 0644]
www/admin/compta/categories/modifier.php [new file with mode: 0644]
www/admin/compta/categories/supprimer.php [new file with mode: 0644]
www/admin/compta/comptes/ajouter.php [new file with mode: 0644]
www/admin/compta/comptes/index.php [new file with mode: 0644]
www/admin/compta/comptes/journal.php [new file with mode: 0644]
www/admin/compta/comptes/modifier.php [new file with mode: 0644]
www/admin/compta/comptes/supprimer.php [new file with mode: 0644]
www/admin/compta/exercices/ajouter.php [new file with mode: 0644]
www/admin/compta/exercices/bilan.php [new file with mode: 0644]
www/admin/compta/exercices/cloturer.php [new file with mode: 0644]
www/admin/compta/exercices/compte_resultat.php [new file with mode: 0644]
www/admin/compta/exercices/grand_livre.php [new file with mode: 0644]
www/admin/compta/exercices/index.php [new file with mode: 0644]
www/admin/compta/exercices/journal.php [new file with mode: 0644]
www/admin/compta/exercices/modifier.php [new file with mode: 0644]
www/admin/compta/exercices/supprimer.php [new file with mode: 0644]
www/admin/compta/graph.php [new file with mode: 0644]
www/admin/compta/import.php [new file with mode: 0644]
www/admin/compta/index.php [new file with mode: 0644]
www/admin/compta/operations/index.php [new file with mode: 0644]
www/admin/compta/operations/membre.php [new file with mode: 0644]
www/admin/compta/operations/modifier.php [new file with mode: 0644]
www/admin/compta/operations/recherche_sql.php [new file with mode: 0644]
www/admin/compta/operations/saisir.php [new file with mode: 0644]
www/admin/compta/operations/supprimer.php [new file with mode: 0644]
www/admin/compta/operations/voir.php [new file with mode: 0644]
www/admin/compta/pie.php [new file with mode: 0644]
www/admin/config/_inc.php [new file with mode: 0644]
www/admin/config/donnees.php [new file with mode: 0644]
www/admin/config/import.php [new file with mode: 0644]
www/admin/config/index.php [new file with mode: 0644]
www/admin/config/membres.php [new file with mode: 0644]
www/admin/config/plugins.php [new file with mode: 0644]
www/admin/config/site.php [new file with mode: 0644]
www/admin/index.php [new file with mode: 0644]
www/admin/install.php [new file with mode: 0644]
www/admin/login.php [new file with mode: 0644]
www/admin/logout.php [new file with mode: 0644]
www/admin/membres/action.php [new file with mode: 0644]
www/admin/membres/ajouter.php [new file with mode: 0644]
www/admin/membres/cat_modifier.php [new file with mode: 0644]
www/admin/membres/cat_supprimer.php [new file with mode: 0644]
www/admin/membres/categories.php [new file with mode: 0644]
www/admin/membres/cotisations.php [new file with mode: 0644]
www/admin/membres/cotisations/ajout.php [new file with mode: 0644]
www/admin/membres/cotisations/gestion/modifier.php [new file with mode: 0644]
www/admin/membres/cotisations/gestion/rappel_modifier.php [new file with mode: 0644]
www/admin/membres/cotisations/gestion/rappel_supprimer.php [new file with mode: 0644]
www/admin/membres/cotisations/gestion/rappels.php [new file with mode: 0644]
www/admin/membres/cotisations/gestion/supprimer.php [new file with mode: 0644]
www/admin/membres/cotisations/index.php [new file with mode: 0644]
www/admin/membres/cotisations/rappels.php [new file with mode: 0644]
www/admin/membres/cotisations/supprimer.php [new file with mode: 0644]
www/admin/membres/cotisations/voir.php [new file with mode: 0644]
www/admin/membres/fiche.php [new file with mode: 0644]
www/admin/membres/import.php [new file with mode: 0644]
www/admin/membres/index.php [new file with mode: 0644]
www/admin/membres/message.php [new file with mode: 0644]
www/admin/membres/message_collectif.php [new file with mode: 0644]
www/admin/membres/modifier.php [new file with mode: 0644]
www/admin/membres/recherche.php [new file with mode: 0644]
www/admin/membres/recherche_sql.php [new file with mode: 0644]
www/admin/membres/supprimer.php [new file with mode: 0644]
www/admin/mes_cotisations.php [new file with mode: 0644]
www/admin/mes_infos.php [new file with mode: 0644]
www/admin/password.php [new file with mode: 0644]
www/admin/plugin.php [new file with mode: 0644]
www/admin/static/admin.css [new file with mode: 0644]
www/admin/static/bg00.png [new file with mode: 0644]
www/admin/static/bg01.png [new file with mode: 0644]
www/admin/static/code_editor.min.js [new file with mode: 0644]
www/admin/static/datepickr.css [new file with mode: 0644]
www/admin/static/datepickr.js [new file with mode: 0644]
www/admin/static/font/garradin.css [new file with mode: 0644]
www/admin/static/font/garradin.eot [new file with mode: 0644]
www/admin/static/font/garradin.svg [new file with mode: 0644]
www/admin/static/font/garradin.ttf [new file with mode: 0644]
www/admin/static/font/garradin.woff [new file with mode: 0644]
www/admin/static/garradin.png [new file with mode: 0644]
www/admin/static/gibberish-aes.min.js [new file with mode: 0644]
www/admin/static/global.js [new file with mode: 0644]
www/admin/static/handheld.css [new file with mode: 0644]
www/admin/static/loader.js [new file with mode: 0644]
www/admin/static/password.js [new file with mode: 0644]
www/admin/static/print.css [new file with mode: 0644]
www/admin/static/skel_editor.css [new file with mode: 0644]
www/admin/static/skel_editor.js [new file with mode: 0644]
www/admin/static/wiki-encryption.js [new file with mode: 0644]
www/admin/static/wikitoolbar.js [new file with mode: 0644]
www/admin/upgrade.php [new file with mode: 0644]
www/admin/wiki/_chercher_parent.php [new file with mode: 0644]
www/admin/wiki/_inc.php [new file with mode: 0644]
www/admin/wiki/chercher.php [new file with mode: 0644]
www/admin/wiki/creer.php [new file with mode: 0644]
www/admin/wiki/editer.php [new file with mode: 0644]
www/admin/wiki/historique.php [new file with mode: 0644]
www/admin/wiki/index.php [new file with mode: 0644]
www/admin/wiki/recent.php [new file with mode: 0644]
www/admin/wiki/supprimer.php [new file with mode: 0644]
www/index.php [new file with mode: 0644]
www/squelettes-dist/article.html [new file with mode: 0644]
www/squelettes-dist/atom.xml [new file with mode: 0644]
www/squelettes-dist/default.css [new file with mode: 0644]
www/squelettes-dist/entete.html [new file with mode: 0644]
www/squelettes-dist/pied.html [new file with mode: 0644]
www/squelettes-dist/rubrique.html [new file with mode: 0644]
www/squelettes-dist/sommaire.html [new file with mode: 0644]

diff --git a/.htaccess b/.htaccess
new file mode 100644 (file)
index 0000000..395cdf0
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,7 @@
+<IfModule mod_alias.c>
+    RedirectMatch 403 /include/
+    RedirectMatch 403 /cache/
+    RedirectMatch 403 /plugins/
+    RedirectMatch 403 /templates/
+    RedirectMatch 403 /*.sqlite
+</IfModule>
diff --git a/COPYING b/COPYING
new file mode 100644 (file)
index 0000000..dba13ed
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<http://www.gnu.org/licenses/>.
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..c11e982
--- /dev/null
+++ b/README
@@ -0,0 +1,33 @@
+Garradin - Gestionnaire d'association libre
+===========================================
+
+Inclus les bibliothèques suivantes :
+
+- Gibberish AES
+  https://github.com/mdp/gibberish-aes
+  Copyright : Mark Percival 2008 - http://markpercival.us
+  Licence : MIT
+
+- Countries - Liste des pays ISO 3166-1
+  Copyright : BohwaZ
+  Licence : Domaine public
+
+- Simple Diff PHP library
+  Copyright : BohwaZ 2009
+  Licence : GNU GPL v3
+
+- Garbage2xhtml - HTML cleaner
+  Copyright : BohwaZ 2006-2011
+  Licence : GNU AGPL v3
+
+- miniSkel - SPIP-like templates
+  Copyright : BohwaZ 2007-2012
+  Licence : GNU GPL v3
+
+- Passphrase - a PHP library to generate passphrases
+  Copyright : BohwaZ 2011-2012
+  Licence : WTFPL
+
+- Template_Lite
+  Copyright : 2003,2004,2005 by Paul Lockaby, 2005,2006 Mark Dickenson, 2005-2012 BohwaZ
+  Licence : GNU GPL v2.1
diff --git a/VERSION b/VERSION
new file mode 100644 (file)
index 0000000..b616048
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.6.2
diff --git a/config.dist.php b/config.dist.php
new file mode 100644 (file)
index 0000000..15ef961
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * Ce fichier représente un exemple des constantes de configuration
+ * disponibles pour Garradin.
+ */
+
+// Nécessaire pour situer les constantes dans le bon namespace
+namespace Garradin;
+
+// Connexion automatique à l'administration avec l'adresse e-mail donnée
+#const LOCAL_LOGIN = 'president@association.net';
+
+// Connexion automatique avec le numéro de membre indiqué
+// Défaut : false (connexion automatique désactivée)
+const LOCAL_LOGIN = 1;
+
+// Répertoire où est le code source de Garradin
+const ROOT = '/usr/share/garradin';
+
+// Répertoire où sont situées les données de Garradin
+// (incluant la base de données SQLite, le cache et les fichiers locaux)
+// Défaut : le même répertoire que le source
+const DATA_ROOT = '/var/www/garradin';
+
+// Emplacement de la base de données
+const DB_FILE = '/var/lib/sqlite/garradin.sqlite';
+
+// Adresse URI de la racine du site Garradin
+// (doit se terminer par un slash)
+// Défaut : découverte automatique à partir de SCRIPT_NAME
+const WWW_URI = '/garradin/';
+
+// Adresse URL HTTP(S) de Garradin
+// Défaut : découverte à partir de HTTP_HOST ou SERVER_NAME + WWW_URI
+define('Garradin\WWW_URL', 'http://garradin.net' . WWW_URI);
+
+// Emplacement de stockage des plugins
+define('Garradin\PLUGINS_ROOT', DATA_ROOT . '/plugins');
+
+// Plugins fixes qui ne peuvent être désinstallés (séparés par une virgule)
+const PLUGINS_SYSTEM = 'email,web';
+
+// 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
+const SHOW_ERRORS = true;
+
+// Envoi des erreurs par e-mail
+// Si rempli, 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
+const MAIL_ERRORS = false;
+
+// Utilisation de cron pour les tâches automatiques
+// Si "true" on s'attend à ce qu'une tâche automatisée appelle
+// le script cron.php à la racine toutes les 24 heures. Sinon Garradin
+// effectuera les actions automatiques quand quelqu'un se connecte à 
+// l'administration ou visite le site.
+// Défaut : false
+const USE_CRON = false;
diff --git a/cron.php b/cron.php
new file mode 100644 (file)
index 0000000..c57f9d3
--- /dev/null
+++ b/cron.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Garradin;
+
+require_once __DIR__ . '/include/init.php';
+
+// Exécution des tâches automatiques
+
+if ($config->get('frequence_sauvegardes') && $config->get('nombre_sauvegardes'))
+{
+       $s = new Sauvegarde;
+       $s->auto();
+}
+
+
+// Exécution des rappels automatiques
+$rappels = new Rappels;
+
+if ($rappels->countAll())
+{
+       $rappels->sendPending();
+}
+
+// Nettoyage du cache statique
+Static_Cache::clean();
\ No newline at end of file
diff --git a/include/class.champs_membres.php b/include/class.champs_membres.php
new file mode 100644 (file)
index 0000000..9c07ae5
--- /dev/null
@@ -0,0 +1,470 @@
+<?php
+
+namespace Garradin;
+
+class Champs_Membres
+{
+       protected $champs = null;
+
+       protected $types = [
+               'email'         =>      'Adresse E-Mail',
+               'url'           =>      'Adresse URL',
+               'checkbox'      =>      'Case à cocher',
+               'date'          =>      'Date',
+               'datetime'      =>      'Date et heure',
+               //'file'                =>      'Fichier',
+        'password'  =>  'Mot de passe',
+               'number'        =>      'Numéro',
+               'tel'           =>      'Numéro de téléphone',
+               'select'        =>      'Sélecteur à choix unique',
+        'multiple'  =>  'Sélecteur à choix multiple',
+               'country'       =>      'Sélecteur de pays',
+               'text'          =>      'Texte',
+               'textarea'      =>      'Texte multi-lignes',
+       ];
+
+    protected $text_types = [
+        'email',
+        'text',
+        'select',
+        'textarea',
+        'url',
+        'password',
+        'country'
+    ];
+
+    protected $config_fields = [
+        'type',
+        'title',
+        'help',
+        'editable',
+        'list_row',
+        'mandatory',
+        'private',
+        'options'
+    ];
+
+    static protected $presets = null;
+
+       public function __toString()
+       {
+               return utils::write_ini_string($this->champs);
+       }
+
+    public function toString()
+    {
+        return utils::write_ini_string($this->champs);
+    }
+
+       static public function importInstall()
+       {
+               $champs = parse_ini_file(ROOT . '/include/data/champs_membres.ini', true);
+        $champs = array_filter($champs, function ($row) { return !empty($row['install']); });
+        return new Champs_Membres($champs);
+       }
+
+    static public function importPresets()
+    {
+        if (is_null(self::$presets))
+        {
+            self::$presets = parse_ini_file(ROOT . '/include/data/champs_membres.ini', true);
+        }
+
+        return self::$presets;
+    }
+
+    static public function listUnusedPresets(Champs_Membres $champs)
+    {
+        return array_diff_key(self::importPresets(), $champs->getAll());
+    }
+
+       public function __construct($champs)
+       {
+               if ($champs instanceOf Champs_Membres)
+               {
+                       $this->champs = $champs->getAll();
+               }
+        elseif (is_array($champs))
+        {
+            foreach ($champs as $key=>&$config)
+            {
+                $this->_checkField($key, $config);
+            }
+
+            $this->champs = $champs;
+        }
+               else
+               {
+                       $champs = parse_ini_string((string)$champs, true);
+
+            foreach ($champs as $key=>&$config)
+            {
+                $this->_checkField($key, $config);
+            }
+
+            $this->champs = $champs;
+               }
+       }
+
+       public function getTypes()
+       {
+               return $this->types;
+       }
+
+       public function get($champ, $key = null)
+       {
+        if ($champ == 'id')
+        {
+            return ['title' => 'Numéro unique', 'type' => 'number'];
+        }
+
+        if (!array_key_exists($champ, $this->champs))
+            return null;
+
+        if ($key !== null)
+        {
+            if (array_key_exists($key, $this->champs[$champ]))
+                return $this->champs[$champ][$key];
+            else
+                return null;
+        }
+
+               return $this->champs[$champ];
+       }
+
+    public function isText($champ)
+    {
+        if (!array_key_exists($champ, $this->champs))
+            return null;
+
+        if (in_array($this->champs[$champ]['type'], $this->text_types))
+            return true;
+        else
+            return false;
+    }
+
+       public function getAll()
+       {
+        $this->champs['passe']['title'] = 'Mot de passe';
+               return $this->champs;
+       }
+
+    public function getList()
+    {
+        $champs = $this->champs;
+        unset($champs['passe']);
+        return $champs;
+    }
+
+    public function getFirst()
+    {
+        reset($this->champs);
+        return key($this->champs);
+    }
+
+    public function getListedFields()
+    {
+        $champs = $this->champs;
+
+        $champs = array_filter($champs, function ($a) {
+            return empty($a['list_row']) ? false : true;
+        });
+
+        uasort($champs, function ($a, $b) {
+            if ($a['list_row'] == $b['list_row'])
+                return 0;
+
+            return ($a['list_row'] > $b['list_row']) ? 1 : -1;
+        });
+
+        return $champs;
+    }
+
+    /**
+     * Vérifie la cohérence et la présence des bons éléments pour un champ
+     * @param  string $name     Nom du champ
+     * @param  array $config    Configuration du champ
+     * @return boolean true
+     */
+    protected function _checkField($name, &$config)
+    {
+        if (!preg_match('!^\w+(_\w+)*$!', $name))
+        {
+            throw new UserException('Le nom du champ est invalide.');
+        }
+
+        foreach ($config as $key=>&$value)
+        {
+            // Champ install non pris en compte
+            if ($key == 'install')
+            {
+                unset($config[$key]);
+                continue;
+            }
+
+            if (!in_array($key, $this->config_fields))
+            {
+                throw new \BadMethodCallException('Champ '.$key.' non valide.');
+            }
+
+            if ($key == 'editable' || $key == 'private' || $key == 'mandatory')
+            {
+                $value = (bool) (int) $value;
+            }
+            elseif ($key == 'list_row')
+            {
+                $value = (int) $value;
+            }
+            elseif ($key == 'help' || $key == 'title')
+            {
+                $value = trim((string) $value);
+            }
+            elseif ($key == 'options')
+            {
+                $value = (array) $value;
+
+                foreach ($value as $option_key=>$option_value)
+                {
+                    if (trim($option_value) == '')
+                    {
+                        unset($value[$option_key]);
+                    }
+                }
+            }
+        }
+
+        if (empty($config['title']) && $name != 'passe')
+        {
+            throw new UserException('Champ "'.$name.'" : Le titre est obligatoire.');
+        }
+
+        if (empty($config['type']) || !array_key_exists($config['type'], $this->types))
+        {
+            throw new UserException('Champ "'.$name.'" : Le type est vide ou non valide.');
+        }
+
+        if ($name == 'email' && $config['type'] != 'email')
+        {
+            throw new UserException('Le champ email ne peut être d\'un type différent de email.');
+        }
+
+        if ($name == 'passe' && $config['type'] != 'password')
+        {
+            throw new UserException('Le champ mot de passe ne peut être d\'un type différent de mot de passe.');
+        }
+
+        if (($config['type'] == 'multiple' || $config['type'] == 'select') && empty($config['options']))
+        {
+            throw new UserException('Le champ "'.$name.'" nécessite de comporter au moins une option possible.');
+        }
+
+        if (!array_key_exists('editable', $config))
+        {
+            $config['editable'] = false;
+        }
+
+        if (!array_key_exists('mandatory', $config))
+        {
+            $config['mandatory'] = false;
+        }
+
+        if (!array_key_exists('private', $config))
+        {
+            $config['private'] = false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Ajouter un nouveau champ
+     * @param string $name Nom du champ
+     * @param array $config Configuration du champ
+     * @return boolean true
+     */
+    public function add($name, $config)
+    {
+        if (!preg_match('!^[a-z0-9]+(_[a-z0-9]+)*$!', $name))
+        {
+            throw new UserException('Le nom du champ est invalide : ne sont acceptés que des lettres minuscules et chiffres.');
+        }
+        
+        $this->_checkField($name, $config);
+
+        $this->champs[$name] = $config;
+
+        return true;
+    }
+
+    /**
+     * Modifie un champ particulier
+     * @param string $champ Nom du champ
+     * @param string $key   Nom de la clé à modifier
+     * @param mixed  $value Valeur à affecter
+     * @return boolean true
+     */
+       public function set($champ, $key, $value)
+       {
+        if (!isset($this->champs[$champ]))
+        {
+            throw new \LogicException('Champ "'.$champ.'" inconnu.');
+        }
+
+        // Vérification
+        $config = $this->champs[$champ];
+        $config[$key] = $value;
+        $this->_checkField($champ, $config);
+
+               $this->champs[$champ] = $config;
+               return true;
+       }
+
+    /**
+     * Modifie les champs en interne en vérifiant que tout va bien
+     * @param array $champs Liste des champs
+     * @return boolean true
+     */
+    public function setAll($champs)
+    {
+        if (!array_key_exists('email', $champs))
+        {
+            throw new UserException('Le champ E-Mail ne peut être supprimé des fiches membres.');
+        }
+
+        if (!array_key_exists('passe', $champs))
+        {
+            throw new UserException('Le champ Mot de passe ne peut être supprimé des fiches membres.');
+        }
+
+        $config = Config::getInstance();
+
+        if (!array_key_exists($config->get('champ_identite'), $champs))
+        {
+            throw new UserException('Le champ '.$config->get('champ_identite')
+                .' est défini comme identité des membres et ne peut donc être supprimé des fiches membres.');
+        }
+
+        if (!array_key_exists($config->get('champ_identifiant'), $champs))
+        {
+            throw new UserException('Le champ '.$config->get('champ_identifiant')
+                .' est défini comme identifiant à la connexion et ne peut donc être supprimé des fiches membres.');
+        }
+
+        foreach ($champs as $name=>&$config)
+        {
+            $this->_checkField($name, $config);
+        }
+
+        $this->champs = $champs;
+
+        return true;
+    }
+
+    /**
+     * Enregistre les changements de champs en base de données
+     * @param  boolean $enable_copy Recopier les anciennes champs dans les nouveaux ?
+     * @return boolean true
+     */
+    public function save($enable_copy = true)
+    {
+       $db = DB::getInstance();
+       $config = Config::getInstance();
+
+       // Champs à créer
+       $create = [
+               'id INTEGER PRIMARY KEY, -- Numéro attribué automatiquement',
+               'id_categorie INTEGER NOT NULL, -- Numéro de catégorie',
+            'date_connexion TEXT NULL, -- Date de dernière connexion',
+            'date_inscription TEXT NOT NULL DEFAULT CURRENT_DATE, -- Date d\'inscription',
+       ];
+
+        $create_keys = [
+            'FOREIGN KEY (id_categorie) REFERENCES membres_categories (id)'
+        ];
+
+       // Champs à recopier
+       $copy = [
+               'id',
+               'id_categorie',
+            'date_connexion',
+            'date_inscription',
+       ];
+
+        $anciens_champs = $config->get('champs_membres');
+       $anciens_champs = is_null($anciens_champs) ? $this->champs : $anciens_champs->getAll();
+
+       foreach ($this->champs as $key=>$cfg)
+       {
+               if ($cfg['type'] == 'number')
+                       $type = 'FLOAT';
+               elseif ($cfg['type'] == 'multiple' || $cfg['type'] == 'checkbox')
+                       $type = 'INTEGER';
+               elseif ($cfg['type'] == 'file')
+                       $type = 'BLOB';
+               else
+                       $type = 'TEXT';
+
+               $line = $key . ' ' . $type . ',';
+
+            if (!empty($cfg['title']))
+            {
+                $line .= ' -- ' . str_replace(["\n", "\r"], '', $cfg['title']);
+            }
+
+            $create[] = $line;
+
+               if (array_key_exists($key, $anciens_champs))
+               {
+                       $copy[] = $key;
+               }
+       }
+
+       $create = array_merge($create, $create_keys);
+
+       $create = 'CREATE TABLE membres_tmp (' . "\n\t" . implode("\n\t", $create) . "\n);";
+       $copy = 'INSERT INTO membres_tmp (' . implode(', ', $copy) . ') SELECT ' . implode(', ', $copy) . ' FROM membres;';
+
+       $db->exec('PRAGMA foreign_keys = OFF;');
+       $db->exec('BEGIN;');
+       $db->exec($create);
+       
+       if ($enable_copy) {
+               $db->exec($copy);
+       }
+       
+        $db->exec('DROP TABLE IF EXISTS membres;');
+       $db->exec('ALTER TABLE membres_tmp RENAME TO membres;');
+        $db->exec('CREATE INDEX membres_id_categorie ON membres (id_categorie);'); // Index
+
+        if ($config->get('champ_identifiant'))
+        {
+            // Mettre les champs identifiant vides à NULL pour pouvoir créer un index unique
+            $db->exec('UPDATE membres SET '.$config->get('champ_identifiant').' = NULL 
+                WHERE '.$config->get('champ_identifiant').' = "";');
+
+            // Création de l'index unique
+            $db->exec('CREATE UNIQUE INDEX membres_identifiant ON membres ('.$config->get('champ_identifiant').');');
+        }
+
+        // Création des index pour les champs affichés dans la liste des membres
+        $listed_fields = array_keys($this->getListedFields());
+        foreach ($listed_fields as $field)
+        {
+            if ($field === $config->get('champ_identifiant'))
+            {
+                // Il y a déjà un index
+                continue;
+            }
+
+            $db->exec('CREATE INDEX membres_liste_' . $field . ' ON membres (' . $field . ');');
+        }
+
+       $db->exec('END;');
+       $db->exec('PRAGMA foreign_keys = ON;');
+
+       $config->set('champs_membres', $this);
+       $config->save();
+
+       return true;
+    }
+}
\ No newline at end of file
diff --git a/include/class.compta_categories.php b/include/class.compta_categories.php
new file mode 100644 (file)
index 0000000..0798be2
--- /dev/null
@@ -0,0 +1,126 @@
+<?php
+
+namespace Garradin;
+
+class Compta_Categories
+{
+    const DEPENSES = -1;
+    const RECETTES = 1;
+    const AUTRES = 0;
+
+    public function importCategories()
+    {
+        $db = DB::getInstance();
+        $db->exec(file_get_contents(ROOT . '/include/data/categories_comptables.sql'));
+    }
+
+    public function add($data)
+    {
+        $this->_checkFields($data);
+
+        $db = DB::getInstance();
+
+        if (empty($data['compte']) || !trim($data['compte']))
+        {
+            throw new UserException('Le compte associé ne peut rester vide.');
+        }
+
+        $data['compte'] = trim($data['compte']);
+
+        if (!$db->simpleQuerySingle('SELECT 1 FROM compta_comptes WHERE id = ?;', false, $data['compte']))
+        {
+            throw new UserException('Le compte associé n\'existe pas.');
+        }
+
+        if (!isset($data['type']) ||
+            ($data['type'] != self::DEPENSES && $data['type'] != self::RECETTES))
+        {
+            // Catégories "autres" pas possibles pour le moment
+            throw new UserException('Type de catégorie inconnu.');
+        }
+
+        $db->simpleInsert('compta_categories', [
+            'intitule'  =>  $data['intitule'],
+            'description'=> $data['description'],
+            'compte'    =>  $data['compte'],
+            'type'      =>  (int)$data['type'],
+        ]);
+
+        return $db->lastInsertRowId();
+    }
+
+    public function edit($id, $data)
+    {
+        $this->_checkFields($data);
+
+        $db = DB::getInstance();
+
+        $db->simpleUpdate('compta_categories',
+            [
+                'intitule'  =>  $data['intitule'],
+                'description'=> $data['description'],
+            ],
+            'id = \''.$db->escapeString(trim($id)).'\'');
+
+        return true;
+    }
+
+    public function delete($id)
+    {
+        $db = DB::getInstance();
+
+        // Ne pas supprimer une catégorie qui est utilisée !
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_categorie = ? LIMIT 1;', false, $id))
+        {
+            throw new UserException('Cette catégorie ne peut être supprimée car des opérations comptables y sont liées.');
+        }
+
+        $db->simpleExec('DELETE FROM compta_categories WHERE id = ?;', $id);
+
+        return true;
+    }
+
+    public function get($id)
+    {
+        $db = DB::getInstance();
+        return $db->simpleQuerySingle('SELECT * FROM compta_categories WHERE id = ?;', true, (int)$id);
+    }
+
+    public function getList($type = null)
+    {
+        $db = DB::getInstance();
+        $type = is_null($type) ? '' : 'cat.type = '.(int)$type;
+        return $db->simpleStatementFetchAssocKey('
+            SELECT cat.id, cat.*, cc.libelle AS compte_libelle
+            FROM compta_categories AS cat INNER JOIN compta_comptes AS cc
+                ON cc.id = cat.compte
+            WHERE '.$type.' ORDER BY cat.intitule;', SQLITE3_ASSOC);
+    }
+
+    public function listMoyensPaiement()
+    {
+        $db = DB::getInstance();
+        return $db->simpleStatementFetchAssocKey('SELECT code, nom FROM compta_moyens_paiement ORDER BY nom COLLATE NOCASE;');
+    }
+
+    public function getMoyenPaiement($code)
+    {
+        $db = DB::getInstance();
+        return $db->simpleQuerySingle('SELECT nom FROM compta_moyens_paiement WHERE code = ?;', false, $code);
+    }
+
+    protected function _checkFields(&$data)
+    {
+        if (empty($data['intitule']) || !trim($data['intitule']))
+        {
+            throw new UserException('L\'intitulé ne peut rester vide.');
+        }
+
+        $data['intitule'] = trim($data['intitule']);
+        $data['description'] = isset($data['description']) ? trim($data['description']) : '';
+
+        return true;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.compta_comptes.php b/include/class.compta_comptes.php
new file mode 100644 (file)
index 0000000..02021a2
--- /dev/null
@@ -0,0 +1,325 @@
+<?php
+
+namespace Garradin;
+
+class Compta_Comptes
+{
+    const CAISSE = 530;
+
+    const PASSIF = 0x01;
+    const ACTIF = 0x02;
+    const PRODUIT = 0x04;
+    const CHARGE = 0x08;
+
+    public function importPlan()
+    {
+        $plan = json_decode(file_get_contents(ROOT . '/include/data/plan_comptable.json'), true);
+
+        $db = DB::getInstance();
+        $db->exec('BEGIN;');
+        $ids = [];
+
+        foreach ($plan as $id=>$compte)
+        {
+            $ids[] = $id;
+
+            if ($db->simpleQuerySingle('SELECT 1 FROM compta_comptes WHERE id = ?;', false, $id))
+            {
+                $db->simpleUpdate('compta_comptes', [
+                    'parent'    =>  $compte['parent'],
+                    'libelle'   =>  $compte['nom'],
+                    'position'  =>  $compte['position'],
+                    'plan_comptable' => 1,
+                ], 'id = \''.$db->escapeString($id).'\'');
+            }
+            else
+            {
+                $db->simpleInsert('compta_comptes', [
+                    'id'        =>  $id,
+                    'parent'    =>  $compte['parent'],
+                    'libelle'   =>  $compte['nom'],
+                    'position'  =>  $compte['position'],
+                    'plan_comptable' => 1,
+                ]);
+            }
+        }
+
+        $db->exec('DELETE FROM compta_comptes WHERE id NOT IN(\''.implode('\', \'', $ids).'\') AND plan_comptable = 1;');
+
+        $db->exec('END;');
+
+        return true;
+    }
+
+    public function add($data)
+    {
+        $this->_checkFields($data, true);
+
+        $db = DB::getInstance();
+
+        if (empty($data['id']))
+        {
+            $new_id = $data['parent'];
+            $nb_sous_comptes = $db->simpleQuerySingle('SELECT COUNT(*) FROM compta_comptes WHERE parent = ?;', false, $new_id);
+
+            // Pas plus de 26 sous-comptes par compte, parce que l'alphabet s'arrête à 26 lettres
+            if ($nb_sous_comptes >= 26)
+            {
+                throw new UserException('Nombre de sous-comptes maximal atteint pour ce compte parent-ci.');
+            }
+
+            $new_id .= chr(65+(int)$nb_sous_comptes);
+        }
+        else
+        {
+            $new_id = $data['id'];
+        }
+
+        if (isset($data['position']))
+        {
+            $position = (int) $data['position'];
+        }
+        else
+        {
+            $position = $db->simpleQuerySingle('SELECT position FROM compta_comptes WHERE id = ?;', false, $data['parent']);
+        }
+
+        $db->simpleInsert('compta_comptes', [
+            'id'        =>  $new_id,
+            'libelle'   =>  trim($data['libelle']),
+            'parent'    =>  $data['parent'],
+            'plan_comptable' => 0,
+            'position'  =>  (int)$position,
+        ]);
+
+        return $new_id;
+    }
+
+    public function edit($id, $data)
+    {
+        $db = DB::getInstance();
+
+        // Vérification que l'on peut éditer ce compte
+        if ($db->simpleQuerySingle('SELECT plan_comptable FROM compta_comptes WHERE id = ?;', false, $id))
+        {
+            throw new UserException('Ce compte fait partie du plan comptable et n\'est pas modifiable.');
+        }
+
+        if (isset($data['position']) && empty($data['position']))
+        {
+            throw new UserException('Aucune position du compte n\'a été indiquée.');
+        }
+
+        $this->_checkFields($data);
+
+        $update = [
+            'libelle'   =>  trim($data['libelle']),
+        ];
+
+        if (isset($data['position']))
+        {
+            $update['position'] = (int) trim($data['position']);
+        }
+
+        $db->simpleUpdate('compta_comptes', $update, 'id = \''.$db->escapeString(trim($id)).'\'');
+
+        return true;
+    }
+
+    public function delete($id)
+    {
+        $db = DB::getInstance();
+
+        // Ne pas supprimer un compte qui est utilisé !
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE compte_debit = ? OR compte_debit = ? LIMIT 1;', false, $id, $id))
+        {
+            throw new UserException('Ce compte ne peut être supprimé car des opérations comptables y sont liées.');
+        }
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_comptes_bancaires WHERE id = ? LIMIT 1;', false, $id))
+        {
+            throw new UserException('Ce compte ne peut être supprimé car il est lié à un compte bancaire.');
+        }
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_categories WHERE compte = ? LIMIT 1;', false, $id))
+        {
+            throw new UserException('Ce compte ne peut être supprimé car des catégories y sont liées.');
+        }
+
+        $db->simpleExec('DELETE FROM compta_comptes WHERE id = ?;', trim($id));
+
+        return true;
+    }
+
+    /**
+     * Peut-on supprimer ce compte ? (OUI s'il n'a pas d'écriture liée)
+     * @param  string $id Numéro du compte
+     * @return boolean TRUE si le compte n'a pas d'écriture liée
+     */
+    public function canDelete($id)
+    {
+        $db = DB::getInstance();
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal
+                WHERE compte_debit = ? OR compte_debit = ? LIMIT 1;', false, $id, $id))
+        {
+            return false;
+        }
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_categories WHERE compte = ? LIMIT 1;', false, $id))
+        {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Peut-on désactiver ce compte ? (OUI s'il n'a pas d'écriture liée dans l'exercice courant)
+     * @param  string $id Numéro du compte
+     * @return boolean TRUE si le compte n'a pas d'écriture liée dans l'exercice courant
+     */
+    public function canDisable($id)
+    {
+        $db = DB::getInstance();
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal
+                WHERE id_exercice = (SELECT id FROM compta_exercices WHERE cloture = 0 LIMIT 1) 
+                AND (compte_debit = ? OR compte_debit = ?) LIMIT 1;', false, $id, $id))
+        {
+            return false;
+        }
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_categories WHERE compte = ? LIMIT 1;', false, $id))
+        {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Désactiver un compte
+     * Le compte ne sera plus utilisable pour les écritures ou les catégories mais restera en base de données
+     * @param  string $id Numéro du compte
+     * @return boolean TRUE si la désactivation a fonctionné, une exception utilisateur si
+     * la désactivation n'est pas possible.
+     */
+    public function disable($id)
+    {
+        $db = DB::getInstance();
+        
+        // Ne pas désactiver un compte utilisé dans l'exercice courant
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal
+                WHERE id_exercice = (SELECT id FROM compta_exercices WHERE cloture = 0 LIMIT 1) 
+                AND (compte_debit = ? OR compte_debit = ?) LIMIT 1;', false, $id, $id))
+        {
+            throw new UserException('Ce compte ne peut être désactivé car des écritures y sont liées sur l\'exercice courant. '
+                . 'Il faut supprimer ou ré-attribuer ces écritures avant de pouvoir supprimer le compte.');
+        }
+
+        // Ne pas désactiver un compte utilisé pour une catégorie
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_categories WHERE compte = ? LIMIT 1;', false, $id))
+        {
+            throw new UserException('Ce compte ne peut être désactivé car des catégories y sont liées.');
+        }
+
+        return $db->simpleUpdate('compta_comptes', ['desactive' => 1], 'id = \''.$db->escapeString(trim($id)).'\'');
+    }
+
+    public function get($id)
+    {
+        $db = DB::getInstance();
+        return $db->simpleQuerySingle('SELECT * FROM compta_comptes WHERE id = ?;', true, trim($id));
+    }
+
+    public function getList($parent = 0)
+    {
+        $db = DB::getInstance();
+        return $db->simpleStatementFetchAssocKey('SELECT id, * FROM compta_comptes WHERE parent = ? ORDER BY id;', SQLITE3_ASSOC, $parent);
+    }
+
+    public function getListAll($parent = 0)
+    {
+        $db = DB::getInstance();
+        return $db->queryFetchAssoc('SELECT id, libelle FROM compta_comptes ORDER BY id;');
+    }
+
+    public function listTree($parent = 0, $include_children = true)
+    {
+        $db = DB::getInstance();
+
+        if ($include_children)
+        {
+            $parent = $parent ? 'WHERE parent LIKE \''.$db->escapeString($parent).'%\' ' : '';
+        }
+        else
+        {
+            $parent = $parent ? 'WHERE parent = \''.$db->escapeString($parent).'\' ' : 'WHERE parent = 0';
+        }
+
+        return $db->simpleStatementFetch('SELECT * FROM compta_comptes '.$parent.' ORDER BY id;');
+    }
+
+    protected function _checkFields(&$data, $force_parent_check = false)
+    {
+        $db = DB::getInstance();
+
+        if (empty($data['libelle']) || !trim($data['libelle']))
+        {
+            throw new UserException('Le libellé ne peut rester vide.');
+        }
+
+        $data['libelle'] = trim($data['libelle']);
+
+        if (isset($data['id']))
+        {
+            $force_parent_check = true;
+            $data['id'] = trim($data['id']);
+
+            if ($db->simpleQuerySingle('SELECT 1 FROM compta_comptes WHERE id = ?;', false, $data['id']))
+            {
+                throw new UserException('Le compte numéro '.$data['id'].' existe déjà.');
+            }
+        }
+
+        if (isset($data['parent']) || $force_parent_check)
+        {
+            if (empty($data['parent']) && !trim($data['parent']))
+            {
+                throw new UserException('Le compte ne peut pas ne pas avoir de compte parent.');
+            }
+
+            if (!($id = $db->simpleQuerySingle('SELECT id FROM compta_comptes WHERE id = ?;', false, $data['parent'])))
+            {
+                throw new UserException('Le compte parent indiqué n\'existe pas.');
+            }
+
+            $data['parent'] = trim($id);
+        }
+
+        if (isset($data['id']))
+        {
+            if (strncmp($data['id'], $data['parent'], strlen($data['parent'])) !== 0)
+            {
+                throw new UserException('Le compte '.$data['id'].' n\'est pas un sous-compte de '.$data['parent'].'.');
+            }
+        }
+
+        return true;
+    }
+
+    public function getPositions()
+    {
+        return [
+            self::ACTIF     =>  'Actif',
+            self::PASSIF    =>  'Passif',
+            self::ACTIF | self::PASSIF      =>  'Actif ou passif (déterminé automatiquement au bilan selon le solde du compte)',
+            self::CHARGE    =>  'Charge',
+            self::PRODUIT   =>  'Produit',
+            self::CHARGE | self::PRODUIT    =>  'Charge et produit',
+        ];
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.compta_comptes_bancaires.php b/include/class.compta_comptes_bancaires.php
new file mode 100644 (file)
index 0000000..1ac1213
--- /dev/null
@@ -0,0 +1,164 @@
+<?php
+
+namespace Garradin;
+
+class Compta_Comptes_Bancaires extends Compta_Comptes
+{
+    const NUMERO_PARENT_COMPTES = 512;
+
+    public function add($data)
+    {
+        $db = DB::getInstance();
+
+        $data['parent'] = self::NUMERO_PARENT_COMPTES;
+        $data['id'] = null;
+
+        $this->_checkBankFields($data);
+
+        $new_id = parent::add($data);
+
+        $db->simpleInsert('compta_comptes_bancaires', [
+            'id'        =>  $new_id,
+            'banque'    =>  $data['banque'],
+            'iban'      =>  $data['iban'],
+            'bic'       =>  $data['bic'],
+        ]);
+
+        return $new_id;
+    }
+
+    public function edit($id, $data)
+    {
+        $db = DB::getInstance();
+
+        if (!$db->simpleQuerySingle('SELECT 1 FROM compta_comptes_bancaires WHERE id = ?;', false, $id))
+        {
+            throw new UserException('Ce compte n\'est pas un compte bancaire.');
+        }
+
+        $this->_checkBankFields($data);
+        $result = parent::edit($id, $data);
+
+        if (!$result)
+        {
+            return $result;
+        }
+
+        $db->simpleUpdate('compta_comptes_bancaires', [
+            'banque'    =>  $data['banque'],
+            'iban'      =>  $data['iban'],
+            'bic'       =>  $data['bic'],
+        ], 'id = \''.$db->escapeString(trim($id)).'\'');
+
+        return true;
+    }
+
+    /**
+     * Supprime un compte bancaire
+     * La suppression sera refusée si le compte est utilisé dans l'exercice en cours
+     * ou dans une catégorie.
+     * Le compte bancaire sera supprimé et le compte au plan comptable seulement désactivé
+     * si le compte est utilisé dans un exercice précédent.
+     *
+     * La désactivation d'un compte fait qu'il n'est plus utilisable dans l'exercice courant
+     * ou les exercices suivants, mais il est possible de le réactiver.
+     * @param  string $id  Numéro du compte
+     * @return boolean     TRUE si la suppression ou désactivation a été effectuée, une exception ou FALSE sinon
+     */
+    public function delete($id)
+    {
+        $db = DB::getInstance();
+        if (!$db->simpleQuerySingle('SELECT 1 FROM compta_comptes_bancaires WHERE id = ?;', false, trim($id)))
+        {
+            throw new UserException('Ce compte n\'est pas un compte bancaire.');
+        }
+
+        // Ne pas supprimer/désactiver un compte qui est utilisé dans l'exercice courant
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal
+                WHERE id_exercice = (SELECT id FROM compta_exercices WHERE cloture = 0 LIMIT 1) 
+                AND (compte_debit = ? OR compte_debit = ?) LIMIT 1;', false, $id, $id))
+        {
+            throw new UserException('Ce compte ne peut être supprimé car des écritures y sont liées sur l\'exercice courant. '
+                . 'Il faut supprimer ou ré-attribuer ces écritures avant de pouvoir supprimer le compte.');
+        }
+
+        // Il n'est pas possible de supprimer ou désactiver un compte qui est lié à des catégories
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_categories WHERE compte = ? LIMIT 1;', false, $id))
+        {
+            throw new UserException('Ce compte ne peut être supprimé car des catégories y sont liées. '
+                . 'Merci de supprimer ou modifier les catégories liées avant de le supprimer.');
+        }
+
+        $db->simpleExec('DELETE FROM compta_comptes_bancaires WHERE id = ?;', trim($id));
+
+        try {
+            $return = parent::delete($id);
+        }
+        catch (UserException $e) {
+            // Impossible de supprimer car des opérations y sont encore liées
+            // sur les exercices précédents, alors on le désactive
+            $return = parent::disable($id);
+        }
+
+        return $return;
+    }
+
+    public function get($id)
+    {
+        $db = DB::getInstance();
+        return $db->simpleQuerySingle('SELECT * FROM compta_comptes AS c
+            INNER JOIN compta_comptes_bancaires AS cc
+            ON c.id = cc.id
+            WHERE c.id = ?;', true, $id);
+    }
+
+    public function getList($parent = false)
+    {
+        $db = DB::getInstance();
+        return $db->simpleStatementFetchAssocKey('SELECT c.id AS id, * FROM compta_comptes AS c
+            INNER JOIN compta_comptes_bancaires AS cc ON c.id = cc.id
+            WHERE c.parent = '.self::NUMERO_PARENT_COMPTES.' ORDER BY c.id;');
+    }
+
+    protected function _checkBankFields(&$data)
+    {
+        if (empty($data['banque']) || !trim($data['banque']))
+        {
+            throw new UserException('Le nom de la banque ne peut rester vide.');
+        }
+
+        if (empty($data['bic']))
+        {
+            $data['bic'] = '';
+        }
+        else
+        {
+            $data['bic'] = trim(strtoupper($data['bic']));
+            $data['bic'] = preg_replace('![^\dA-Z]!', '', $data['bic']);
+
+            if (!utils::checkBIC($data['bic']))
+            {
+                throw new UserException('Code BIC/SWIFT invalide.');
+            }
+        }
+
+        if (empty($data['iban']))
+        {
+            $data['iban'] = '';
+        }
+        else
+        {
+            $data['iban'] = trim(strtoupper($data['iban']));
+            $data['iban'] = preg_replace('![^\dA-Z]!', '', $data['iban']);
+
+            if (!utils::checkIBAN($data['iban']))
+            {
+                throw new UserException('Code IBAN invalide.');
+            }
+        }
+
+        return true;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.compta_exercices.php b/include/class.compta_exercices.php
new file mode 100644 (file)
index 0000000..02edb14
--- /dev/null
@@ -0,0 +1,569 @@
+<?php
+
+namespace Garradin;
+
+class Compta_Exercices
+{
+    public function add($data)
+    {
+        $this->_checkFields($data);
+
+        $db = DB::getInstance();
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_exercices WHERE
+            (debut <= :debut AND fin >= :debut) OR (debut <= :fin AND fin >= :fin);', false,
+            ['debut' => $data['debut'], 'fin' => $data['fin']]))
+        {
+            throw new UserException('La date de début ou de fin se recoupe avec un autre exercice.');
+        }
+
+        if ($db->querySingle('SELECT 1 FROM compta_exercices WHERE cloture = 0;'))
+        {
+            throw new UserException('Il n\'est pas possible de créer un nouvel exercice tant qu\'il existe un exercice non-clôturé.');
+        }
+
+        $db->simpleInsert('compta_exercices', [
+            'libelle'   =>  trim($data['libelle']),
+            'debut'     =>  $data['debut'],
+            'fin'       =>  $data['fin'],
+        ]);
+
+        return $db->lastInsertRowId();
+    }
+
+    public function edit($id, $data)
+    {
+        $db = DB::getInstance();
+
+        $this->_checkFields($data);
+
+        // Evitons que les exercices se croisent
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_exercices WHERE id != :id AND
+            ((debut <= :debut AND fin >= :debut) OR (debut <= :fin AND fin >= :fin));', false,
+            ['debut' => $data['debut'], 'fin' => $data['fin'], 'id' => (int) $id]))
+        {
+            throw new UserException('La date de début ou de fin se recoupe avec un autre exercice.');
+        }
+
+        // On vérifie qu'on ne va pas mettre des opérations en dehors de tout exercice
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_exercice = ?
+            AND date < ? LIMIT 1;', false, (int)$id, $data['debut']))
+        {
+            throw new UserException('Des opérations de cet exercice ont une date antérieure à la date de début de l\'exercice.');
+        }
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_exercice = ?
+            AND date > ? LIMIT 1;', false, (int)$id, $data['fin']))
+        {
+            throw new UserException('Des opérations de cet exercice ont une date postérieure à la date de fin de l\'exercice.');
+        }
+
+        $db->simpleUpdate('compta_exercices', [
+            'libelle'   =>  trim($data['libelle']),
+            'debut'     =>  $data['debut'],
+            'fin'       =>  $data['fin'],
+        ], 'id = \''.(int)$id.'\'');
+
+        return true;
+    }
+
+    /**
+     * Clôturer un exercice et en ouvrir un nouveau
+     * Le report à nouveau n'est pas effectué automatiquement par cette fonction, voir doReports pour ça.
+     * @param  integer  $id     ID de l'exercice à clôturer
+     * @param  string   $end    Date de clôture de l'exercice au format Y-m-d
+     * @return integer          L'ID du nouvel exercice créé
+     */
+    public function close($id, $end)
+    {
+        $db = DB::getInstance();
+
+        if (!utils::checkDate($end))
+        {
+            throw new UserException('Date de fin vide ou invalide.');
+        }
+
+        $db->exec('BEGIN;');
+
+        // Clôture de l'exercice
+        $db->simpleUpdate('compta_exercices', [
+            'cloture'   =>  1,
+            'fin'       =>  $end,
+        ], 'id = \''.(int)$id.'\'');
+
+        // Date de début du nouvel exercice : lendemain de la clôture du précédent exercice
+        $new_begin = utils::modifyDate($end, '+1 day');
+
+        // Date de fin du nouvel exercice : un an moins un jour après l'ouverture
+        $new_end = utils::modifyDate($new_begin, '+1 year -1 day');
+
+        // Enfin sauf s'il existe déjà des opérations après cette date, auquel cas la date de fin
+        // est fixée à la date de la dernière opération, ceci pour ne pas avoir d'opération
+        // orpheline d'exercice
+        $last = $db->simpleQuerySingle('SELECT date FROM compta_journal WHERE id_exercice = ? AND date >= ? ORDER BY date DESC LIMIT 1;', false, $id, $new_end);
+        $new_end = $last ?: $new_end;
+
+        // Création du nouvel exercice
+        $new_id = $this->add([
+            'debut'     =>  $new_begin,
+            'fin'       =>  $new_end,
+            'libelle'   =>  'Nouvel exercice'
+        ]);
+
+        // Ré-attribution des opérations de l'exercice à clôturer qui ne sont pas dans son
+        // intervale au nouvel exercice
+        $db->simpleExec('UPDATE compta_journal SET id_exercice = ? WHERE id_exercice = ? AND date >= ?;',
+            $new_id, $id, $new_begin);
+
+        $db->exec('END;');
+
+        return $new_id;
+    }
+
+    /**
+     * Créer les reports à nouveau issus de l'exercice $old_id dans le nouvel exercice courant
+     * @param  integer $old_id  ID de l'ancien exercice
+     * @param  integer $new_id  ID du nouvel exercice
+     * @param  string  $date    Date Y-m-d donnée aux opérations créées
+     * @return boolean          true si succès
+     */
+    public function doReports($old_id, $date)
+    {
+        $db = DB::getInstance();
+
+        $db->exec('BEGIN;');
+
+        $this->solderResultat($old_id, $date);
+
+        $report_crediteur = 110;
+        $report_debiteur  = 119;
+
+        // Récupérer chacun des comptes de bilan et leurs soldes (uniquement les classes 1 à 5)
+        $statement = $db->simpleStatement('SELECT compta_comptes.id AS compte, compta_comptes.position AS position,
+            COALESCE((SELECT SUM(montant) FROM compta_journal WHERE compte_debit = compta_comptes.id AND id_exercice = :id), 0)
+            - COALESCE((SELECT SUM(montant) FROM compta_journal WHERE compte_credit = compta_comptes.id AND id_exercice = :id), 0) AS solde
+            FROM compta_comptes 
+            INNER JOIN compta_journal ON compta_comptes.id = compta_journal.compte_debit 
+                OR compta_comptes.id = compta_journal.compte_credit
+            WHERE id_exercice = :id AND solde != 0 AND CAST(substr(compta_comptes.id, 1, 1) AS INTEGER) <= 5
+            GROUP BY compta_comptes.id;', ['id' => $old_id]);
+
+        $diff = 0;
+        $journal = new Compta_Journal;
+
+        while ($row = $statement->fetchArray(SQLITE3_ASSOC))
+        {
+            $solde = ($row['position'] & Compta_Comptes::ACTIF) ? abs($row['solde']) : -abs($row['solde']);
+            $solde = round($solde, 2);
+
+            $diff += $solde;
+
+            if (empty($solde))
+            {
+                continue;
+            }
+
+            // Chaque solde de compte est reporté dans le nouvel exercice
+            $journal->add([
+                'libelle'       =>  'Report à nouveau',
+                'date'          =>  $date,
+                'montant'       =>  abs($solde),
+                'compte_debit'  =>  ($solde < 0 ? NULL : $row['compte']),
+                'compte_credit' =>  ($solde > 0 ? NULL : $row['compte']),
+                'remarques'     =>  'Report de solde créé automatiquement à la clôture de l\'exercice précédent',
+            ]);
+        }
+        
+        // FIXME utiliser $diff pour équilibrer
+
+        $db->exec('END;');
+
+        return true;
+    }
+
+    /**
+     * Solder les comptes de charge et de produits de l'exercice N 
+     * et les inscrire au résultat de l'exercice N+1
+     * @param  integer  $exercice   ID de l'exercice à solder
+     * @param  string   $date       Date de début de l'exercice Y-m-d
+     * @return boolean              true en cas de succès
+     */
+    public function solderResultat($exercice, $date)
+    {
+        $db = DB::getInstance();
+
+        $resultat_excedent = 120;
+        $resultat_debiteur = 129;
+
+        $resultat = $this->getCompteResultat($exercice);
+        $resultat = $resultat['resultat'];
+
+        if ($resultat != 0)
+        {
+            $journal = new Compta_Journal;
+            $journal->add([
+                'libelle'   =>  'Résultat de l\'exercice précédent',
+                'date'      =>  $date,
+                'montant'   =>  abs($resultat),
+                'compte_debit'  =>  $resultat < 0 ? 129 : NULL,
+                'compte_credit' =>  $resultat > 0 ? 120 : NULL,
+            ]);
+        }
+
+        return true;
+    }
+    
+    public function delete($id)
+    {
+        $db = DB::getInstance();
+
+        // Ne pas supprimer un compte qui est utilisé !
+        if ($db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_exercice = ? LIMIT 1;', false, $id))
+        {
+            throw new UserException('Cet exercice ne peut être supprimé car des opérations comptables y sont liées.');
+        }
+
+        $db->simpleExec('DELETE FROM compta_exercices WHERE id = ?;', (int)$id);
+
+        return true;
+    }
+
+    public function get($id)
+    {
+        $db = DB::getInstance();
+        return $db->simpleQuerySingle('SELECT *, strftime(\'%s\', debut) AS debut,
+            strftime(\'%s\', fin) AS fin FROM compta_exercices WHERE id = ?;', true, (int)$id);
+    }
+
+    public function getCurrent()
+    {
+        $db = DB::getInstance();
+        return $db->querySingle('SELECT *, strftime(\'%s\', debut) AS debut, strftime(\'%s\', fin) FROM compta_exercices
+            WHERE cloture = 0 LIMIT 1;', true);
+    }
+
+    public function getCurrentId()
+    {
+        $db = DB::getInstance();
+        return $db->querySingle('SELECT id FROM compta_exercices WHERE cloture = 0 LIMIT 1;');
+    }
+
+    public function getList()
+    {
+        $db = DB::getInstance();
+        return $db->simpleStatementFetchAssocKey('SELECT id, *, strftime(\'%s\', debut) AS debut,
+            strftime(\'%s\', fin) AS fin,
+            (SELECT COUNT(*) FROM compta_journal WHERE id_exercice = compta_exercices.id) AS nb_operations
+            FROM compta_exercices ORDER BY fin DESC;', SQLITE3_ASSOC);
+    }
+
+    protected function _checkFields(&$data)
+    {
+        if (empty($data['libelle']) || !trim($data['libelle']))
+        {
+            throw new UserException('Le libellé ne peut rester vide.');
+        }
+
+        $data['libelle'] = trim($data['libelle']);
+
+        if (empty($data['debut']) || !checkdate(substr($data['debut'], 5, 2), substr($data['debut'], 8, 2), substr($data['debut'], 0, 4)))
+        {
+            throw new UserException('Date de début vide ou invalide.');
+        }
+
+        if (empty($data['fin']) || !checkdate(substr($data['fin'], 5, 2), substr($data['fin'], 8, 2), substr($data['fin'], 0, 4)))
+        {
+            throw new UserException('Date de fin vide ou invalide.');
+        }
+
+        return true;
+    }
+
+
+    public function getJournal($exercice)
+    {
+        $db = DB::getInstance();
+        $query = 'SELECT *, strftime(\'%s\', date) AS date FROM compta_journal
+            WHERE id_exercice = '.(int)$exercice.' ORDER BY date, id;';
+        return $db->simpleStatementFetch($query);
+    }
+
+    public function getGrandLivre($exercice)
+    {
+        $db = DB::getInstance();
+        $livre = ['classes' => [], 'debit' => 0.0, 'credit' => 0.0];
+
+        $res = $db->prepare('SELECT compte FROM
+            (SELECT compte_debit AS compte FROM compta_journal
+                    WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_debit
+                UNION
+                SELECT compte_credit AS compte FROM compta_journal
+                    WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_credit)
+            ORDER BY base64(compte) COLLATE BINARY ASC;'
+            )->execute();
+
+        while ($row = $res->fetchArray(SQLITE3_NUM))
+        {
+            $compte = $row[0];
+
+            if (is_null($compte))
+                continue;
+
+            $classe = substr($compte, 0, 1);
+            $parent = substr($compte, 0, 2);
+
+            if (!array_key_exists($classe, $livre['classes']))
+            {
+                $livre['classes'][$classe] = [];
+            }
+
+            if (!array_key_exists($parent, $livre['classes'][$classe]))
+            {
+                $livre['classes'][$classe][$parent] = [
+                    'total'         =>  0.0,
+                    'comptes'       =>  [],
+                ];
+            }
+
+            $livre['classes'][$classe][$parent]['comptes'][$compte] = ['debit' => 0.0, 'credit' => 0.0, 'journal' => []];
+
+            $livre['classes'][$classe][$parent]['comptes'][$compte]['journal'] = $db->simpleStatementFetch(
+                'SELECT *, strftime(\'%s\', date) AS date FROM (
+                    SELECT * FROM compta_journal WHERE compte_debit = :compte AND id_exercice = '.(int)$exercice.'
+                    UNION
+                    SELECT * FROM compta_journal WHERE compte_credit = :compte AND id_exercice = '.(int)$exercice.'
+                    )
+                ORDER BY date, numero_piece, id;', SQLITE3_ASSOC, ['compte' => $compte]);
+
+            $debit = (float) $db->simpleQuerySingle(
+                'SELECT SUM(montant) FROM compta_journal WHERE compte_debit = ? AND id_exercice = '.(int)$exercice.';',
+                false, $compte);
+
+            $credit = (float) $db->simpleQuerySingle(
+                'SELECT SUM(montant) FROM compta_journal WHERE compte_credit = ? AND id_exercice = '.(int)$exercice.';',
+                false, $compte);
+
+            $livre['classes'][$classe][$parent]['comptes'][$compte]['debit'] = $debit;
+            $livre['classes'][$classe][$parent]['comptes'][$compte]['credit'] = $credit;
+
+            $livre['classes'][$classe][$parent]['total'] += $debit;
+            $livre['classes'][$classe][$parent]['total'] -= $credit;
+
+            $livre['debit'] += $debit;
+            $livre['credit'] += $credit;
+        }
+
+        $res->finalize();
+
+        return $livre;
+    }
+
+    public function getCompteResultat($exercice)
+    {
+        $db = DB::getInstance();
+
+        $charges    = ['comptes' => [], 'total' => 0.0];
+        $produits   = ['comptes' => [], 'total' => 0.0];
+        $resultat   = 0.0;
+
+        $res = $db->prepare('SELECT compte, SUM(debit), SUM(credit)
+            FROM
+                (SELECT compte_debit AS compte, SUM(montant) AS debit, 0 AS credit
+                    FROM compta_journal WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_debit
+                UNION
+                SELECT compte_credit AS compte, 0 AS debit, SUM(montant) AS credit
+                    FROM compta_journal WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_credit)
+            WHERE compte LIKE \'6%\' OR compte LIKE \'7%\'
+            GROUP BY compte
+            ORDER BY base64(compte) COLLATE BINARY ASC;'
+            )->execute();
+
+        while ($row = $res->fetchArray(SQLITE3_NUM))
+        {
+            list($compte, $debit, $credit) = $row;
+            $classe = substr($compte, 0, 1);
+            $parent = substr($compte, 0, 2);
+
+            if ($classe == 6)
+            {
+                if (!isset($charges['comptes'][$parent]))
+                {
+                    $charges['comptes'][$parent] = ['comptes' => [], 'solde' => 0.0];
+                }
+
+                $solde = round($debit - $credit, 2);
+
+                if (empty($solde))
+                    continue;
+
+                $charges['comptes'][$parent]['comptes'][$compte] = $solde;
+                $charges['total'] += $solde;
+                $charges['comptes'][$parent]['solde'] += $solde;
+            }
+            elseif ($classe == 7)
+            {
+                if (!isset($produits['comptes'][$parent]))
+                {
+                    $produits['comptes'][$parent] = ['comptes' => [], 'solde' => 0.0];
+                }
+
+                $solde = round($credit - $debit, 2);
+
+                if (empty($solde))
+                    continue;
+
+                $produits['comptes'][$parent]['comptes'][$compte] = $solde;
+                $produits['total'] += $solde;
+                $produits['comptes'][$parent]['solde'] += $solde;
+            }
+        }
+
+        $res->finalize();
+
+        $resultat = $produits['total'] - $charges['total'];
+
+        return ['charges' => $charges, 'produits' => $produits, 'resultat' => $resultat];
+    }
+
+    /**
+     * Calculer le bilan comptable pour l'exercice $exercice
+     * @param  integer  $exercice   ID de l'exercice dont il faut produire le bilan
+     * @param  boolean  $resultat   true s'il faut calculer le résultat de l'exercice (utile pour un exercice en cours)
+     * @return array    Un tableau multi-dimensionnel avec deux clés : actif et passif
+     */
+    public function getBilan($exercice)
+    {
+        $db = DB::getInstance();
+
+        $include = [Compta_Comptes::ACTIF, Compta_Comptes::PASSIF,
+            Compta_Comptes::PASSIF | Compta_Comptes::ACTIF];
+
+        $actif      = ['comptes' => [], 'total' => 0.0];
+        $passif     = ['comptes' => [], 'total' => 0.0];
+
+        $resultat = $this->getCompteResultat($exercice);
+
+        if ($resultat['resultat'] >= 0)
+        {
+            $passif['comptes']['12'] = [
+                'comptes'   =>  ['120' => $resultat['resultat']],
+                'solde'     =>  $resultat['resultat']
+            ];
+
+            $passif['total'] = $resultat['resultat'];
+        }
+        else
+        {
+            $passif['comptes']['12'] = [
+                'comptes'   =>  ['129' => $resultat['resultat']],
+                'solde'     =>  $resultat['resultat']
+            ];
+
+            $passif['total'] = $resultat['resultat'];
+        }
+
+        // Y'a sûrement moyen d'améliorer tout ça pour que le maximum de travail
+        // soit fait au niveau du SQL, mais pour le moment ça marche
+        $res = $db->prepare('SELECT compte, debit, credit, (SELECT position FROM compta_comptes WHERE id = compte) AS position
+            FROM
+                (SELECT compte_debit AS compte, SUM(montant) AS debit, NULL AS credit
+                    FROM compta_journal WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_debit
+                UNION
+                SELECT compte_credit AS compte, NULL AS debit, SUM(montant) AS credit
+                    FROM compta_journal WHERE id_exercice = '.(int)$exercice.' GROUP BY compte_credit)
+            WHERE compte IN (SELECT id FROM compta_comptes WHERE position IN ('.implode(', ', $include).'))
+            ORDER BY base64(compte) COLLATE BINARY ASC;'
+            )->execute();
+
+        while ($row = $res->fetchArray(SQLITE3_NUM))
+        {
+            list($compte, $debit, $credit, $position) = $row;
+            $parent = substr($compte, 0, 2);
+            $classe = $compte[0];
+
+            if (($position & Compta_Comptes::ACTIF) && ($position & Compta_Comptes::PASSIF))
+            {
+                $solde = $debit - $credit;
+
+                if ($solde > 0)
+                    $position = 'actif';
+                elseif ($solde < 0)
+                    $position = 'passif';
+                else
+                    continue;
+
+                $solde = abs($solde);
+            }
+            else if ($position & Compta_Comptes::ACTIF)
+            {
+                $position = 'actif';
+                $solde = $debit - $credit;
+            }
+            else if ($position & Compta_Comptes::PASSIF)
+            {
+                $position = 'passif';
+                $solde = $credit - $debit;
+            }
+            else
+            {
+                continue;
+            }
+
+            if (!isset(${$position}['comptes'][$parent]))
+            {
+                ${$position}['comptes'][$parent] = ['comptes' => [], 'solde' => 0];
+            }
+
+            if (!isset(${$position}['comptes'][$parent]['comptes'][$compte]))
+            {
+                ${$position}['comptes'][$parent]['comptes'][$compte] = 0;
+            }
+
+            $solde = round($solde, 2);
+            ${$position}['comptes'][$parent]['comptes'][$compte] += $solde;
+            ${$position}['total'] += $solde;
+            ${$position}['comptes'][$parent]['solde'] += $solde;
+        }
+
+        $res->finalize();
+
+        // Suppression des soldes nuls
+        foreach ($passif['comptes'] as $parent=>$p)
+        {
+            if ($p['solde'] == 0)
+            {
+                unset($passif['comptes'][$parent]);
+                continue;
+            }
+
+            foreach ($p['comptes'] as $id=>$solde)
+            {
+                if ($solde == 0)
+                {
+                    unset($passif['comptes'][$parent]['comptes'][$id]);
+                }
+            }
+        }
+
+        foreach ($actif['comptes'] as $parent=>$p)
+        {
+            if (empty($p['solde']))
+            {
+                unset($actif['comptes'][$parent]);
+                continue;
+            }
+
+            foreach ($p['comptes'] as $id=>$solde)
+            {
+                if (empty($solde))
+                {
+                    unset($actif['comptes'][$parent]['comptes'][$id]);
+                }
+            }
+        }
+
+        return ['actif' => $actif, 'passif' => $passif];
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.compta_import.php b/include/class.compta_import.php
new file mode 100644 (file)
index 0000000..73fd99b
--- /dev/null
@@ -0,0 +1,387 @@
+<?php
+
+namespace Garradin;
+
+class Compta_Import
+{
+       protected $csv_header = [
+               'Numéro mouvement',
+               'Date',
+               'Type de mouvement',
+               'Catégorie',
+               'Libellé',
+               'Montant',
+               'Compte de débit - numéro',
+               'Compte de débit - libellé',
+               'Compte de crédit - numéro',
+               'Compte de crédit - libellé',
+               'Moyen de paiement',
+               'Numéro de chèque',
+               'Numéro de pièce',
+               'Remarques'
+       ];
+
+       public function toCSV($exercice)
+       {
+               $db = DB::getInstance();
+
+               $res = $db->prepare('SELECT
+                       journal.id,
+                       strftime(\'%d/%m/%Y\', date) AS date,
+                       (CASE cat.type WHEN 1 THEN \'Recette\' WHEN -1 THEN \'Dépense\' ELSE \'Autre\' END) AS type,
+                       (CASE cat.intitule WHEN NULL THEN \'\' ELSE cat.intitule END) AS cat,
+                       journal.libelle,
+                       montant,
+                       compte_debit,
+                       debit.libelle AS libelle_debit,
+                       compte_credit,
+                       credit.libelle AS libelle_credit,
+                       (CASE moyen_paiement WHEN NULL THEN \'\' ELSE moyen.nom END) AS moyen,
+                       numero_cheque,
+                       numero_piece,
+                       remarques
+                       FROM compta_journal AS journal
+                               LEFT JOIN compta_categories AS cat ON cat.id = journal.id_categorie
+                               LEFT JOIN compta_comptes AS debit ON debit.id = journal.compte_debit
+                               LEFT JOIN compta_comptes AS credit ON credit.id = journal.compte_credit
+                               LEFT JOIN compta_moyens_paiement AS moyen ON moyen.code = journal.moyen_paiement
+                       WHERE id_exercice = '.(int)$exercice.'
+                       ORDER BY journal.date;
+               ')->execute();
+
+               $fp = fopen('php://output', 'w');
+
+               fputcsv($fp, $this->csv_header);
+
+               while ($row = $res->fetchArray(SQLITE3_ASSOC))
+               {
+                       fputcsv($fp, $row);
+               }
+
+               fclose($fp);
+
+               return true;
+       }
+
+       public function fromCSV($path)
+       {
+               if (!file_exists($path) || !is_readable($path))
+               {
+                       throw new \RuntimeException('Fichier inconnu : '.$path);
+               }
+
+               $fp = fopen($path, 'r');
+
+               if (!$fp)
+               {
+                       return false;
+               }
+
+               $db = DB::getInstance();
+               $db->exec('BEGIN;');
+               $comptes = new Compta_Comptes;
+               $banques = new Compta_Comptes_Bancaires;
+               $cats = new Compta_Categories;
+               $journal = new Compta_Journal;
+
+               $columns = array_flip($this->csv_header);
+               $liste_comptes = $db->simpleStatementFetchAssoc('SELECT id, id FROM compta_comptes;');
+               $liste_cats = $db->simpleStatementFetchAssoc('SELECT intitule, id FROM compta_categories;');
+               $liste_moyens = $cats->listMoyensPaiement();
+
+               $col = function($column) use (&$row, &$columns)
+               {
+                       if (!isset($columns[$column]))
+                               return null;
+
+                       if (!isset($row[$columns[$column]]))
+                               return null;
+
+                       return $row[$columns[$column]];
+               };
+
+               $line = 0;
+               $delim = utils::find_csv_delim($fp);
+
+               while (!feof($fp))
+               {
+                       $row = fgetcsv($fp, 4096, $delim);
+                       $line++;
+
+                       if (empty($row))
+                       {
+                               continue;
+                       }
+
+                       if ($line === 1)
+                       {
+                               if (trim($row[0]) != 'Numéro mouvement')
+                               {
+                                       throw new UserException('Erreur sur la ligne ' . $line . ' : l\'entête des colonnes est absent ou incorrect.');
+                               }
+                               
+                               continue;
+                       }
+       
+                       if (count($row) != count($columns))
+                       {
+                               $db->exec('ROLLBACK;');
+                               throw new UserException('Erreur sur la ligne ' . $line . ' : le nombre de colonnes est incorrect.');
+                       }
+
+                       if (trim($row[0]) !== '' && !is_numeric($row[0]))
+                       {
+                               $db->exec('ROLLBACK;');
+                               throw new UserException('Erreur sur la ligne ' . $line . ' : la première colonne doit être vide ou contenir le numéro unique d\'opération.');
+                       }
+
+                       $id = $col('Numéro mouvement');
+                       $date = $col('Date');
+
+                       if (!preg_match('!^\d{2}/\d{2}/\d{4}$!', $date))
+                       {
+                               $db->exec('ROLLBACK;');
+                               throw new UserException('Erreur sur la ligne ' . $line . ' : la date n\'est pas au format jj/mm/aaaa.');
+                       }
+
+                       $date = explode('/', $date);
+                       $date = $date[2] . '-' . $date[1] . '-' . $date[0];
+
+                       // En dehors de l'exercice courant
+                       if ($db->simpleQuerySingle('SELECT 1 FROM compta_exercices
+                               WHERE (? < debut OR ? > fin) AND cloture = 0;', false, $date, $date))
+                       {
+                               continue;
+                       }
+
+                       $debit = $col('Compte de débit - numéro');
+                       $credit = $col('Compte de crédit - numéro');
+
+                       if (trim($debit) == '' && trim($credit) != '')
+                       {
+                               $debit = null;
+                       }
+                       elseif (trim($debit) != '' && trim($credit) == '')
+                       {
+                               $credit = null;
+                       }
+
+                       $cat = $col('Catégorie');
+                       $moyen = strtoupper(substr($col('Moyen de paiement'), 0, 2));
+
+                       if (!$moyen || !array_key_exists($moyen, $liste_moyens))
+                       {
+                               $moyen = false;
+                               $cat = false;
+                       }
+
+                       if ($cat && !array_key_exists($cat, $liste_cats))
+                       {
+                               $cat = $moyen = false;
+                       }
+
+                       $data = [
+                               'libelle'       =>  $col('Libellé'),
+                               'montant'       =>  (float) $col('Montant'),
+                               'date'          =>  $date,
+                               'compte_credit' =>  $credit,
+                               'compte_debit'  =>  $debit,
+                               'numero_piece'  =>  $col('Numéro de pièce'),
+                               'remarques'     =>  $col('Remarques'),
+                       ];
+
+                       if ($cat)
+                       {
+                               $data['moyen_paiement'] =       $moyen;
+                               $data['numero_cheque']  =       $col('Numéro de chèque');
+                               $data['id_categorie']   =       $liste_cats[$cat];
+                       }
+
+                       if (empty($id))
+                       {
+                               $journal->add($data);
+                       }
+                       else
+                       {
+                               $journal->edit($id, $data);
+                       }
+               }
+
+               $db->exec('END;');
+
+               fclose($fp);
+               return true;
+       }
+
+       public function fromCitizen($path)
+       {
+               if (!file_exists($path) || !is_readable($path))
+               {
+                       throw new \RuntimeException('Fichier inconnu : '.$path);
+               }
+
+               $fp = fopen($path, 'r');
+
+               if (!$fp)
+               {
+                       return false;
+               }
+
+               $db = DB::getInstance();
+               $db->exec('BEGIN;');
+               $comptes = new Compta_Comptes;
+               $banques = new Compta_Comptes_Bancaires;
+               $cats = new Compta_Categories;
+               $journal = new Compta_Journal;
+
+               $columns = [];
+               $liste_comptes = $db->simpleStatementFetchAssoc('SELECT id, id FROM compta_comptes;');
+               $liste_cats = $db->simpleStatementFetchAssoc('SELECT intitule, id FROM compta_categories;');
+               $liste_moyens = $cats->listMoyensPaiement();
+
+               $get_compte = function ($compte, $intitule) use (&$liste_comptes, &$comptes, &$banques)
+               {
+                       if (substr($compte, 0, 2) == '51')
+                       {
+                               $compte = '512' . substr($compte, -1);
+                       }
+
+                       // Création comptes
+                       if (!array_key_exists($compte, $liste_comptes))
+                       {
+                               if (substr($compte, 0, 3) == '512')
+                               {
+                                       $liste_comptes[$compte] = $banques->add([
+                                               'libelle'       =>      $intitule,
+                                               'banque'        =>      'Inconnue',
+                                       ]);
+                               }
+                               else
+                               {
+                                       $liste_comptes[$compte] = $comptes->add([
+                                               'id'            =>      $compte,
+                                               'libelle'       =>      $intitule,
+                                               'parent'        =>      substr($compte, 0, -1)
+                                       ]);
+                               }
+                       }
+
+                       return $compte;
+               };
+
+               $col = function($column) use (&$row, &$columns)
+               {
+                       if (!isset($columns[$column]))
+                               return null;
+
+                       if (!isset($row[$columns[$column]]))
+                               return null;
+
+                       return $row[$columns[$column]];
+               };
+
+               $line = 0;
+               $delim = utils::find_csv_delim($fp);
+
+               while (!feof($fp))
+               {
+                       $row = fgetcsv($fp, 4096, $delim);
+                       $line++;
+
+                       if (empty($row))
+                       {
+                               continue;
+                       }
+
+                       if (empty($columns))
+                       {
+                               $columns = $row;
+                               $columns = array_flip($columns);
+                               continue;
+                       }
+
+                       $date = $col('Date');
+
+                       if (!preg_match('!^\d{2}/\d{2}/\d{4}$!', $date))
+                       {
+                               $db->exec('ROLLBACK;');
+                               throw new UserException('Erreur sur la ligne ' . $line . ' : la date n\'est pas au format jj/mm/aaaa.');
+                       }
+
+                       $date = explode('/', $date);
+                       $date = $date[2] . '-' . $date[1] . '-' . $date[0];
+
+                       if ($db->simpleQuerySingle('SELECT 1 FROM compta_exercices
+                               WHERE (? < debut OR ? > fin) AND cloture = 0;', false, $date, $date))
+                       {
+                               continue;
+                       }
+
+                       $debit = $get_compte($col('Compte débité - Numéro'), $col('Compte débité - Intitulé'));
+                       $credit = $get_compte($col('Compte crédité - Numéro'), $col('Compte crédité - Intitulé'));
+
+                       $cat = $col('Rubrique');
+                       $moyen = strtoupper(substr($col('Moyen de paiement'), 0, 2));
+
+                       if (!$moyen || !array_key_exists($moyen, $liste_moyens))
+                       {
+                               $moyen = false;
+                               $cat = false;
+                       }
+
+                       if ($cat && !array_key_exists($cat, $liste_cats))
+                       {
+                               if ($col('Nature') == 'Recette')
+                               {
+                                       $type = $cats::RECETTES;
+                                       $compte = $credit;
+                               }
+                               elseif ($col('Nature') == 'Dépense')
+                               {
+                                       $type = $cats::DEPENSES;
+                                       $compte = $debit;
+                               }
+                               else
+                               {
+                                       $type = $cats::AUTRES;
+                                       $cat = false;
+                               }
+
+                               if ($type != $cats::AUTRES)
+                               {
+                                       $liste_cats[$cat] = $cats->add([
+                                               'intitule'      =>      $cat,
+                                               'type'          =>      $type,
+                                               'compte'        =>      $compte
+                                       ]);
+                               }
+                       }
+
+                       $data = [
+                               'libelle'       =>  $col('Libellé'),
+                               'montant'       =>  $col('Montant'),
+                               'date'          =>  $date,
+                               'compte_credit' =>  $credit,
+                               'compte_debit'  =>  $debit,
+                               'numero_piece'  =>  $col('Numéro de pièce'),
+                               'remarques'     =>  $col('Remarques'),
+                       ];
+
+                       if ($cat)
+                       {
+                               $data['moyen_paiement'] =       $moyen;
+                               $data['numero_cheque']  =       $col('Numéro de chèque');
+                               $data['id_categorie']   =       $liste_cats[$cat];
+                       }
+
+                       $journal->add($data);
+               }
+
+               $db->exec('END;');
+
+               fclose($fp);
+               return true;
+       }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.compta_journal.php b/include/class.compta_journal.php
new file mode 100644 (file)
index 0000000..9ff8c95
--- /dev/null
@@ -0,0 +1,363 @@
+<?php
+
+namespace Garradin;
+
+class Compta_Journal
+{
+    protected function _getCurrentExercice()
+    {
+        $db = DB::getInstance();
+        $id = $db->querySingle('SELECT id FROM compta_exercices WHERE cloture = 0 LIMIT 1;');
+
+        if (!$id)
+        {
+            throw new UserException('Aucun exercice en cours.');
+        }
+
+        return $id;
+    }
+
+    public function checkExercice()
+    {
+        return $this->_getCurrentExercice();
+    }
+
+    protected function _checkOpenExercice($id)
+    {
+        if (is_null($id))
+            return true;
+
+        $db = DB::getInstance();
+        $id = $db->simpleQuerySingle('SELECT id FROM compta_exercices
+            WHERE cloture = 0 AND id = ? LIMIT 1;', false, (int)$id);
+
+        if ($id)
+            return true;
+
+        return false;
+    }
+
+    public function getSolde($id_compte, $inclure_sous_comptes = false)
+    {
+        $db = DB::getInstance();
+        $exercice = $this->_getCurrentExercice();
+        $compte = $inclure_sous_comptes
+            ? 'LIKE \'' . $db->escapeString(trim($id_compte)) . '%\''
+            : '= \'' . $db->escapeString(trim($id_compte)) . '\'';
+
+        $debit = 'COALESCE((SELECT SUM(montant) FROM compta_journal WHERE compte_debit '.$compte.' AND id_exercice = '.(int)$exercice.'), 0)';
+        $credit = 'COALESCE((SELECT SUM(montant) FROM compta_journal WHERE compte_credit '.$compte.' AND id_exercice = '.(int)$exercice.'), 0)';
+
+        // L'actif augmente au débit, le passif au crédit
+        $position = $db->simpleQuerySingle('SELECT position FROM compta_comptes WHERE id = ?;', false, $id_compte);
+
+        if (($position & Compta_Comptes::ACTIF) || ($position & Compta_Comptes::CHARGE))
+        {
+            $query = $debit . ' - ' . $credit;
+        }
+        else
+        {
+            $query = $credit . ' - ' . $debit;
+        }
+
+        return $db->querySingle('SELECT ' . $query . ';');
+    }
+
+    public function getJournalCompte($compte, $inclure_sous_comptes = false)
+    {
+        $db = DB::getInstance();
+
+        $position = $db->simpleQuerySingle('SELECT position FROM compta_comptes WHERE id = ?;', false, $compte);
+
+        $exercice = $this->_getCurrentExercice();
+        $compte = $inclure_sous_comptes
+            ? 'LIKE \'' . $db->escapeString(trim($compte)) . '%\''
+            : '= \'' . $db->escapeString(trim($compte)) . '\'';
+
+        // L'actif et les charges augmentent au débit, le passif et les produits au crédit
+        if (($position & Compta_Comptes::ACTIF) || ($position & Compta_Comptes::CHARGE))
+        {
+            $d = '';
+            $c = '-';
+        }
+        else
+        {
+            $d = '-';
+            $c = '';
+        }
+
+        $query = 'SELECT *, strftime(\'%s\', date) AS date,
+            running_sum(CASE WHEN compte_debit '.$compte.' THEN '.$d.'montant ELSE '.$c.'montant END) AS solde
+            FROM compta_journal WHERE (compte_debit '.$compte.' OR compte_credit '.$compte.') AND id_exercice = '.(int)$exercice.'
+            ORDER BY date ASC;';
+
+        // Obligatoire pour bien taper dans l'index de la date
+        // sinon running_sum est appelé 2 fois et ça marche pas du coup
+        // FIXME mettre ça ailleurs pour que ça soit appelé moins souvent
+        $db->exec('ANALYZE compta_journal;');
+
+        $db->resetRunningSum();
+        return $db->simpleStatementFetch($query);
+    }
+
+    public function add($data)
+    {
+        $this->_checkFields($data);
+
+        $db = DB::getInstance();
+
+        $data['id_exercice'] = $this->_getCurrentExercice();
+
+        $db->simpleInsert('compta_journal', $data);
+        $id = $db->lastInsertRowId();
+
+        return $id;
+    }
+
+    public function edit($id, $data)
+    {
+        $db = DB::getInstance();
+
+        // Vérification que l'on peut éditer cette opération
+        if (!$this->_checkOpenExercice($db->simpleQuerySingle('SELECT id_exercice FROM compta_journal WHERE id = ?;', false, $id)))
+        {
+            throw new UserException('Cette opération fait partie d\'un exercice qui a été clôturé.');
+        }
+
+        $this->_checkFields($data);
+
+        $db->simpleUpdate('compta_journal', $data,
+            'id = \''.trim($id).'\'');
+
+        return true;
+    }
+
+    public function delete($id)
+    {
+        $db = DB::getInstance();
+
+        // Vérification que l'on peut éditer cette opération
+        if (!$this->_checkOpenExercice($db->simpleQuerySingle('SELECT id_exercice FROM compta_journal WHERE id = ?;', false, $id)))
+        {
+            throw new UserException('Cette opération fait partie d\'un exercice qui a été clôturé.');
+        }
+
+        $db->simpleExec('DELETE FROM membres_operations WHERE id_operation = ?;', (int)$id);
+        $db->simpleExec('DELETE FROM compta_journal WHERE id = ?;', (int)$id);
+
+        return true;
+    }
+
+    public function get($id)
+    {
+        $db = DB::getInstance();
+        return $db->simpleQuerySingle('SELECT *, strftime(\'%s\', date) AS date FROM compta_journal WHERE id = ?;', true, $id);
+    }
+
+    public function countForMember($id)
+    {
+        $db = DB::getInstance();
+        return $db->simpleQuerySingle('SELECT COUNT(*) 
+            FROM compta_journal WHERE id_auteur = ?;', false, (int)$id);
+    }
+
+    public function listForMember($id, $exercice)
+    {
+        $db = DB::getInstance();
+        return $db->simpleStatementFetch('SELECT * FROM compta_journal
+            WHERE id_auteur = ? AND id_exercice = ?;', \SQLITE3_ASSOC, (int)$id, (int)$exercice);
+    }
+
+    protected function _checkFields(&$data)
+    {
+        $db = DB::getInstance();
+
+        if (empty($data['libelle']) || !trim($data['libelle']))
+        {
+            throw new UserException('Le libellé ne peut rester vide.');
+        }
+
+        $data['libelle'] = trim($data['libelle']);
+
+        if (!empty($data['moyen_paiement'])
+            && !$db->simpleQuerySingle('SELECT 1 FROM compta_moyens_paiement WHERE code = ?;', false, $data['moyen_paiement']))
+        {
+            throw new UserException('Moyen de paiement invalide.');
+        }
+
+        if (empty($data['date']) || !utils::checkDate($data['date']))
+        {
+            throw new UserException('Date vide ou invalide.');
+        }
+
+        if (!$db->simpleQuerySingle('SELECT 1 FROM compta_exercices WHERE cloture = 0
+            AND debut <= :date AND fin >= :date;', false, ['date' => $data['date']]))
+        {
+            throw new UserException('La date ne correspond pas à l\'exercice en cours.');
+        }
+
+        if (empty($data['moyen_paiement']))
+        {
+            $data['moyen_paiement'] = null;
+            $data['numero_cheque'] = null;
+        }
+        else
+        {
+            $data['moyen_paiement'] = strtoupper($data['moyen_paiement']);
+
+            if ($data['moyen_paiement'] != 'CH')
+            {
+                $data['numero_cheque'] = null;
+            }
+
+            if (!$db->simpleQuerySingle('SELECT 1 FROM compta_moyens_paiement WHERE code = ? LIMIT 1;',
+                false, $data['moyen_paiement']))
+            {
+                throw new UserException('Moyen de paiement invalide.');
+            }
+        }
+
+        $data['montant'] = str_replace(',', '.', $data['montant']);
+        $data['montant'] = (float)$data['montant'];
+
+        if ($data['montant'] <= 0)
+        {
+            throw new UserException('Le montant ne peut être égal ou inférieur à zéro.');
+        }
+
+        foreach (['remarques', 'numero_piece', 'numero_cheque'] as $champ)
+        {
+            if (empty($data[$champ]) || !trim($data[$champ]))
+            {
+                $data[$champ] = '';
+            }
+            else
+            {
+                $data[$champ] = trim($data[$champ]);
+            }
+        }
+
+        if (!array_key_exists('compte_debit', $data) || 
+            (!is_null($data['compte_debit']) && 
+                !$db->simpleQuerySingle('SELECT 1 FROM compta_comptes WHERE id = ?;', false, $data['compte_debit'])))
+        {
+            throw new UserException('Compte débité inconnu.');
+        }
+
+        if (!array_key_exists('compte_credit', $data) || 
+            (!is_null($data['compte_credit']) && 
+                !$db->simpleQuerySingle('SELECT 1 FROM compta_comptes WHERE id = ?;', false, $data['compte_credit'])))
+        {
+            throw new UserException('Compte crédité inconnu.');
+        }
+
+        $data['compte_credit'] = is_null($data['compte_credit']) ? null : strtoupper(trim($data['compte_credit']));
+        $data['compte_debit'] = is_null($data['compte_debit']) ? null : strtoupper(trim($data['compte_debit']));
+
+        if ($data['compte_credit'] == $data['compte_debit'])
+        {
+            throw new UserException('Compte crédité identique au compte débité.');
+        }
+
+        if (isset($data['id_categorie']))
+        {
+            if (!$db->simpleQuerySingle('SELECT 1 FROM compta_categories WHERE id = ?;', false, (int)$data['id_categorie']))
+            {
+                throw new UserException('Catégorie inconnue.');
+            }
+
+            $data['id_categorie'] = (int)$data['id_categorie'];
+        }
+        else
+        {
+            $data['id_categorie'] = NULL;
+        }
+
+        if (isset($data['id_auteur']))
+        {
+            $data['id_auteur'] = (int)$data['id_auteur'];
+        }
+
+        return true;
+    }
+
+    public function getListForCategory($type = null, $cat = null)
+    {
+        $db = DB::getInstance();
+        $exercice = $this->_getCurrentExercice();
+
+        $query = 'SELECT compta_journal.*, strftime(\'%s\', compta_journal.date) AS date ';
+
+        if (is_null($cat) && !is_null($type))
+        {
+            $query.= ', compta_categories.intitule AS categorie
+                FROM compta_journal LEFT JOIN compta_categories
+                ON compta_journal.id_categorie = compta_categories.id ';
+        }
+        else
+        {
+            $query.= ' FROM compta_journal ';
+        }
+
+        $query .= ' WHERE ';
+
+        if (!is_null($cat))
+        {
+            $query .= 'id_categorie = ' . (int)$cat;
+        }
+        elseif (is_null($type) && is_null($cat))
+        {
+            $query .= 'id_categorie IS NULL';
+        }
+        else
+        {
+            $query.= 'id_categorie IN (SELECT id FROM compta_categories WHERE type = '.(int)$type.')';
+        }
+
+        $query .= ' AND id_exercice = ' . (int)$exercice;
+        $query .= ' ORDER BY date;';
+
+        return $db->simpleStatementFetch($query);
+    }
+
+    public function searchSQL($query)
+    {
+        $db = DB::getInstance();
+
+        if (!preg_match('/LIMIT\s+/', $query))
+        {
+            $query = preg_replace('/;?\s*$/', '', $query);
+            $query .= ' LIMIT 100';
+        }
+
+        $st = $db->prepare($query);
+
+        if (!$st->readOnly())
+        {
+            throw new UserException('Seules les requêtes en lecture sont autorisées.');
+        }
+
+        $res = $st->execute();
+        $out = [];
+
+        while ($row = $res->fetchArray(SQLITE3_ASSOC))
+        {
+            $out[] = $row;
+        }
+
+        return $out;
+    }
+
+    public function schemaSQL()
+    {
+        $db = DB::getInstance();
+
+        $tables = [
+            'journal'   =>  $db->querySingle('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'compta_journal\';'),
+        ];
+
+        return $tables;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.compta_stats.php b/include/class.compta_stats.php
new file mode 100644 (file)
index 0000000..335fbe0
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+
+namespace Garradin;
+
+class Compta_Stats
+{
+       protected function _parRepartitionCategorie($type)
+       {
+               $db = DB::getInstance();
+               return $db->simpleStatementFetch('SELECT COUNT(*) AS nb, id_categorie
+                       FROM compta_journal
+                       WHERE id_categorie IN (SELECT id FROM compta_categories WHERE type = ?)
+                       AND id_exercice = (SELECT id FROM compta_exercices WHERE cloture = 0)
+                       GROUP BY id_categorie ORDER BY nb DESC;', SQLITE3_ASSOC, $type);
+       }
+
+       public function repartitionRecettes()
+       {
+               return $this->_parRepartitionCategorie(Compta_Categories::RECETTES);
+       }
+
+       public function repartitionDepenses()
+       {
+               return $this->_parRepartitionCategorie(Compta_Categories::DEPENSES);
+       }
+
+       protected function _parType($type)
+       {
+               return $this->getStats('SELECT strftime(\'%Y%m\', date) AS date,
+                       SUM(montant) FROM compta_journal
+                       WHERE id_categorie IN (SELECT id FROM compta_categories WHERE type = '.$type.')
+                       AND id_exercice = (SELECT id FROM compta_exercices WHERE cloture = 0)
+                       GROUP BY strftime(\'%Y-%m\', date) ORDER BY date;');
+       }
+
+       public function recettes()
+       {
+               return $this->_parType(Compta_Categories::RECETTES);
+       }
+
+       public function depenses()
+       {
+               return $this->_parType(Compta_Categories::DEPENSES);
+       }
+
+       public function soldeCompte($compte, $augmente = 'debit', $diminue = 'credit')
+       {
+               $db = DB::getInstance();
+
+               if (strpos($compte, '%') !== false)
+               {
+                       $compte = 'LIKE \''. $db->escapeString($compte) . '\'';
+               }
+               else
+               {
+                       $compte = '= \''. $db->escapeString($compte) . '\'';
+               }
+
+               $stats = $this->getStats('SELECT strftime(\'%Y%m\', date) AS date,
+                       (COALESCE((SELECT SUM(montant) FROM compta_journal
+                               WHERE compte_'.$augmente.' '.$compte.' AND id_exercice = cj.id_exercice
+                               AND date >= strftime(\'%Y-%m-01\', cj.date)
+                               AND date <= strftime(\'%Y-%m-31\', cj.date)), 0)
+                       - COALESCE((SELECT SUM(montant) FROM compta_journal
+                               WHERE compte_'.$diminue.' '.$compte.' AND id_exercice = cj.id_exercice
+                               AND date >= strftime(\'%Y-%m-01\', cj.date)
+                               AND date <= strftime(\'%Y-%m-31\', cj.date)), 0)
+                       ) AS solde
+                       FROM compta_journal AS cj
+                       WHERE (compte_debit '.$compte.' OR compte_credit '.$compte.')
+                       AND id_exercice = (SELECT id FROM compta_exercices WHERE cloture = 0)
+                       GROUP BY strftime(\'%Y-%m\', date) ORDER BY date;');
+
+               $c = 0;
+               foreach ($stats as $k=>$v)
+               {
+                       $c += $v;
+                       $stats[$k] = $c;
+               }
+
+               return $stats;
+       }
+
+       public function getStats($query)
+       {
+               $db = DB::getInstance();
+
+               $data = $db->simpleStatementFetchAssoc($query);
+
+               $e = $db->querySingle('SELECT *, strftime(\'%s\', debut) AS debut,
+                       strftime(\'%s\', fin) AS fin FROM compta_exercices WHERE cloture = 0;', true);
+
+               $y = date('Y', $e['debut']);
+               $m = date('m', $e['debut']);
+               $max = date('Ym', $e['fin']);
+
+               while ($y . $m <= $max)
+               {
+                       if (!isset($data[$y . $m]))
+                       {
+                               $data[$y . $m] = 0;
+                       }
+
+                       if ($m == 12)
+                       {
+                               $m = '01';
+                               $y++;
+                       }
+                       else
+                       {
+                               $m++;
+                               $m = str_pad((int)$m, 2, '0', STR_PAD_LEFT);
+                       }
+               }
+
+               ksort($data);
+
+               return $data;
+       }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.config.php b/include/class.config.php
new file mode 100644 (file)
index 0000000..6ee1bd3
--- /dev/null
@@ -0,0 +1,326 @@
+<?php
+
+namespace Garradin;
+
+class Config
+{
+    protected $fields_types = null;
+    protected $config = null;
+    protected $modified = [];
+
+    static protected $_instance = null;
+
+    static public function getInstance()
+    {
+        return self::$_instance ?: self::$_instance = new Config;
+    }
+
+    private function __clone()
+    {
+    }
+
+    protected function __construct()
+    {
+        // Définition des types de données stockées
+        $string = '';
+        $int = 0;
+        $float = 0.0;
+        $array = [];
+        $bool = false;
+        $object = new \stdClass;
+
+        $this->fields_types = [
+            'nom_asso'              =>  $string,
+            'adresse_asso'          =>  $string,
+            'email_asso'            =>  $string,
+            'site_asso'             =>  $string,
+
+            'monnaie'               =>  $string,
+            'pays'                  =>  $string,
+
+            'champs_membres'        =>  $object,
+
+            'email_envoi_automatique'=> $string,
+
+            'categorie_membres'     =>  $int,
+
+            'categorie_dons'        =>  $int,
+            'categorie_cotisations' =>  $int,
+
+            'accueil_wiki'          =>  $string,
+            'accueil_connexion'     =>  $string,
+
+            'frequence_sauvegardes' =>  $int,
+            'nombre_sauvegardes'    =>  $int,
+
+            'champ_identifiant'     =>  $string,
+            'champ_identite'        =>  $string,
+
+            'version'               =>  $string,
+        ];
+
+        $db = DB::getInstance();
+
+        $this->config = $db->simpleStatementFetchAssoc('SELECT cle, valeur FROM config ORDER BY cle;');
+
+        foreach ($this->config as $key=>&$value)
+        {
+            if (!array_key_exists($key, $this->fields_types))
+            {
+                // Ancienne clé de config qui n'est plus utilisée
+                continue;
+            }
+
+            if (is_array($this->fields_types[$key]))
+            {
+                $value = explode(',', $value);
+            }
+            elseif ($key == 'champs_membres')
+            {
+                $value = new Champs_Membres((string)$value);
+            }
+            else
+            {
+                settype($value, gettype($this->fields_types[$key]));
+            }
+        }
+    }
+
+    public function __destruct()
+    {
+        if (!empty($this->modified))
+        {
+            //echo '<div style="color: red; background: #fff;">Il y a des champs modifiés non sauvés dans '.__CLASS__.' !</div>';
+        }
+    }
+
+    public function save()
+    {
+        if (empty($this->modified))
+            return true;
+
+        $values = [];
+
+        $db = DB::getInstance();
+        $db->exec('BEGIN;');
+
+        foreach ($this->modified as $key=>$modified)
+        {
+            $value = $this->config[$key];
+
+            if (is_array($value))
+            {
+                $value = implode(',', $value);
+            }
+            elseif (is_object($value))
+            {
+                $value = (string) $value;
+            }
+
+            $db->simpleExec('INSERT OR REPLACE INTO config (cle, valeur) VALUES (?, ?);',
+                $key, $value);
+        }
+
+        if (!empty($this->modified['champ_identifiant']))
+        {
+            // Mettre les champs identifiant vides à NULL pour pouvoir créer un index unique
+            $db->exec('UPDATE membres SET '.$this->get('champ_identifiant').' = NULL 
+                WHERE '.$this->get('champ_identifiant').' = "";');
+
+            // Création de l'index unique
+            $db->exec('DROP INDEX IF EXISTS membres_identifiant;');
+            $db->exec('CREATE UNIQUE INDEX membres_identifiant ON membres ('.$this->get('champ_identifiant').');');
+        }
+
+        $db->exec('END;');
+
+        $this->modified = [];
+
+        return true;
+    }
+
+    public function get($key)
+    {
+        if (!array_key_exists($key, $this->fields_types))
+        {
+            throw new \OutOfBoundsException('Ce champ est inconnu.');
+        }
+
+        if (!array_key_exists($key, $this->config))
+        {
+            return null;
+        }
+        
+        return $this->config[$key];
+    }
+
+    public function getVersion()
+    {
+        if (!array_key_exists('version', $this->config))
+        {
+            return '0';
+        }
+
+        return $this->config['version'];
+    }
+
+    public function setVersion($version)
+    {
+        $this->config['version'] = $version;
+
+        $db = DB::getInstance();
+        $db->simpleExec('INSERT OR REPLACE INTO config (cle, valeur) VALUES (?, ?);',
+                'version', $version);
+
+        return true;
+    }
+
+    public function set($key, $value)
+    {
+        if (!array_key_exists($key, $this->fields_types))
+        {
+            throw new \OutOfBoundsException('Ce champ est inconnu.');
+        }
+
+        if (is_array($this->fields_types[$key]))
+        {
+            $value = !empty($value) ? (array) $value : [];
+        }
+        elseif (is_int($this->fields_types[$key]))
+        {
+            $value = (int) $value;
+        }
+        elseif (is_float($this->fields_types[$key]))
+        {
+            $value = (float) $value;
+        }
+        elseif (is_bool($this->fields_types[$key]))
+        {
+            $value = (bool) $value;
+        }
+        elseif (is_string($this->fields_types[$key]))
+        {
+            $value = (string) $value;
+        }
+
+        switch ($key)
+        {
+            case 'nom_asso':
+            {
+                if (!trim($value))
+                {
+                    throw new UserException('Le nom de l\'association ne peut rester vide.');
+                }
+                break;
+            }
+            case 'accueil_wiki':
+            case 'accueil_connexion':
+            {
+                if (!trim($value))
+                {
+                    $key = str_replace('accueil_', '', $key);
+                    throw new UserException('Le nom de la page d\'accueil ' . $key . ' ne peut rester vide.');
+                }
+                break;
+            }
+            case 'email_asso':
+            case 'email_envoi_automatique':
+            {
+                if (!filter_var($value, FILTER_VALIDATE_EMAIL))
+                {
+                    throw new UserException('Adresse e-mail invalide.');
+                }
+                break;
+            }
+            case 'champs_membres':
+            {
+                if (!($value instanceOf Champs_Membres))
+                {
+                    throw new \UnexpectedValueException('$value doit être de type Champs_Membres');
+                }
+                break;
+            }
+            case 'champ_identite':
+            case 'champ_identifiant':
+            {
+                $champs = $this->get('champs_membres');
+                $db = DB::getInstance();
+
+                // Vérification que le champ existe bien
+                if (!$champs->get($value))
+                {
+                    throw new UserException('Le champ '.$value.' n\'existe pas pour la configuration de '.$key);
+                }
+
+                // Vérification que le champ est unique pour l'identifiant
+                if ($key == 'champ_identifiant' 
+                    && !$db->simpleQuerySingle('SELECT (COUNT(DISTINCT '.$value.') = COUNT(*)) 
+                        FROM membres WHERE '.$value.' IS NOT NULL AND '.$value.' != \'\';'))
+                {
+                    throw new UserException('Le champ '.$value.' comporte des doublons et ne peut donc pas servir comme identifiant pour la connexion.');
+                }
+                break;
+            }
+            case 'categorie_cotisations':
+            case 'categorie_dons':
+            {
+                return false;
+                $db = DB::getInstance();
+                if (!$db->simpleQuerySingle('SELECT 1 FROM compta_categories WHERE id = ?;', false, $value))
+                {
+                    throw new UserException('Champ '.$key.' : La catégorie comptable numéro \''.$value.'\' ne semble pas exister.');
+                }
+                break;
+            }
+            case 'categorie_membres':
+            {
+                $db = DB::getInstance();
+                if (!$db->simpleQuerySingle('SELECT 1 FROM membres_categories WHERE id = ?;', false, $value))
+                {
+                    throw new UserException('La catégorie de membres par défaut numéro \''.$value.'\' ne semble pas exister.');
+                }
+                break;
+            }
+            case 'monnaie':
+            {
+                if (!trim($value))
+                {
+                    throw new UserException('La monnaie doit être renseignée.');
+                }
+
+                break;
+            }
+            case 'pays':
+            {
+                if (!trim($value) || !utils::getCountryName($value))
+                {
+                    throw new UserException('Le pays renseigné est invalide.');
+                }
+
+                break;
+            }
+            default:
+                break;
+        }
+
+        if (!isset($this->config[$key]) || $value !== $this->config[$key])
+        {
+            $this->config[$key] = $value;
+            $this->modified[$key] = true;
+        }
+
+        return true;
+    }
+
+    public function getFieldsTypes()
+    {
+        return $this->fields_types;
+    }
+
+    public function getConfig()
+    {
+        return $this->config;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.cotisations.php b/include/class.cotisations.php
new file mode 100644 (file)
index 0000000..9490784
--- /dev/null
@@ -0,0 +1,170 @@
+<?php
+
+namespace Garradin;
+
+class Cotisations
+{
+       /**
+        * Vérification des champs fournis pour la modification de donnée
+        * @param  array $data Tableau contenant les champs à ajouter/modifier
+        * @return void
+        */
+       protected function _checkFields(&$data)
+       {
+               $db = DB::getInstance();
+
+               if (!isset($data['intitule']) || trim($data['intitule']) == '')
+               {
+                       throw new UserException('L\'intitulé ne peut rester vide.');
+               }
+
+               $data['intitule'] = trim($data['intitule']);
+
+               if (isset($data['description']))
+               {
+                       $data['description'] = trim($data['description']);
+               }
+
+               if (!isset($data['montant']) || !is_numeric($data['montant']) || (float)$data['montant'] < 0)
+               {
+                       throw new UserException('Le montant doit être un nombre positif et valide.');
+               }
+
+               $data['montant'] = (float) $data['montant'];
+
+               if (isset($data['duree']))
+               {
+                       $data['duree'] = (int) $data['duree'];
+
+                       if ($data['duree'] < 0)
+                       {
+                               $data['duree'] = 0;
+                       }
+               }
+
+               if (isset($data['debut']) && trim($data['debut']) != '')
+               {
+                       if (!empty($data['duree']))
+                       {
+                               throw new UserException('Il n\'est pas possible de spécifier une durée ET une date fixe, merci de choisir l\'une des deux options.');
+                       }
+
+                       if (!isset($data['fin']) || trim($data['fin']) == '')
+                       {
+                               throw new UserException('Une date de fin est obligatoire avec la date de début de validité.');
+                       }
+
+                       if (!utils::checkDate($data['debut']))
+                       {
+                               throw new UserException('La date de début est invalide.');
+                       }
+
+                       if (!utils::checkDate($data['fin']))
+                       {
+                               throw new UserException('La date de fin est invalide.');
+                       }
+               }
+
+               if (isset($data['id_categorie_compta']))
+               {
+                       if ($data['id_categorie_compta'] != 0 && !$db->simpleQuerySingle('SELECT 1 FROM compta_categories WHERE id = ?;', false, (int) $data['id_categorie_compta']))
+                       {
+                               throw new UserException('Catégorie comptable inconnue');
+                       }
+
+                       $data['id_categorie_compta'] = (int) $data['id_categorie_compta'];
+               }
+       }
+
+       /**
+        * Ajouter une cotisation
+        * @param array $data Tableau des champs à insérer
+        * @return integer ID de la cotisation créée
+        */
+       public function add($data)
+       {
+               $db = DB::getInstance();
+
+               $this->_checkFields($data);
+
+               $db->simpleInsert('cotisations', $data);
+               $id = $db->lastInsertRowId();
+
+               return $id;
+       }
+
+       /**
+        * Modifier une cotisation
+        * @param  integer $id  ID de la cotisation à modifier
+        * @param  array $data Tableau des champs à modifier
+        * @return bool true si succès
+        */
+       public function edit($id, $data)
+       {
+               $db = DB::getInstance();
+
+        $this->_checkFields($data);
+
+        return $db->simpleUpdate('cotisations', $data, 'id = \''.(int) $id.'\'');
+       }
+
+       /**
+        * Supprimer une cotisation
+        * @param  integer $id ID de la cotisation à supprimer
+        * @return integer true en cas de succès
+        */
+       public function delete($id)
+       {
+               $db = DB::getInstance();
+
+               $db->exec('BEGIN;');
+               $db->simpleExec('DELETE FROM cotisations_membres WHERE id_cotisation = ?;', (int) $id);
+               $db->simpleExec('DELETE FROM cotisations WHERE id = ?;', (int) $id);
+               $db->exec('END;');
+
+               return true;
+       }
+
+       /**
+        * Renvoie les infos sur une cotisation
+        * @param  integer $id Numéro de la cotisation
+        * @return array     Infos de la cotisation
+        */
+       public function get($id)
+       {
+               $db = DB::getInstance();
+               return $db->simpleQuerySingle('SELECT co.*,
+                       (SELECT COUNT(DISTINCT id_membre) FROM cotisations_membres WHERE id_cotisation = co.id) AS nb_membres,
+                       (SELECT COUNT(DISTINCT id_membre) FROM cotisations_membres AS cm WHERE id_cotisation = co.id
+                               AND ((co.duree IS NOT NULL AND date(cm.date, \'+\'||co.duree||\' days\') >= date())
+                                       OR (co.fin IS NOT NULL AND co.debut <= cm.date AND co.fin >= cm.date))) AS nb_a_jour
+                       FROM cotisations AS co WHERE id = :id;', true, ['id' => (int) $id]);
+       }
+
+       public function listByName()
+       {
+               $db = DB::getInstance();
+               return $db->simpleStatementFetch('SELECT * FROM cotisations ORDER BY intitule;');
+       }
+
+       public function listCurrent()
+       {
+               $db = DB::getInstance();
+               return $db->simpleStatementFetch('SELECT * FROM cotisations WHERE fin >= date(\'now\') OR fin IS NULL
+                       ORDER BY transliterate_to_ascii(intitule) COLLATE NOCASE;');
+       }
+
+       public function listCurrentWithStats()
+       {
+               $db = DB::getInstance();
+               return $db->simpleStatementFetch('SELECT co.*,
+                       (SELECT COUNT(DISTINCT id_membre) FROM cotisations_membres WHERE id_cotisation = co.id) AS nb_membres,
+                       (SELECT COUNT(DISTINCT id_membre) FROM cotisations_membres AS cm WHERE id_cotisation = co.id
+                               AND ((co.duree IS NOT NULL AND date(cm.date, \'+\'||co.duree||\' days\') >= date())
+                                       OR (co.fin IS NOT NULL AND co.debut <= cm.date AND co.fin >= cm.date))) AS nb_a_jour
+                       FROM cotisations AS co WHERE fin >= date(\'now\') OR fin IS NULL
+                       ORDER BY transliterate_to_ascii(intitule) COLLATE NOCASE;');
+       }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.cotisations_membres.php b/include/class.cotisations_membres.php
new file mode 100644 (file)
index 0000000..a895ee2
--- /dev/null
@@ -0,0 +1,336 @@
+<?php
+
+namespace Garradin;
+
+class Cotisations_Membres
+{
+       const ITEMS_PER_PAGE = 100;
+
+       /**
+        * Vérification des champs fournis pour la modification de donnée
+        * @param  array $data Tableau contenant les champs à ajouter/modifier
+        * @return void
+        */
+       protected function _checkFields(&$data, $compta = false)
+       {
+               $db = DB::getInstance();
+
+        if (empty($data['date']) || !utils::checkDate($data['date']))
+        {
+            throw new UserException('Date vide ou invalide.');
+        }
+
+               if (empty($data['id_cotisation']) 
+                       || !$db->simpleQuerySingle('SELECT 1 FROM cotisations WHERE id = ?;', false, (int) $data['id_cotisation']))
+               {
+                       throw new UserException('Cotisation inconnue.');
+               }
+
+               $data['id_cotisation'] = (int) $data['id_cotisation'];
+
+               if (empty($data['id_membre']) 
+                       || !$db->simpleQuerySingle('SELECT 1 FROM membres WHERE id = ?;', false, (int) $data['id_membre']))
+               {
+                       throw new UserException('Membre inconnu ou invalide.');
+               }
+
+               $data['id_membre'] = (int) $data['id_membre'];
+
+               if ($compta)
+               {
+               if (!isset($data['moyen_paiement']) || trim($data['moyen_paiement']) === '')
+               {
+                       throw new UserException('Moyen de paiement inconnu ou invalide.');
+               }
+
+                       if ($data['moyen_paiement'] != 'ES')
+               {
+                   if (trim($data['banque']) == '')
+                   {
+                       throw new UserException('Le compte bancaire choisi est invalide.');
+                   }
+
+                   if (!$db->simpleQuerySingle('SELECT 1 FROM compta_comptes_bancaires WHERE id = ?;',
+                       false, $data['banque']))
+                   {
+                       throw new UserException('Le compte bancaire choisi n\'existe pas.');
+                   }
+               }
+
+               if (empty($data['montant']) || !is_numeric($data['montant']))
+               {
+                       throw new UserException('Le montant indiqué n\'est pas un nombre valide.');
+               }
+           }
+       }
+
+       /**
+        * Enregistrer un événement de cotisation
+        * @param array $data Tableau des champs à insérer
+        * @return integer ID de l'événement créé
+        */
+       public function add($data)
+       {
+               $db = DB::getInstance();
+
+               $co = $db->simpleQuerySingle('SELECT * FROM cotisations WHERE id = ?;', 
+                       true, (int)$data['id_cotisation']);
+
+               $this->_checkFields($data, !empty($co['id_categorie_compta']));
+
+               $check = $db->simpleQuerySingle('SELECT 1 FROM cotisations_membres 
+                       WHERE id_cotisation = ? AND id_membre = ? AND date = ?;', 
+                       false, (int)$data['id_cotisation'], (int)$data['id_membre'], $data['date']);
+
+               if ($check)
+               {
+                       throw new UserException('Cette cotisation a déjà été enregistrée pour ce jour-ci et ce membre-ci.');
+               }
+
+               $db->begin();
+
+               $db->simpleInsert('cotisations_membres', [
+                       'date'                          =>      $data['date'],
+                       'id_cotisation'         =>      $data['id_cotisation'],
+                       'id_membre'                     =>      $data['id_membre'],
+                       ]);
+
+               $id = $db->lastInsertRowId();
+
+               if ($co['id_categorie_compta'] && $co['montant'] > 0)
+               {
+                       try {
+                       $id_operation = $this->addOperationCompta($id, [
+                               'id_categorie'  =>      $co['id_categorie_compta'],
+                           'libelle'       =>  'Cotisation (automatique)',
+                           'montant'       =>  $data['montant'],
+                           'date'          =>  $data['date'],
+                           'moyen_paiement'=>  $data['moyen_paiement'],
+                           'numero_cheque' =>  isset($data['numero_cheque']) ? $data['numero_cheque'] : null,
+                           'id_auteur'     =>  $data['id_auteur'],
+                           'banque'            =>      isset($data['banque']) ? $data['banque'] : null,
+                           'id_membre'         =>      $data['id_membre'],
+                       ]);
+               }
+               catch (\Exception $e)
+               {
+                       $db->rollback();
+                       throw $e;
+               }
+               }
+
+               $db->commit();
+
+               return $id;
+       }
+
+       /**
+        * Supprimer un événement de cotisation
+        * @param  integer $id ID de l'événement à supprimer
+        * @return integer true en cas de succès
+        */
+       public function delete($id)
+       {
+               $db = DB::getInstance();
+               $db->simpleExec('DELETE FROM membres_operations WHERE id_cotisation = ?;', (int)$id);
+               return $db->simpleExec('DELETE FROM cotisations_membres WHERE id = ?;', (int) $id);
+       }
+
+       public function get($id)
+       {
+               $db = DB::getInstance();
+               return $db->simpleQuerySingle('SELECT * FROM cotisations_membres WHERE id = ?;', true, (int)$id);
+       }
+
+       /**
+        * Renvoie une liste des écritures comptables liées à une cotisation
+        * @param  int $id Numéro de la cotisation membre
+        * @return array Liste des écritures
+        */
+       public function listOperationsCompta($id)
+       {
+               $db = DB::getInstance();
+               return $db->simpleStatementFetch('SELECT * FROM compta_journal
+                       WHERE id IN (SELECT id_operation FROM membres_operations
+                               WHERE id_cotisation = ?);', \SQLITE3_ASSOC, (int)$id);
+       }
+
+       /**
+        * Ajouter une écriture comptable pour un paiemement membre
+        * @param int $id Numéro de la cotisation membre
+        * @param array $data Données
+        */
+       public function addOperationCompta($id, $data)
+       {
+               $journal = new Compta_Journal;
+               $db = DB::getInstance();
+
+               if (!isset($data['libelle']) || trim($data['libelle']) == '')
+               {
+                       throw new UserException('Le libellé ne peut rester vide.');
+               }
+
+               $data['libelle'] = trim($data['libelle']);
+
+               if (!isset($data['montant']) || !is_numeric($data['montant']) || (float)$data['montant'] < 0)
+               {
+                       throw new UserException('Le montant doit être un nombre positif et valide.');
+               }
+
+               $data['montant'] = (float) $data['montant'];
+
+               if ($data['moyen_paiement'] != 'ES')
+               {
+            $debit = $data['banque'];
+        }
+        else
+        {
+               $debit = Compta_Comptes::CAISSE;
+        }
+
+        $credit = $db->simpleQuerySingle('SELECT compte FROM compta_categories WHERE id = ?;', 
+               false, $data['id_categorie']);
+
+        $id_operation = $journal->add([
+            'libelle'       =>  $data['libelle'],
+            'montant'       =>  $data['montant'],
+            'date'          =>  $data['date'],
+            'moyen_paiement'=>  $data['moyen_paiement'],
+            'numero_cheque' =>  isset($data['numero_cheque']) ? $data['numero_cheque'] : null,
+            'compte_debit'  =>  $debit,
+            'compte_credit' =>  $credit,
+            'id_categorie'  =>  (int)$data['id_categorie'],
+            'id_auteur'     =>  (int)$data['id_auteur'],
+        ]);
+
+        $db->simpleInsert('membres_operations', [
+               'id_operation' => $id_operation,
+               'id_membre' => $data['id_membre'],
+               'id_cotisation' => (int)$id,
+        ]);
+
+        return $id_operation;
+       }
+
+       /**
+        * Nombre de membres pour une cotisation
+        * @param  integer $id Numéro de la cotisation
+        * @return integer     Nombre d'événements pour cette cotisation
+        */
+       public function countMembersForCotisation($id)
+       {
+               $db = DB::getInstance();
+               return $db->simpleQuerySingle('SELECT COUNT(DISTINCT id_membre) FROM cotisations_membres 
+                       WHERE id_cotisation = ?;',
+                       false, (int)$id);
+       }
+
+       /**
+        * Liste des membres qui sont inscrits à une cotisation
+        * @param  integer $id Numéro de la cotisation
+        * @return array     Liste des membres
+        */
+       public function listMembersForCotisation($id, $page = 1, $order = null, $desc = true)
+       {
+               $begin = ($page - 1) * self::ITEMS_PER_PAGE;
+
+               $db = DB::getInstance();
+               $champ_id = Config::getInstance()->get('champ_identite');
+
+               if (empty($order))
+                       $order = 'date';
+
+               switch ($order)
+               {
+                       case 'date':
+                       case 'a_jour':
+                               break;
+                       case 'identite':
+                               $order = 'transliterate_to_ascii('.$champ_id.') COLLATE NOCASE';
+                               break;
+                       default:
+                               $order = 'cm.id_membre';
+                               break;
+               }
+
+               $desc = $desc ? 'DESC' : 'ASC';
+
+               return $db->simpleStatementFetch('SELECT cm.id_membre, cm.date, cm.id,
+                       (SELECT '.$champ_id.' FROM membres WHERE id = cm.id_membre) AS nom, c.montant,
+                       CASE WHEN c.duree IS NOT NULL THEN date(cm.date, \'+\'||c.duree||\' days\') >= date()
+                       WHEN c.fin IS NOT NULL THEN c.fin >= date() ELSE 1 END AS a_jour
+                       FROM cotisations_membres AS cm
+                               INNER JOIN cotisations AS c ON c.id = cm.id_cotisation
+                       WHERE
+                               cm.id_cotisation = ?
+                       GROUP BY cm.id_membre ORDER BY '.$order.' '.$desc.' LIMIT ?,?;',
+                       \SQLITE3_ASSOC, (int)$id, $begin, self::ITEMS_PER_PAGE);
+       }
+
+       /**
+        * Liste des événements d'un membre
+        * @param  integer $id Numéro de membre
+        * @return array     Liste des événements de cotisation fait par ce membre
+        */
+       public function listForMember($id)
+       {
+               $db = DB::getInstance();
+               return $db->simpleStatementFetch('SELECT cm.*, c.intitule, c.duree, c.debut, c.fin, c.montant,
+                       (SELECT COUNT(*) FROM membres_operations WHERE id_cotisation = cm.id) AS nb_operations
+                       FROM cotisations_membres AS cm
+                               LEFT JOIN cotisations AS c ON c.id = cm.id_cotisation
+                       WHERE cm.id_membre = ? ORDER BY cm.date DESC;', \SQLITE3_ASSOC, (int)$id);
+       }
+
+       /**
+        * Liste des cotisations / activités en cours pour ce membre
+        * @param  integer $id Numéro de membre
+        * @return array     Liste des cotisations en cours de validité
+        */
+       public function listSubscriptionsForMember($id)
+       {
+               $db = DB::getInstance();
+               return $db->simpleStatementFetch('SELECT c.*,
+                       CASE WHEN c.duree IS NOT NULL THEN date(cm.date, \'+\'||c.duree||\' days\') >= date()
+                       WHEN c.fin IS NOT NULL THEN c.fin >= date()
+                       WHEN cm.id IS NOT NULL THEN 1 ELSE 0 END AS a_jour,
+                       CASE WHEN c.duree IS NOT NULL THEN date(cm.date, \'+\'||c.duree||\' days\')
+                       WHEN c.fin IS NOT NULL THEN c.fin ELSE 1 END AS expiration,
+                       (julianday(date()) - julianday(CASE WHEN c.duree IS NOT NULL THEN date(cm.date, \'+\'||c.duree||\' days\')
+                       WHEN c.fin IS NOT NULL THEN c.fin END)) AS nb_jours
+                       FROM cotisations_membres AS cm
+                               INNER JOIN cotisations AS c ON c.id = cm.id_cotisation
+                       WHERE cm.id_membre = ?
+                       GROUP BY cm.id_cotisation
+                       ORDER BY cm.date DESC;', \SQLITE3_ASSOC, (int)$id);
+       }
+
+       /**
+        * Ce membre est-il à jour sur cette cotisation ?
+        * @param  integer  $id             Numéro de membre
+        * @param  integer  $id_cotisation  Numéro de cotisation
+        * @return array                                        Infos sur la cotisation, et champ expiration
+        * (si NULL = cotisation jamais enregistrée, si 1 = cotisation ponctuelle enregistrée, sinon date d'expiration)
+        */
+       public function isMemberUpToDate($id, $id_cotisation)
+       {
+               $db = DB::getInstance();
+               return $db->simpleQuerySingle('SELECT c.*,
+                       CASE WHEN c.duree IS NOT NULL THEN date(cm.date, \'+\'||c.duree||\' days\') >= date()
+                       WHEN c.fin IS NOT NULL THEN c.fin >= date()
+                       WHEN cm.id IS NOT NULL THEN 1 ELSE 0 END AS a_jour,
+                       CASE WHEN c.duree IS NOT NULL THEN date(cm.date, \'+\'||c.duree||\' days\')
+                       WHEN c.fin IS NOT NULL THEN c.fin ELSE 1 END AS expiration
+                       FROM cotisations AS c 
+                               LEFT JOIN cotisations_membres AS cm ON cm.id_cotisation = c.id AND cm.id_membre = ?
+                       WHERE c.id = ? ORDER BY cm.date DESC;',
+                       true, (int)$id, (int)$id_cotisation);
+       }
+
+       public function countForMember($id)
+       {
+               $db = DB::getInstance();
+               return $db->simpleQuerySingle('SELECT COUNT(DISTINCT id_cotisation) FROM cotisations_membres 
+                       WHERE id_membre = ?;', false, (int)$id);
+       }
+}
\ No newline at end of file
diff --git a/include/class.db.php b/include/class.db.php
new file mode 100644 (file)
index 0000000..c560c59
--- /dev/null
@@ -0,0 +1,411 @@
+<?php
+
+namespace Garradin;
+
+function str_replace_first ($search, $replace, $subject)
+{
+    $pos = strpos($subject, $search);
+
+    if ($pos !== false)
+    {
+        $subject = substr_replace($subject, $replace, $pos, strlen($search));
+    }
+
+    return $subject;
+}
+
+class DB extends \SQLite3
+{
+    static protected $_instance = null;
+
+    protected $_running_sum = 0.0;
+
+    protected $_transaction = 0;
+
+    const NUM = \SQLITE3_NUM;
+    const ASSOC = \SQLITE3_ASSOC;
+    const BOTH = \SQLITE3_BOTH;
+
+    static public function getInstance($create = false)
+    {
+        return self::$_instance ?: self::$_instance = new DB($create);
+    }
+
+    private function __clone()
+    {
+    }
+
+    public function __construct($create = false)
+    {
+        $flags = SQLITE3_OPEN_READWRITE;
+
+        if ($create)
+        {
+            $flags |= SQLITE3_OPEN_CREATE;
+        }
+
+        parent::__construct(DB_FILE, $flags);
+
+        $this->enableExceptions(true);
+
+        // Activer les contraintes des foreign keys
+        $this->exec('PRAGMA foreign_keys = ON;');
+
+        $this->createFunction('transliterate_to_ascii', ['Garradin\utils', 'transliterateToAscii']);
+        $this->createFunction('base64', 'base64_encode');
+        $this->createFunction('rank', [$this, 'sql_rank']);
+        $this->createFunction('running_sum', [$this, 'sql_running_sum']);
+    }
+
+    public function sql_running_sum($data)
+    {
+        // Why is this function called two times for the first row?!
+        // Dunno but here is a workaround
+        if (is_null($this->_running_sum))
+        {
+            $this->_running_sum = 0.0;
+            return $this->_running_sum;
+        }
+
+        $this->_running_sum += $data;
+        return $this->_running_sum;
+    }
+
+    public function resetRunningSum()
+    {
+        $this->_running_sum = null;
+    }
+
+    public function sql_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:
+            **
+            **   (<hit count> / <global hit count>) * <column weight>
+            **
+            ** 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)
+                {
+                    $score += ((double)$nHitCount / (double)$nGlobalHitCount) * $weight;
+                }
+            }
+        }
+
+        return $score;
+    }
+
+    public function escape($str)
+    {
+        return $this->escapeString($str);
+    }
+
+    public function e($str)
+    {
+        return $this->escapeString($str);
+    }
+
+    public function begin()
+    {
+        if (!$this->_transaction)
+        {
+            $this->exec('BEGIN;');
+        }
+
+        $this->_transaction++;
+
+        return $this->_transaction == 1 ? true : false;
+    }
+
+    public function commit()
+    {
+        if ($this->_transaction == 1)
+        {
+            $this->exec('END;');
+        }
+
+        if ($this->_transaction > 0)
+        {
+            $this->_transaction--;
+        }
+
+        return $this->_transaction ? false : true;
+    }
+
+    public function rollback()
+    {
+        $this->exec('ROLLBACK;');
+        $this->_transaction = 0;
+        return true;
+    }
+
+    protected function _getArgType($arg, $name = '')
+    {
+        if (is_float($arg))
+            return SQLITE3_FLOAT;
+        elseif (is_int($arg))
+            return SQLITE3_INTEGER;
+        elseif (is_bool($arg))
+            return SQLITE3_INTEGER;
+        elseif (is_null($arg))
+            return SQLITE3_NULL;
+        elseif (is_string($arg))
+            return SQLITE3_TEXT;
+        else
+            throw new \InvalidArgumentException('Argument '.$name.' is of invalid type '.gettype($arg));
+    }
+
+    public function simpleStatement($query, $args = [])
+    {
+        $statement = $this->prepare($query);
+        $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('Arguments error: '.count($args).' supplied, but '.$nb.' are required by query.');
+            }
+
+            reset($args);
+
+            if (is_int(key($args)))
+            {
+                foreach ($args as $i=>$arg)
+                {
+                    $statement->bindValue((int)$i+1, $arg, $this->_getArgType($arg, $i+1));
+                }
+            }
+            else
+            {
+                foreach ($args as $key=>$value)
+                {
+                    if (is_int($key))
+                    {
+                        throw new \InvalidArgumentException(__FUNCTION__ . ' requires argument to be a named-associative array, but key '.$key.' is an integer.');
+                    }
+
+                    $statement->bindValue(':'.$key, $value, $this->_getArgType($value, $key));
+                }
+            }
+        }
+
+        try {
+            return $statement->execute();
+        }
+        catch (\Exception $e)
+        {
+            throw new \Exception($e->getMessage() . "\n" . $query . "\n" . json_encode($args, true));
+        }
+    }
+
+    public function simpleStatementFetch($query, $mode = SQLITE3_BOTH)
+    {
+        if ($mode != SQLITE3_BOTH && $mode != SQLITE3_ASSOC && $mode != SQLITE3_NUM)
+        {
+            throw new \InvalidArgumentException('Mode argument should be either SQLITE3_BOTH, SQLITE3_ASSOC or SQLITE3_NUM.');
+        }
+
+        $args = array_slice(func_get_args(), 2);
+        return $this->fetchResult($this->simpleStatement($query, $args), $mode);
+    }
+
+    public function simpleStatementFetchAssoc($query)
+    {
+        $args = array_slice(func_get_args(), 1);
+        return $this->fetchResultAssoc($this->simpleStatement($query, $args));
+    }
+
+    public function simpleStatementFetchAssocKey($query, $mode = SQLITE3_BOTH)
+    {
+        if ($mode != SQLITE3_BOTH && $mode != SQLITE3_ASSOC && $mode != SQLITE3_NUM)
+        {
+            throw new \InvalidArgumentException('Mode argument should be either SQLITE3_BOTH, SQLITE3_ASSOC or SQLITE3_NUM.');
+        }
+
+        $args = array_slice(func_get_args(), 2);
+        return $this->fetchResultAssocKey($this->simpleStatement($query, $args), $mode);
+    }
+
+    public function escapeAuto($value, $name = '')
+    {
+        $type = $this->_getArgType($value, $name);
+
+        switch ($type)
+        {
+            case SQLITE3_FLOAT:
+                return floatval($value);
+            case SQLITE3_INTEGER:
+                return intval($value);
+            case SQLITE3_NULL:
+                return 'NULL';
+            case SQLITE3_TEXT:
+                return '\'' . $this->escapeString($value) . '\'';
+        }
+    }
+
+    /**
+     * Simple INSERT query
+     */
+    public function simpleInsert($table, $fields)
+    {
+        $fields_names = array_keys($fields);
+        return $this->simpleStatement('INSERT INTO '.$table.' ('.implode(', ', $fields_names).')
+            VALUES (:'.implode(', :', $fields_names).');', $fields);
+    }
+
+    public function simpleUpdate($table, $fields, $where)
+    {
+        if (empty($fields))
+            return false;
+        
+        $query = 'UPDATE '.$table.' SET ';
+
+        foreach ($fields as $key=>$value)
+        {
+            $query .= $key . ' = :'.$key.', ';
+        }
+
+        $query = substr($query, 0, -2);
+        $query .= ' WHERE '.$where.';';
+        return $this->simpleStatement($query, $fields);
+    }
+
+    /**
+     * Formats and escapes a statement and then returns the result of exec()
+     */
+    public function simpleExec($query)
+    {
+        return $this->simpleStatement($query, array_slice(func_get_args(), 1));
+    }
+
+    public function simpleQuerySingle($query, $all_columns = false)
+    {
+        $res = $this->simpleStatement($query, array_slice(func_get_args(), 2));
+
+        $row = $res->fetchArray($all_columns ? SQLITE3_ASSOC : SQLITE3_NUM);
+
+        if (!$all_columns)
+        {
+            if (isset($row[0]))
+                return $row[0];
+            return false;
+        }
+        else
+        {
+            return $row;
+        }
+    }
+
+    public function queryFetch($query, $mode = SQLITE3_BOTH)
+    {
+        return $this->fetchResult($this->query($query), $mode);
+    }
+
+    public function queryFetchAssoc($query)
+    {
+        return $this->fetchResultAssoc($this->query($query));
+    }
+
+    public function queryFetchAssocKey($query, $mode = SQLITE3_BOTH)
+    {
+        return $this->fetchResultAssocKey($this->query($query), $mode);
+    }
+
+    public function fetchResult($result, $mode = \SQLITE3_BOTH)
+    {
+        $out = [];
+
+        while ($row = $result->fetchArray($mode))
+        {
+            $out[] = $row;
+        }
+
+        $result->finalize();
+        unset($result, $row);
+
+        return $out;
+    }
+
+    protected function fetchResultAssoc($result)
+    {
+        $out = [];
+
+        while ($row = $result->fetchArray(SQLITE3_NUM))
+        {
+            $out[$row[0]] = $row[1];
+        }
+
+        $result->finalize();
+        unset($result, $row);
+
+        return $out;
+    }
+
+    protected function fetchResultAssocKey($result, $mode = \SQLITE3_BOTH)
+    {
+        $out = [];
+
+        while ($row = $result->fetchArray($mode))
+        {
+            $key = current($row);
+            $out[$key] = $row;
+        }
+
+        $result->finalize();
+        unset($result, $row);
+
+        return $out;
+    }
+
+    public function countRows($result)
+    {
+        $i = 0;
+
+        while ($result->fetchArray(SQLITE3_NUM))
+            $i++;
+
+        return $i;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.membres.php b/include/class.membres.php
new file mode 100644 (file)
index 0000000..0136d6d
--- /dev/null
@@ -0,0 +1,792 @@
+<?php
+
+namespace Garradin;
+
+class Membres
+{
+    const DROIT_AUCUN = 0;
+    const DROIT_ACCES = 1;
+    const DROIT_ECRITURE = 2;
+    const DROIT_ADMIN = 9;
+
+    const ITEMS_PER_PAGE = 50;
+
+    protected function _getSalt($length)
+    {
+        $str = str_split('./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789');
+        shuffle($str);
+
+        return implode('',
+            array_rand(
+                $str,
+                $length)
+        );
+    }
+
+    protected function _hashPassword($password)
+    {
+        $salt = '$2a$08$' . $this->_getSalt(22);
+        return crypt($password, $salt);
+    }
+
+    protected function _checkPassword($password, $stored_hash)
+    {
+        return crypt($password, $stored_hash) == $stored_hash;
+    }
+
+    protected function _sessionStart($force = false)
+    {
+        if (!isset($_SESSION) && ($force || isset($_COOKIE[session_name()])))
+        {
+            session_start();
+        }
+
+        return true;
+    }
+
+    public function keepSessionAlive()
+    {
+        $this->_sessionStart(true);
+    }
+
+    public function login($id, $passe)
+    {
+        $db = DB::getInstance();
+        $champ_id = Config::getInstance()->get('champ_identifiant');
+
+        $r = $db->simpleQuerySingle('SELECT id, passe, id_categorie FROM membres WHERE '.$champ_id.' = ? LIMIT 1;', true, trim($id));
+
+        if (empty($r))
+            return false;
+
+        if (!$this->_checkPassword(trim($passe), $r['passe']))
+            return false;
+
+        $droits = $this->getDroits($r['id_categorie']);
+
+        if ($droits['connexion'] == self::DROIT_AUCUN)
+            return false;
+
+        $this->_sessionStart(true);
+        $db->simpleExec('UPDATE membres SET date_connexion = datetime(\'now\') WHERE id = ?;', $r['id']);
+
+        return $this->updateSessionData($r['id'], $droits);
+    }
+
+    public function recoverPasswordCheck($id)
+    {
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+
+        $champ_id = $config->get('champ_identifiant');
+
+        $membre = $db->simpleQuerySingle('SELECT id, email FROM membres WHERE '.$champ_id.' = ? LIMIT 1;', true, trim($id));
+
+        if (!$membre || trim($membre['email']) == '')
+        {
+            return false;
+        }
+
+        $this->_sessionStart(true);
+        $hash = sha1($membre['email'] . $membre['id'] . 'recover' . ROOT . time());
+        $_SESSION['recover_password'] = [
+            'id' => (int) $membre['id'],
+            'email' => $membre['email'],
+            'hash' => $hash
+        ];
+
+        $message = "Bonjour,\n\nVous avez oublié votre mot de passe ? Pas de panique !\n\n";
+        $message.= "Il vous suffit de cliquer sur le lien ci-dessous pour recevoir un nouveau mot de passe.\n\n";
+        $message.= WWW_URL . 'admin/password.php?c=' . substr($hash, -10);
+        $message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé.";
+
+        return utils::mail($membre['email'], '['.$config->get('nom_asso').'] Mot de passe perdu ?', $message);
+    }
+
+    public function recoverPasswordConfirm($hash)
+    {
+        $this->_sessionStart();
+
+        if (empty($_SESSION['recover_password']['hash']))
+            return false;
+
+        if (substr($_SESSION['recover_password']['hash'], -10) != $hash)
+            return false;
+
+        $config = Config::getInstance();
+        $db = DB::getInstance();
+
+        $password = utils::suggestPassword();
+
+        $dest = $_SESSION['recover_password']['email'];
+        $id = (int)$_SESSION['recover_password']['id'];
+
+        $message = "Bonjour,\n\nVous avez demandé un nouveau mot de passe pour votre compte.\n\n";
+        $message.= "Votre adresse email : ".$dest."\n";
+        $message.= "Votre nouveau mot de passe : ".$password."\n\n";
+        $message.= "Si vous n'avez pas demandé à recevoir ce message, merci de nous le signaler.";
+
+        $password = $this->_hashPassword($password);
+
+        $db->simpleUpdate('membres', ['passe' => $password], 'id = '.(int)$id);
+
+        return utils::mail($dest, '['.$config->get('nom_asso').'] Nouveau mot de passe', $message);
+    }
+
+    public function updateSessionData($membre = null, $droits = null)
+    {
+        if (is_null($membre))
+        {
+            $membre = $this->get($_SESSION['logged_user']['id']);
+        }
+        elseif (is_int($membre))
+        {
+            $membre = $this->get($membre);
+        }
+
+        if (is_null($droits))
+        {
+            $droits = $this->getDroits($membre['id_categorie']);
+        }
+
+        $membre['droits'] = $droits;
+        $_SESSION['logged_user'] = $membre;
+        return true;
+    }
+
+    public function localLogin()
+    {
+        if (!defined('Garradin\LOCAL_LOGIN'))
+            return false;
+
+        if (trim(LOCAL_LOGIN) == '')
+            return false;
+
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+        $champ_id = $config->get('champ_identifiant');
+        
+        if (is_int(LOCAL_LOGIN) && $db->simpleQuerySingle('SELECT 1 FROM membres WHERE id = ? LIMIT 1;', true, LOCAL_LOGIN))
+        {
+            $this->_sessionStart(true);
+            return $this->updateSessionData(LOCAL_LOGIN);
+        }
+        elseif ($id = $db->simpleQuerySingle('SELECT id FROM membres WHERE '.$champ_id.' = ? LIMIT 1;', true, LOCAL_LOGIN))
+        {
+            $this->_sessionStart(true);
+            return $this->updateSessionData($membre);
+        }
+
+        throw new UserException('Le membre ' . LOCAL_LOGIN . ' n\'existe pas, merci de modifier la directive Garradin\LOCAL_LOGIN.');
+    }
+
+    public function isLogged()
+    {
+        $this->_sessionStart();
+
+        if (empty($_SESSION['logged_user']))
+        {
+            if (defined('Garradin\LOCAL_LOGIN'))
+            {
+                return $this->localLogin();
+            }
+
+            return false;
+        }
+
+        return true;
+    }
+
+    public function getLoggedUser()
+    {
+        if (!$this->isLogged())
+            return false;
+
+        return $_SESSION['logged_user'];
+    }
+
+    public function logout()
+    {
+        $_SESSION = [];
+        setcookie(session_name(), '', 0, '/');
+        return true;
+    }
+
+    public function sessionStore($key, $value)
+    {
+        if (!isset($_SESSION['storage']))
+        {
+            $_SESSION['storage'] = [];
+        }
+
+        if ($value === null)
+        {
+            unset($_SESSION['storage'][$key]);
+        }
+        else
+        {
+            $_SESSION['storage'][$key] = $value;
+        }
+
+        return true;
+    }
+
+    public function sessionGet($key)
+    {
+        if (!isset($_SESSION['storage'][$key]))
+        {
+            return null;
+        }
+
+        return $_SESSION['storage'][$key];
+    }
+
+    public function sendMessage($dest, $sujet, $message, $copie = false)
+    {
+        if (!$this->isLogged())
+        {
+            throw new \LogicException('Cette fonction ne peut être appelée que par un utilisateur connecté.');
+        }
+
+        $from = $this->getLoggedUser();
+        $from = $from['email'];
+        // Uniquement adresse email pour le moment car faudrait trouver comment
+        // indiquer le nom mais qu'il soit correctement échappé FIXME
+
+        $config = Config::getInstance();
+
+        $message .= "\n\n--\nCe message a été envoyé par un membre de ".$config->get('nom_asso');
+        $message .= ", merci de contacter ".$config->get('email_asso')." en cas d'abus.";
+
+        if ($copie)
+        {
+            utils::mail($from, $sujet, $message);
+        }
+
+        return utils::mail($dest, $sujet, $message, ['From' => $from]);
+    }
+
+    // Gestion des données ///////////////////////////////////////////////////////
+
+    public function _checkFields(&$data, $check_editable = true, $check_password = true)
+    {
+        $champs = Config::getInstance()->get('champs_membres');
+
+        foreach ($champs->getAll() as $key=>$config)
+        {
+            if (!$check_editable && (!empty($config['private']) || empty($config['editable'])))
+            {
+                unset($data[$key]);
+                continue;
+            }
+
+            if (!isset($data[$key]) || (!is_array($data[$key]) && trim($data[$key]) === '')
+                || (is_array($data[$key]) && empty($data[$key])))
+            {
+                if (!empty($config['mandatory']) && ($check_password || $key != 'passe'))
+                {
+                    throw new UserException('Le champ "' . $config['title'] . '" doit obligatoirement être renseigné.');
+                }
+                elseif (!empty($config['mandatory']))
+                {
+                    continue;
+                }
+            }
+
+            if (isset($data[$key]))
+            {
+                if ($config['type'] == 'email' && trim($data[$key]) !== '' && !filter_var($data[$key], FILTER_VALIDATE_EMAIL))
+                {
+                    throw new UserException('Adresse e-mail invalide dans le champ "' . $config['title'] . '".');
+                }
+                elseif ($config['type'] == 'url' && trim($data[$key]) !== '' && !filter_var($data[$key], FILTER_VALIDATE_URL))
+                {
+                    throw new UserException('Adresse URL invalide dans le champ "' . $config['title'] . '".');
+                }
+                elseif ($config['type'] == 'date' && trim($data[$key]) !== '' && !utils::checkDate($data[$key]))
+                {
+                    throw new UserException('Date invalide "' . $config['title'] . '", format attendu : AAAA-MM-JJ.');
+                }
+                elseif ($config['type'] == 'datetime' && trim($data[$key]) !== '')
+                {
+                    if (!utils::checkDateTime($data[$key]) || !($dt = new DateTime($data[$key])))
+                    {
+                        throw new UserException('Date invalide "' . $config['title'] . '", format attendu : AAAA-MM-JJ HH:mm.');
+                    }
+
+                    $data[$key] = $dt->format('Y-m-d H:i');
+                }
+                elseif ($config['type'] == 'tel')
+                {
+                    $data[$key] = utils::normalizePhoneNumber($data[$key]);
+                }
+                elseif ($config['type'] == 'country')
+                {
+                    $data[$key] = strtoupper(substr($data[$key], 0, 2));
+                }
+                elseif ($config['type'] == 'checkbox')
+                {
+                    $data[$key] = empty($data[$key]) ? 0 : 1;
+                }
+                elseif ($config['type'] == 'number' && trim($data[$key]) !== '')
+                {
+                    if (empty($data[$key]))
+                    {
+                        $data[$key] = 0;
+                    }
+
+                    if (!is_numeric($data[$key]))
+                        throw new UserException('Le champ "' . $config['title'] . '" doit contenir un chiffre.');
+                }
+                elseif ($config['type'] == 'select' && !in_array($data[$key], $config['options']))
+                {
+                    throw new UserException('Le champ "' . $config['title'] . '" ne correspond pas à un des choix proposés.');
+                }
+                elseif ($config['type'] == 'multiple')
+                {
+                    if (empty($data[$key]) || !is_array($data[$key]))
+                    {
+                        $data[$key] = 0;
+                        continue;
+                    }
+
+                    $binary = 0;
+
+                    foreach ($data[$key] as $k => $v)
+                    {
+                        if (array_key_exists($k, $config['options']) && !empty($v))
+                        {
+                            $binary |= 0x01 << $k;
+                        }
+                    }
+
+                    $data[$key] = $binary;
+                }
+
+                // Un champ texte vide c'est un champ NULL
+                if (is_string($data[$key]) && trim($data[$key]) === '')
+                {
+                    $data[$key] = null;
+                }
+            }
+        }
+
+        if (isset($data['code_postal']) && trim($data['code_postal']) != '')
+        {
+            if (!empty($data['pays']) && $data['pays'] == 'FR' && !preg_match('!^\d{5}$!', $data['code_postal']))
+            {
+                throw new UserException('Code postal invalide.');
+            }
+        }
+
+        if (!empty($data['passe']) && strlen($data['passe']) < 5)
+        {
+            throw new UserException('Le mot de passe doit faire au moins 5 caractères.');
+        }
+
+        return true;
+    }
+
+    public function add($data = [])
+    {
+        $this->_checkFields($data);
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+        $id = $config->get('champ_identifiant');
+
+        if (!empty($data[$id])
+            && $db->simpleQuerySingle('SELECT 1 FROM membres WHERE '.$id.' = ? LIMIT 1;', false, $data[$id]))
+        {
+            throw new UserException('La valeur du champ '.$id.' est déjà utilisée par un autre membre, hors ce champ doit être unique à chaque membre.');
+        }
+
+        if (isset($data['passe']) && trim($data['passe']) != '')
+        {
+            $data['passe'] = $this->_hashPassword($data['passe']);
+        }
+        else
+        {
+            unset($data['passe']);
+        }
+
+        if (empty($data['id_categorie']))
+        {
+            $data['id_categorie'] = Config::getInstance()->get('categorie_membres');
+        }
+
+        $db->simpleInsert('membres', $data);
+        return $db->lastInsertRowId();
+    }
+
+    public function edit($id, $data = [], $check_editable = true)
+    {
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+
+        if (isset($data['id']) && ($data['id'] == $id || empty($data['id'])))
+        {
+            unset($data['id']);
+        }
+
+        $this->_checkFields($data, $check_editable, false);
+        $champ_id = $config->get('champ_identifiant');
+
+        if (!empty($data[$champ_id])
+            && $db->simpleQuerySingle('SELECT 1 FROM membres WHERE '.$champ_id.' = ? AND id != ? LIMIT 1;', false, $data[$champ_id], (int)$id))
+        {
+            throw new UserException('La valeur du champ '.$champ_id.' est déjà utilisée par un autre membre, hors ce champ doit être unique à chaque membre.');
+        }
+
+        if (!empty($data['id']))
+        {
+            if ($db->simpleQuerySingle('SELECT 1 FROM membres WHERE id = ?;', false, (int)$data['id']))
+            {
+                throw new UserException('Ce numéro est déjà attribué à un autre membre.');
+            }
+
+            // Si on ne vérifie pas toutes les tables qui sont liées ici à un ID de membre
+            // la requête de modification provoquera une erreur de contrainte de foreign key
+            // ce qui est normal. Donc : il n'est pas possible de changer l'ID d'un membre qui
+            // a participé au wiki, à la compta, etc.
+            if ($db->simpleQuerySingle('SELECT 1 FROM wiki_revisions WHERE id_auteur = ?;', false, (int)$id)
+                || $db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_auteur = ?;', false, (int)$id))
+            # FIXME || $db->simpleQuerySingle('SELECT 1 FROM wiki_suivi WHERE id_membre = ?;', false, (int)$id))
+            {
+                throw new UserException('Le numéro n\'est pas modifiable pour ce membre car des contenus sont liés à ce numéro de membre (wiki, compta, etc.).');
+            }
+        }
+
+        if (!empty($data['passe']) && trim($data['passe']))
+        {
+            $data['passe'] = $this->_hashPassword($data['passe']);
+        }
+        else
+        {
+            unset($data['passe']);
+        }
+
+        if (isset($data['id_categorie']) && empty($data['id_categorie']))
+        {
+            $data['id_categorie'] = Config::getInstance()->get('categorie_membres');
+        }
+
+        if (empty($data))
+        {
+            return true;
+        }
+
+        return $db->simpleUpdate('membres', $data, 'id = '.(int)$id);
+    }
+
+    public function get($id)
+    {
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+
+        return $db->simpleQuerySingle('SELECT *,
+            '.$config->get('champ_identite').' AS identite,
+            strftime(\'%s\', date_inscription) AS date_inscription,
+            strftime(\'%s\', date_connexion) AS date_connexion
+            FROM membres WHERE id = ? LIMIT 1;', true, (int)$id);
+    }
+
+    public function delete($ids)
+    {
+        if (!is_array($ids))
+        {
+            $ids = [(int)$ids];
+        }
+
+        if ($this->isLogged())
+        {
+            $user = $this->getLoggedUser();
+
+            foreach ($ids as $id)
+            {
+                if ($user['id'] == $id)
+                {
+                    throw new UserException('Il n\'est pas possible de supprimer son propre compte.');
+                }
+            }
+        }
+
+        return self::_deleteMembres($ids);
+    }
+
+    public function getNom($id)
+    {
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+
+        return $db->simpleQuerySingle('SELECT '.$config->get('champ_identite').' FROM membres WHERE id = ? LIMIT 1;', false, (int)$id);
+    }
+
+    public function getDroits($id)
+    {
+        $db = DB::getInstance();
+        $droits = $db->simpleQuerySingle('SELECT * FROM membres_categories WHERE id = ?;', true, (int)$id);
+
+        foreach ($droits as $key=>$value)
+        {
+            unset($droits[$key]);
+            $key = str_replace('droit_', '', $key, $found);
+
+            if ($found)
+            {
+                $droits[$key] = (int) $value;
+            }
+        }
+
+        return $droits;
+    }
+
+    public function search($field, $query)
+    {
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+
+        $champs = $config->get('champs_membres');
+
+        if ($field != 'id' && !$champs->get($field))
+        {
+            throw new \UnexpectedValueException($field . ' is not a valid field');
+        }
+
+        $champ = $champs->get($field);
+
+        if ($champ['type'] == 'multiple')
+        {
+            $where = 'WHERE '.$field.' & (1 << '.(int)$query.')';
+            $order = false;
+        }
+        elseif ($champ['type'] == 'tel')
+        {
+            $query = utils::normalizePhoneNumber($query);
+            $query = preg_replace('!^0+!', '', $query);
+
+            if ($query == '')
+            {
+                return false;
+            }
+
+            $where = 'WHERE '.$field.' LIKE \'%'.$db->escapeString($query).'\'';
+            $order = $field;
+        }
+        elseif (!$champs->isText($field))
+        {
+            $where = 'WHERE '.$field.' = \''.$db->escapeString($query).'\'';
+            $order = $field;
+        }
+        else
+        {
+            $where = 'WHERE transliterate_to_ascii('.$field.') LIKE transliterate_to_ascii(\'%'.$db->escapeString($query).'%\')';
+            $order = 'transliterate_to_ascii('.$field.') COLLATE NOCASE';
+        }
+
+        $fields = array_keys($champs->getListedFields());
+
+        if (!in_array($field, $fields))
+        {
+            $fields[] = $field;
+        }
+
+        if (!in_array('email', $fields))
+        {
+            $fields[] = 'email';
+        }
+
+        return $db->simpleStatementFetch(
+            'SELECT id, id_categorie, ' . implode(', ', $fields) . ',
+                '.$config->get('champ_identite').' AS identite,
+                strftime(\'%s\', date_inscription) AS date_inscription
+                FROM membres ' . $where . ($order ? ' ORDER BY ' . $order : '') . '
+                LIMIT 1000;',
+            SQLITE3_ASSOC
+        );
+    }
+
+    public function listByCategory($cat, $fields, $page = 1, $order = null, $desc = false)
+    {
+        $begin = ($page - 1) * self::ITEMS_PER_PAGE;
+
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+
+        $champs = $config->get('champs_membres');
+
+        if (is_int($cat) && $cat)
+            $where = 'WHERE id_categorie = '.(int)$cat;
+        elseif (is_array($cat))
+            $where = 'WHERE id_categorie IN ('.implode(',', $cat).')';
+        else
+            $where = '';
+
+        if (is_null($order) || !$champs->get($order))
+            $order = 'id';
+
+        if (!empty($fields) && $order != 'id' && $champs->isText($order))
+        {
+            $order = 'transliterate_to_ascii('.$order.') COLLATE NOCASE';
+        }
+
+        if ($desc)
+        {
+            $order .= ' DESC';
+        }
+
+        if (!in_array('email', $fields))
+        {
+            $fields []= 'email';
+        }
+
+        $fields = implode(', ', $fields);
+
+        $query = 'SELECT id, id_categorie, '.$fields.', '.$config->get('champ_identite').' AS identite,
+            strftime(\'%s\', date_inscription) AS date_inscription
+            FROM membres '.$where.'
+            ORDER BY '.$order.' LIMIT ?, ?;';
+
+        return $db->simpleStatementFetch($query, SQLITE3_ASSOC, $begin, self::ITEMS_PER_PAGE);
+    }
+
+    public function countByCategory($cat = 0)
+    {
+        $db = DB::getInstance();
+
+        if (is_int($cat) && $cat)
+            $where = 'WHERE id_categorie = '.(int)$cat;
+        elseif (is_array($cat))
+            $where = 'WHERE id_categorie IN ('.implode(',', $cat).')';
+        else
+            $where = '';
+
+        return $db->simpleQuerySingle('SELECT COUNT(*) FROM membres '.$where.';');
+    }
+
+    public function countAllButHidden()
+    {
+        $db = DB::getInstance();
+        return $db->simpleQuerySingle('SELECT COUNT(*) FROM membres WHERE id_categorie NOT IN (SELECT id FROM membres_categories WHERE cacher = 1);');
+    }
+
+    static public function changeCategorie($id_cat, $membres)
+    {
+        foreach ($membres as &$id)
+        {
+            $id = (int) $id;
+        }
+
+        $db = DB::getInstance();
+        return $db->simpleUpdate('membres',
+            ['id_categorie' => (int)$id_cat],
+            'id IN ('.implode(',', $membres).')'
+        );
+    }
+
+    static protected function _deleteMembres($membres)
+    {
+        foreach ($membres as &$id)
+        {
+            $id = (int) $id;
+        }
+
+        $membres = implode(',', $membres);
+
+        $db = DB::getInstance();
+        $db->exec('UPDATE wiki_revisions SET id_auteur = NULL WHERE id_auteur IN ('.$membres.');');
+        $db->exec('UPDATE compta_journal SET id_auteur = NULL WHERE id_auteur IN ('.$membres.');');
+        //$db->exec('DELETE FROM wiki_suivi WHERE id_membre IN ('.$membres.');');
+        return $db->exec('DELETE FROM membres WHERE id IN ('.$membres.');');
+    }
+
+    public function sendMessageToCategory($dest, $sujet, $message, $subscribed_only = false)
+    {
+        $config = Config::getInstance();
+
+        $headers = [
+            'From'  =>  '"'.$config->get('nom_asso').'" <'.$config->get('email_asso').'>',
+        ];
+        $message .= "\n\n--\n".$config->get('nom_asso')."\n".$config->get('site_asso');
+
+        if ($dest == 0)
+            $where = 'id_categorie NOT IN (SELECT id FROM membres_categories WHERE cacher = 1)';
+        else
+            $where = 'id_categorie = '.(int)$dest;
+
+        if ($subscribed_only)
+        {
+            $where .= ' AND lettre_infos = 1';
+        }
+
+        $db = DB::getInstance();
+        $res = $db->query('SELECT email FROM membres WHERE LENGTH(email) > 0 AND '.$where.' ORDER BY id;');
+
+        $sujet = '['.$config->get('nom_asso').'] '.$sujet;
+
+        while ($row = $res->fetchArray(SQLITE3_ASSOC))
+        {
+            utils::mail($row['email'], $sujet, $message, $headers);
+        }
+
+        return true;
+    }
+
+    public function searchSQL($query)
+    {
+        $db = DB::getInstance();
+
+        $st = $db->prepare($query);
+
+        if (!$st->readOnly())
+        {
+            throw new UserException('Seules les requêtes en lecture sont autorisées.');
+        }
+
+        if (!preg_match('/LIMIT\s+/', $query))
+        {
+            $query = preg_replace('/;?\s*$/', '', $query);
+            $query .= ' LIMIT 100';
+        }
+
+        if (!preg_match('/FROM\s+membres(?:\s+|$|;)/i', $query))
+        {
+            throw new UserException('Seules les requêtes sur la table membres sont autorisées.');
+        }
+
+        if (preg_match('/;\s*(.+?)$/', $query))
+        {
+            throw new UserException('Une seule requête peut être envoyée en même temps.');
+        }
+
+        $st = $db->prepare($query);
+
+        $res = $st->execute();
+        $out = [];
+
+        while ($row = $res->fetchArray(SQLITE3_ASSOC))
+        {
+            if (array_key_exists('passe', $row))
+            {
+                unset($row['passe']);
+            }
+            
+            $out[] = $row;
+        }
+
+        return $out;
+    }
+
+    public function schemaSQL()
+    {
+        $db = DB::getInstance();
+
+        $tables = [
+            'membres'   =>  $db->querySingle('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'membres\';'),
+            'categories'=>  $db->querySingle('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'membres_categories\';'),
+        ];
+
+        return $tables;
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.membres_categories.php b/include/class.membres_categories.php
new file mode 100644 (file)
index 0000000..4a78e13
--- /dev/null
@@ -0,0 +1,150 @@
+<?php
+
+namespace Garradin;
+
+class Membres_Categories
+{
+    protected $droits = [
+        'inscription'=> Membres::DROIT_AUCUN,
+        'connexion' =>  Membres::DROIT_ACCES,
+        'membres'   =>  Membres::DROIT_ACCES,
+        'compta'    =>  Membres::DROIT_ACCES,
+        'wiki'      =>  Membres::DROIT_ACCES,
+        'config'    =>  Membres::DROIT_AUCUN,
+    ];
+
+    static public function getDroitsDefaut()
+    {
+        return $this->droits;
+    }
+
+    protected function _checkData(&$data)
+    {
+        $db = DB::getInstance();
+
+        if (!isset($data['nom']) || !trim($data['nom']))
+        {
+            throw new UserException('Le nom de catégorie ne peut rester vide.');
+        }
+
+        if (!empty($data['id_cotisation_obligatoire']) 
+            && !$db->simpleQuerySingle('SELECT 1 FROM cotisations WHERE id = ?;', 
+                false, (int)$data['id_cotisation_obligatoire']))
+        {
+            throw new UserException('Numéro de cotisation inconnu.');
+        }
+
+        if (isset($data['id_cotisation_obligatoire']) && empty($data['id_cotisation_obligatoire']))
+        {
+            $data['id_cotisation_obligatoire'] = null;
+        }
+    }
+
+    public function add($data)
+    {
+        $this->_checkData($data);
+
+        if (!isset($data['description']))
+        {
+            $data['description'] = '';
+        }
+
+        foreach ($this->droits as $key=>$value)
+        {
+            if (!isset($data['droit_'.$key]))
+                $data['droit_'.$key] = $value;
+            else
+                $data['droit_'.$key] = (int)$data['droit_'.$key];
+        }
+
+        $db = DB::getInstance();
+        $db->simpleInsert('membres_categories', $data);
+
+        return $db->lastInsertRowID();
+    }
+
+    public function edit($id, $data)
+    {
+        $this->_checkData($data);
+
+        foreach ($this->droits as $key=>$value)
+        {
+            if (isset($data['droit_'.$key]))
+                $data['droit_'.$key] = (int)$data['droit_'.$key];
+        }
+
+        if (!isset($data['cacher']) || $data['cacher'] != 1)
+            $data['cacher'] = 0;
+
+        $db = DB::getInstance();
+        return $db->simpleUpdate('membres_categories', $data, 'id = '.(int)$id);
+    }
+
+    public function get($id)
+    {
+        $db = DB::getInstance();
+
+        return $db->simpleQuerySingle('SELECT * FROM membres_categories WHERE id = ?;',
+            true, (int) $id);
+    }
+
+    public function remove($id)
+    {
+        $db = DB::getInstance();
+        $config = Config::getInstance();
+
+        if ($id == $config->get('categorie_membres'))
+        {
+            throw new UserException('Il est interdit de supprimer la catégorie définie par défaut dans la configuration.');
+        }
+
+        if ($db->simpleQuerySingle('SELECT 1 FROM membres WHERE id_categorie = ?;', false, (int)$id))
+        {
+            throw new UserException('La catégorie contient encore des membres, il n\'est pas possible de la supprimer.');
+        }
+
+        $db->simpleUpdate(
+            'wiki_pages',
+            [
+                'droit_lecture'     =>  Wiki::LECTURE_NORMAL,
+                'droit_ecriture'    =>  Wiki::ECRITURE_NORMAL,
+            ],
+            'droit_lecture = '.(int)$id.' OR droit_ecriture = '.(int)$id
+        );
+
+        return $db->simpleExec('DELETE FROM membres_categories WHERE id = ?;', (int) $id);
+    }
+
+    public function listSimple()
+    {
+        $db = DB::getInstance();
+        return $db->queryFetchAssoc('SELECT id, nom FROM membres_categories ORDER BY nom;');
+    }
+
+    public function listComplete()
+    {
+        $db = DB::getInstance();
+        return $db->queryFetch('SELECT * FROM membres_categories ORDER BY nom;');
+    }
+
+    public function listCompleteWithStats()
+    {
+        $db = DB::getInstance();
+        return $db->queryFetch('SELECT *, (SELECT COUNT(*) FROM membres WHERE id_categorie = membres_categories.id) AS nombre FROM membres_categories ORDER BY nom;');
+    }
+
+
+    public function listHidden()
+    {
+        $db = DB::getInstance();
+        return $db->queryFetchAssoc('SELECT id, nom FROM membres_categories WHERE cacher = 1;');
+    }
+
+    public function listNotHidden()
+    {
+        $db = DB::getInstance();
+        return $db->queryFetchAssoc('SELECT id, nom FROM membres_categories WHERE cacher = 0;');
+    }
+}
+
+?>
\ No newline at end of file
diff --git a/include/class.membres_import.php b/include/class.membres_import.php
new file mode 100644 (file)
index 0000000..eb3342a
--- /dev/null
@@ -0,0 +1,272 @@
+<?php
+
+namespace Garradin;
+
+class Membres_Import
+{
+       /**
+        * Champs du CSV de Galette
+        * les lignes vides ('') ne seront pas proposées à l'import
+        * @var array
+        */
+       public $galette_fields = [
+               'Numéro',
+               1,
+               'Nom',
+               'Prénom',
+               'Pseudo',
+               'Société',
+               2,
+               'Date de naissance',
+               3,
+               'Adresse, ligne 1',
+               'Adresse, ligne 2',
+               'Code postal',
+               'Ville',
+               'Pays',
+               'Téléphone fixe',
+               'Téléphone mobile',
+               'E-Mail',
+               'Site web',
+               'ICQ',
+               'MSN',
+               'Jabber',
+               'Infos (réservé administrateur)',
+               'Infos (public)',
+               'Profession',
+               'Identifiant',
+               'Mot de passe',
+               'Date création fiche',
+               'Date modification fiche',
+               4, // activite_adh
+               5, // bool_admin_adh
+               6, // bool_exempt_adh
+               7, // bool_display_info
+               8, // date_echeance
+               9, // pref_lang
+               'Lieu de naissance',
+               10, // GPG id
+               11 // Fingerprint
+       ];
+
+       /**
+        * Importer un CSV de la liste des membres depuis Galette
+        * @param  string $path              Chemin vers le CSV
+        * @param  array  $translation_table Tableau indiquant la correspondance à effectuer entre les champs
+        * de Galette et ceux de Garradin. Par exemple : ['Date création fiche' => 'date_inscription']
+        * @return boolean                   TRUE en cas de succès
+        */
+       public function fromGalette($path, $translation_table)
+       {
+               if (!file_exists($path) || !is_readable($path))
+               {
+                       throw new \RuntimeException('Fichier inconnu : '.$path);
+               }
+
+               $fp = fopen($path, 'r');
+
+               if (!$fp)
+               {
+                       return false;
+               }
+
+               $db = DB::getInstance();
+               $db->exec('BEGIN;');
+               $membres = new Membres;
+
+               $columns = array_flip($this->galette_fields);
+
+               $col = function($column) use (&$row, &$columns)
+               {
+                       if (!isset($columns[$column]))
+                               return null;
+
+                       if (!isset($row[$columns[$column]]))
+                               return null;
+
+                       return $row[$columns[$column]];
+               };
+
+               $line = 0;
+               $delim = utils::find_csv_delim($fp);
+
+               while (!feof($fp))
+               {
+                       $row = fgetcsv($fp, 4096, $delim);
+                       $line++;
+
+                       if (empty($row))
+                       {
+                               continue;
+                       }
+
+                       if (count($row) != count($columns))
+                       {
+                               $db->exec('ROLLBACK;');
+                               throw new UserException('Erreur sur la ligne ' . $line . ' : le nombre de colonnes est incorrect.');
+                       }
+
+                       $data = [];
+
+                       foreach ($translation_table as $galette=>$garradin)
+                       {
+                               // Champs qu'on ne veut pas importer
+                               if (empty($garradin))
+                                       continue;
+
+                               // Concaténer plusieurs champs
+                               if (isset($data[$garradin]))
+                                       $data[$garradin] .= "\n" . $col($galette);
+                               else
+                                       $data[$garradin] = $col($galette);
+                       }
+
+                       try {
+                               $membres->add($data);
+                       }
+                       catch (UserException $e)
+                       {
+                               $db->exec('ROLLBACK;');
+                               throw new UserException('Erreur sur la ligne ' . $line . ' : ' . $e->getMessage());
+                       }
+               }
+
+               $db->exec('END;');
+
+               fclose($fp);
+               return true;
+       }
+
+       /**
+        * Importer un CSV de la liste des membres depuis un export Garradin
+        * @param  string $path         Chemin vers le CSV
+        * @return boolean          TRUE en cas de succès
+        */
+       public function fromCSV($path)
+       {
+               if (!file_exists($path) || !is_readable($path))
+               {
+                       throw new \RuntimeException('Fichier inconnu : '.$path);
+               }
+
+               $fp = fopen($path, 'r');
+
+               if (!$fp)
+               {
+                       return false;
+               }
+
+               $db = DB::getInstance();
+               $db->exec('BEGIN;');
+               $membres = new Membres;
+
+               // On récupère les champs qu'on peut importer
+               $champs = Config::getInstance()->get('champs_membres')->getAll();
+               $champs = array_keys($champs);
+               $champs[] = 'date_inscription';
+               $champs[] = 'date_connexion';
+               $champs[] = 'id';
+               $champs[] = 'id_categorie';
+
+               $line = 0;
+               $delim = utils::find_csv_delim($fp);
+
+               while (!feof($fp))
+               {
+                       $row = fgetcsv($fp, 4096, $delim);
+
+                       $line++;
+
+                       if (empty($row))
+                       {
+                               continue;
+                       }
+
+                       if ($line == 1)
+                       {
+                               if (is_numeric($row[0]))
+                               {
+                                       throw new UserException('Erreur sur la ligne 1 : devrait contenir l\'en-tête des colonnes.');
+                               }
+
+                               $columns = array_flip($row);
+                               continue;
+                       }
+
+                       if (count($row) != count($columns))
+                       {
+                               $db->exec('ROLLBACK;');
+                               throw new UserException('Erreur sur la ligne ' . $line . ' : le nombre de colonnes est incorrect.');
+                       }
+
+                       $data = [];
+
+                       foreach ($columns as $name=>$id)
+                       {
+                               $name = trim($name);
+                               
+                               // Champs qui n'existent pas dans le schéma actuel
+                               if (!in_array($name, $champs))
+                                       continue;
+
+                               if (trim($row[$id]) !== '')
+                                       $data[$name] = $row[$id];
+                       }
+
+                       if (!empty($data['id']))
+                       {
+                               $id = (int)$data['id'];
+                               unset($data['id']);
+                       }
+                       else
+                       {
+                               $id = false;
+                       }
+                       
+                       try {
+                               if ($id)
+                                       $membres->edit($id, $data);
+                               else
+                                       $membres->add($data);
+                       }
+                       catch (UserException $e)
+                       {
+                               $db->exec('ROLLBACK;');
+                               throw new UserException('Erreur sur la ligne ' . $line . ' : ' . $e->getMessage());
+                       }
+               }
+
+               $db->exec('END;');
+
+               fclose($fp);
+               return true;
+       }
+
+    public function toCSV()
+    {
+        $db = DB::getInstance();
+
+        $res = $db->prepare('SELECT m.id, c.nom AS categorie, m.* FROM membres AS m 
+            LEFT JOIN membres_categories AS c ON m.id_categorie = c.id ORDER BY c.id;')->execute();
+
+        $fp = fopen('php://output', 'w');
+        $header = false;
+
+        while ($row = $res->fetchArray(SQLITE3_ASSOC))
+        {
+            unset($row['passe']);
+
+            if (!$header)
+            {
+                fputcsv($fp, array_keys($row));
+                $header = true;
+            }
+
+            fputcsv($fp, $row);
+        }
+
+        fclose($fp);
+
+        return true;
+    }
+}
\ No newline at end of file
diff --git a/include/class.plugin.php b/include/class.plugin.php
new file mode 100644 (file)
index 0000000..611e59d
--- /dev/null
@@ -0,0 +1,523 @@
+<?php
+
+namespace Garradin;
+
+class Plugin
+{
+       protected $id = null;
+       protected $plugin = null;
+
+       protected $mimes = [
+               'css' => '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/x-javascript',
+               'pdf' => 'application/pdf',
+               'png' => 'image/png',
+               'swf' => 'application/shockwave-flash',
+               'xml' => 'text/xml',
+               'svg' => 'image/svg+xml',
+       ];
+
+       /**
+        * Construire un objet Plugin pour un plugin
+        * @param string $id Identifiant du plugin
+        * @throws UserException Si le plugin n'est pas installé (n'existe pas en DB)
+        */
+       public function __construct($id)
+       {
+               $db = DB::getInstance();
+               $this->plugin = $db->simpleQuerySingle('SELECT * FROM plugins WHERE id = ?;', true, $id);
+
+               if (!$this->plugin)
+               {
+                       throw new UserException('Ce plugin n\'existe pas ou n\'est pas installé correctement.');
+               }
+
+               $this->plugin['config'] = json_decode($this->plugin['config'], true);
+               
+               if (!is_array($this->plugin['config']))
+               {
+                       $this->plugin['config'] = [];
+               }
+
+               $this->id = $id;
+       }
+
+       /**
+        * Renvoie le chemin absolu vers l'archive du plugin
+        * @return string Chemin PHAR vers l'archive
+        */
+       public function path()
+       {
+               return 'phar://' . PLUGINS_ROOT . '/' . $this->id . '.tar.gz';
+       }
+
+       /**
+        * Renvoie une entrée de la configuration ou la configuration complète
+        * @param  string $key Clé à rechercher, ou NULL si on désire toutes les entrées de la
+        * @return mixed       L'entrée demandée (mixed), ou l'intégralité de la config (array),
+        * ou NULL si l'entrée demandée n'existe pas.
+        */
+       public function getConfig($key = null)
+       {
+               if (is_null($key))
+               {
+                       return $this->plugin['config'];
+               }
+
+               if (array_key_exists($key, $this->plugin['config']))
+               {
+                       return $this->plugin['config'][$key];
+               }
+
+               return null;
+       }
+
+       /**
+        * Enregistre une entrée dans la configuration du plugin
+        * @param string $key   Clé à modifier
+        * @param mixed  $value Valeur à enregistrer, choisir NULL pour effacer cette clé de la configuration
+        * @return boolean              TRUE si tout se passe bien
+        */
+       public function setConfig($key, $value = null)
+       {
+               if (is_null($value))
+               {
+                       unset($this->plugin['config'][$key]);
+               }
+               else
+               {
+                       $this->plugin['config'][$key] = $value;
+               }
+
+               $db = DB::getInstance();
+               $db->simpleUpdate('plugins', 
+                       ['config' => json_encode($this->plugin['config'])],
+                       'id = \'' . $this->id . '\'');
+
+               return true;
+       }
+
+       /**
+        * Renvoie une information ou toutes les informations sur le plugin
+        * @param  string $key Clé de l'info à retourner, ou NULL pour recevoir toutes les infos
+        * @return mixed       Info demandée ou tableau des infos.
+        */
+       public function getInfos($key = null)
+       {
+               if (is_null($key))
+               {
+                       return $this->plugin;
+               }
+
+               if (array_key_exists($key, $this->plugin))
+               {
+                       return $this->plugin[$key];
+               }
+
+               return null;
+       }
+
+       /**
+        * Renvoie l'identifiant du plugin
+        * @return string Identifiant du plugin
+        */
+       public function id()
+       {
+               return $this->id;
+       }
+
+       /**
+        * Inclure un fichier depuis le plugin (dynamique ou statique)
+        * @param  string $file Chemin du fichier à aller chercher : si c'est un .php il sera inclus,
+        * sinon il sera juste affiché
+        * @return void
+        * @throws UserException Si le fichier n'existe pas ou fait partie des fichiers qui ne peuvent
+        * être appelés que par des méthodes de Plugin.
+        * @throws RuntimeException Si le chemin indiqué tente de sortir du contexte du PHAR
+        */
+       public function call($file)
+       {
+               $file = preg_replace('!^[./]*!', '', $file);
+
+               if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file))
+               {
+                       throw new \RuntimeException('Chemin de fichier incorrect.');
+               }
+
+               $forbidden = ['install.php', 'garradin_plugin.ini', 'upgrade.php', 'uninstall.php', 'signals.php'];
+
+               if (in_array($file, $forbidden))
+               {
+                       throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.');
+               }
+
+               if (!file_exists($this->path() . '/www/' . $file))
+               {
+                       throw new UserException('Le fichier ' . $file . ' n\'existe pas dans le plugin ' . $this->id);
+               }
+
+               $plugin = $this;
+               global $tpl, $config, $user, $membres;
+
+               if (substr($file, -4) === '.php')
+               {
+                       include $this->path() . '/www/' . $file;
+               }
+               else
+               {
+                       // Récupération du type MIME à partir de l'extension
+                       $ext = substr($file, strrpos($file, '.')+1);
+
+                       if (isset($this->mimes[$ext]))
+                       {
+                               $mime = $this->mimes[$ext];
+                       }
+                       else
+                       {
+                               $mime = 'text/plain';
+                       }
+
+                       header('Content-Type: ' .$this->mimes[$ext]);
+                       header('Content-Length: ' . filesize($this->path() . '/www/' . $file));
+
+                       readfile($this->path() . '/www/' . $file);
+               }
+       }
+
+       /**
+        * Désinstaller le plugin
+        * @return boolean TRUE si la suppression a fonctionné
+        */
+       public function uninstall()
+       {
+               if (file_exists($this->path() . '/uninstall.php'))
+               {
+                       include $this->path() . '/uninstall.php';
+               }
+               
+               unlink(PLUGINS_ROOT . '/' . $this->id . '.tar.gz');
+
+               $db = DB::getInstance();
+               return $db->simpleExec('DELETE FROM plugins WHERE id = ?;', $this->id);
+       }
+
+       /**
+        * 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 garradin_plugin.ini)
+        * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon
+        */
+       public function needUpgrade()
+       {
+               $infos = parse_ini_file($this->path() . '/garradin_plugin.ini', false);
+               
+               if (version_compare($this->plugin['version'], $infos['version'], '!='))
+                       return true;
+
+               return false;
+       }
+
+       /**
+        * Mettre à jour le plugin
+        * Appelle le fichier upgrade.php dans l'archive si celui-ci existe.
+        * @return boolean TRUE si tout a fonctionné
+        */
+       public function upgrade()
+       {
+               if (file_exists($this->path() . '/upgrade.php'))
+               {
+                       include $this->path() . '/upgrade.php';
+               }
+
+               $db = DB::getInstance();
+               return $db->simpleUpdate('plugins', 
+                       'id = \''.$db->escapeString($this->id).'\'', 
+                       ['version' => $infos['version']]);
+       }
+
+       /**
+        * Liste des plugins installés (en DB)
+        * @return array Liste des plugins triés par nom
+        */
+       static public function listInstalled()
+       {
+               $db = DB::getInstance();
+               $plugins = $db->simpleStatementFetchAssocKey('SELECT id, * FROM plugins ORDER BY nom;');
+               $system = explode(',', PLUGINS_SYSTEM);
+
+               foreach ($plugins as &$row)
+               {
+                       $row['system'] = in_array($row['id'], $system);
+               }
+
+               return $plugins;
+       }
+
+       /**
+        * Liste les plugins qui doivent être affichés dans le menu
+        * @return array Tableau associatif id => nom (ou un tableau vide si aucun plugin ne doit être affiché)
+        */
+       static public function listMenu()
+       {
+               $db = DB::getInstance();
+               return $db->simpleStatementFetchAssoc('SELECT id, nom FROM plugins WHERE menu = 1 ORDER BY nom;');
+       }
+
+       /**
+        * Liste les plugins téléchargés mais non installés
+        * @return array Liste des plugins téléchargés
+        */
+       static public function listDownloaded()
+       {
+               $installed = self::listInstalled();
+
+               $list = [];
+               $dir = dir(PLUGINS_ROOT);
+
+               while ($file = $dir->read())
+               {
+                       if (substr($file, 0, 1) == '.')
+                               continue;
+
+                       if (!preg_match('!^([a-z0-9_.-]+)\.tar\.gz$!', $file, $match))
+                               continue;
+                       
+                       if (array_key_exists($match[1], $installed))
+                               continue;
+
+                       $list[$match[1]] = parse_ini_file('phar://' . PLUGINS_ROOT . '/' . $match[1] . '.tar.gz/garradin_plugin.ini', false);
+               }
+
+               $dir->close();
+
+               return $list;
+       }
+
+       /**
+        * Liste des plugins officiels depuis le repository signé
+        * @return array Liste des plugins
+        */
+       static public function listOfficial()
+       {
+               // La liste est stockée en cache une heure pour ne pas tuer le serveur distant
+               if (Static_Cache::expired('plugins_list', 3600 * 24))
+               {
+                       $url = parse_url(PLUGINS_URL);
+
+                       $context_options = [
+                               'ssl' => [
+                                       'verify_peer'   => TRUE,
+                                       // On vérifie en utilisant le certificat maître de CACert
+                                       'cafile'        => ROOT . '/include/data/cacert.pem',
+                                       'verify_depth'  => 5,
+                                       'CN_match'      => $url['host'],
+                                       'SNI_enabled'   => true,
+                                       'SNI_server_name'               =>      $url['host'],
+                                       'disable_compression'   =>      true,
+                               ]
+                       ];
+
+                       $context = stream_context_create($context_options);
+
+                       try {
+                               $result = file_get_contents(PLUGINS_URL, NULL, $context);
+                       }
+                       catch (\Exception $e)
+                       {
+                               throw new UserException('Le téléchargement de la liste des plugins a échoué : ' . $e->getMessage());
+                       }
+
+                       Static_Cache::store('plugins_list', $result);
+               }
+               else
+               {
+                       $result = Static_Cache::get('plugins_list');
+               }
+
+               $list = json_decode($result, true);
+               return $list;
+       }
+
+       /**
+        * Vérifier le hash du plugin $id pour voir s'il correspond au hash du fichier téléchargés
+        * @param  string $id Identifiant du plugin
+        * @return boolean    TRUE si le hash correspond (intégrité OK), sinon FALSE
+        */
+       static public function checkHash($id)
+       {
+               $list = self::fetchOfficialList();
+
+               if (!array_key_exists($id, $list))
+                       return null;
+
+               $hash = sha1_file(PLUGINS_ROOT . '/' . $id . '.tar.gz');
+
+               return ($hash === $list[$id]['hash']);
+       }
+
+       /**
+        * Est-ce que le plugin est officiel ?
+        * @param  string  $id Identifiant du plugin
+        * @return boolean     TRUE si le plugin est officiel, FALSE sinon
+        */
+       static public function isOfficial($id)
+       {
+               $list = self::fetchOfficialList();
+               return array_key_exists($id, $list);
+       }
+
+       /**
+        * Télécharge un plugin depuis le repository officiel, et l'installe
+        * @param  string $id Identifiant du plugin
+        * @return boolean    TRUE si ça marche
+        * @throws LogicException Si le plugin n'est pas dans la liste des plugins officiels
+        * @throws UserException Si le plugin est déjà installé ou que le téléchargement a échoué
+        * @throws RuntimeException Si l'archive téléchargée est corrompue (intégrité du hash ne correspond pas)
+        */
+       static public function download($id)
+       {
+               $list = self::fetchOfficialList();
+
+               if (!array_key_exists($id, $list))
+               {
+                       throw new \LogicException($id . ' n\'est pas un plugin officiel (absent de la liste)');
+               }
+
+               if (file_exists(PLUGINS_ROOT . '/' . $id . '.tar.gz'))
+               {
+                       throw new UserException('Le plugin '.$id.' existe déjà.');
+               }
+
+               $url = parse_url(PLUGINS_URL);
+
+               $context_options = [
+                       'ssl' => [
+                               'verify_peer'   => TRUE,
+                               'cafile'        => ROOT . '/include/data/cacert.pem',
+                               'verify_depth'  => 5,
+                               'CN_match'      => $url['host'],
+                               'SNI_enabled'   => true,
+                               'SNI_server_name'               =>      $url['host'],
+                               'disable_compression'   =>      true,
+                       ]
+               ];
+
+               $context = stream_context_create($context_options);
+
+               try {
+                       copy($list[$id]['phar'], PLUGINS_ROOT . '/' . $id . '.tar.gz', $context);
+               }
+               catch (\Exception $e)
+               {
+                       throw new UserException('Le téléchargement du plugin '.$id.' a échoué : ' . $e->getMessage());
+               }
+
+               if (!self::checkHash($id))
+               {
+                       unlink(PLUGINS_ROOT . '/' . $id . '.tar.gz');
+                       throw new \RuntimeException('L\'archive du plugin '.$id.' est corrompue (le hash SHA1 ne correspond pas).');
+               }
+
+               self::install($id, true);
+
+               return true;
+       }
+
+       /**
+        * Installer un plugin
+        * @param  string  $id       Identifiant du plugin
+        * @param  boolean $official TRUE si le plugin est officiel
+        * @return boolean           TRUE si tout a fonctionné
+        */
+       static public function install($id, $official = false)
+       {
+               if (!file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz'))
+               {
+                       throw new \RuntimeException('Le plugin ' . $id . ' ne semble pas exister et ne peut donc être installé.');
+               }
+
+               if (!file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/garradin_plugin.ini'))
+               {
+                       throw new UserException('L\'archive '.$id.'.tar.gz n\'est pas une extension Garradin : fichier garradin_plugin.ini manquant.');
+               }
+
+               $infos = parse_ini_file('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/garradin_plugin.ini', false);
+
+               $required = ['nom', 'description', 'auteur', 'url', 'version', 'menu', 'config'];
+
+               foreach ($required as $key)
+               {
+                       if (!array_key_exists($key, $infos))
+                       {
+                               throw new \RuntimeException('Le fichier garradin_plugin.ini ne contient pas d\'entrée "'.$key.'".');
+                       }
+               }
+
+               if (!empty($infos['menu']) && !file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/www/admin/index.php'))
+               {
+                       throw new \RuntimeException('Le plugin '.$id.' ne comporte pas de fichier www/admin/index.php alors qu\'il demande à figurer au menu.');
+               }
+
+               $config = '';
+
+               if ((bool)$infos['config'])
+               {
+                       if (!file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/config.json'))
+                       {
+                               throw new \RuntimeException('L\'archive '.$id.'.tar.gz ne comporte pas de fichier config.json 
+                                       alors que le plugin nécessite le stockage d\'une configuration.');
+                       }
+
+                       if (!file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/www/admin/config.php'))
+                       {
+                               throw new \RuntimeException('L\'archive '.$id.'.tar.gz ne comporte pas de fichier www/admin/config.php 
+                                       alors que le plugin nécessite le stockage d\'une configuration.');
+                       }
+
+                       $config = json_decode(file_get_contents('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/config.json'), true);
+
+                       if (is_null($config))
+                       {
+                               throw new \RuntimeException('config.json invalide. Code erreur JSON: ' . json_last_error());
+                       }
+
+                       $config = json_encode($config);
+               }
+
+               $db = DB::getInstance();
+               $db->simpleInsert('plugins', [
+                       'id'            =>      $id,
+                       'officiel'      =>      (int)(bool)$official,
+                       'nom'           =>      $infos['nom'],
+                       'description'=> $infos['description'],
+                       'auteur'        =>      $infos['auteur'],
+                       'url'           =>      $infos['url'],
+                       'version'       =>      $infos['version'],
+                       'menu'          =>      (int)(bool)$infos['menu'],
+                       'config'        =>      $config,
+               ]);
+
+               if (file_exists('phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/install.php'))
+               {
+                       include 'phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz/install.php';
+               }
+
+               return true;
+       }
+
+       /**
+        * Renvoie la version installée d'un plugin ou FALSE s'il n'est pas installé
+        * @param  string $id Identifiant du plugin
+        * @return mixed      Numéro de version du plugin ou FALSE
+        */
+       static public function getInstalledVersion($id)
+       {
+               return DB::getInstance()->simpleQuerySingle('SELECT version FROM plugins WHERE id = ?;');
+       }
+}
\ No newline at end of file
diff --git a/include/class.rappels.php b/include/class.rappels.php
new file mode 100644 (file)
index 0000000..d970cf6
--- /dev/null
@@ -0,0 +1,208 @@
+<?php
+
+namespace Garradin;
+
+class Rappels
+{
+       /**
+        * Vérification des champs fournis pour la modification de donnée
+        * @param  array $data Tableau contenant les champs à ajouter/modifier
+        * @return void
+        */
+       protected function _checkFields(&$data)
+       {
+               $db = DB::getInstance();
+
+        if (empty($data['id_cotisation'])
+               || !$db->simpleQuerySingle('SELECT 1 FROM cotisations WHERE id = ?;', false, (int) $data['id_cotisation']))
+        {
+            throw new UserException('Cotisation inconnue.');
+        }
+
+               $data['id_cotisation'] = (int) $data['id_cotisation'];
+
+               if ((trim($data['delai']) === '') || !is_numeric($data['delai']))
+               {
+                       throw new UserException('Délai avant rappel invalide : doit être indiqué en nombre de jours.');
+               }
+
+               $data['delai'] = (int) $data['delai'];
+
+               if (!isset($data['sujet']) || trim($data['sujet']) === '')
+               {
+                       throw new UserException('Le sujet du rappel ne peut être vide.');
+               }
+
+               $data['sujet'] = trim($data['sujet']);
+
+               if (!isset($data['texte']) || trim($data['texte']) === '')
+               {
+                       throw new UserException('Le contenu du rappel ne peut être vide.');
+               }
+
+               $data['texte'] = trim($data['texte']);
+       }
+
+       /**
+        * Ajouter un rappel
+        * @param array $data Données du rappel
+        * @return integer Numéro ID du rappel créé
+        */
+       public function add($data)
+       {
+               $db = DB::getInstance();
+
+               $this->_checkFields($data);
+
+               $db->simpleInsert('rappels', $data);
+
+               return $db->lastInsertRowId();
+       }
+
+       /**
+        * Modifier un rappel automatique
+        * @param  integer      $id   Numéro du rappel
+        * @param  array        $data Données du rappel
+        * @return boolean        TRUE si tout s'est bien passé
+        * @throws UserException  En cas d'erreur dans une donnée à modifier
+        */
+       public function edit($id, $data)
+       {
+               $db = DB::getInstance();
+
+               $this->_checkFields($data);
+
+               return $db->simpleUpdate('rappels', $data, 'id = ' . (int)$id);
+       }
+
+       /**
+        * Supprimer un rappel automatique
+        * @param  integer $id Numéro du rappel
+        * @param  boolean $delete_history Effacer aussi l'historique des rappels envoyés
+        * @return boolean     TRUE en cas de succès
+        */
+       public function delete($id, $delete_history = false)
+       {
+               $db = DB::getInstance();
+
+               $db->exec('BEGIN;');
+
+               if ($delete_history)
+               {
+                       $db->simpleExec('DELETE FROM rappels_envoyes WHERE id_rappel = ?;', (int) $id);
+               }
+               else
+               {
+                       $db->simpleExec('UPDATE rappels_envoyes SET id_rappel = NULL WHERE id_rappel = ?;', (int) $id);
+               }
+
+               $db->simpleExec('DELETE FROM rappels WHERE id = ?;', (int) $id);
+               $db->exec('END;');
+
+               return true;
+       }
+
+       /**
+        * Renvoie les données sur un rappel
+        * @param  integer $id Numéro du rappel
+        * @return array     Données du rappel
+        */
+       public function get($id)
+       {
+               return DB::getInstance()->simpleQuerySingle('SELECT * FROM rappels WHERE id = ?;', true, (int)$id);
+       }
+
+       /**
+        * Renvoie le nombre de rappels automatiques enregistrés
+        * @return integer Nombre de rappels
+        */
+       public function countAll()
+       {
+               return DB::getInstance()->simpleQuerySingle('SELECT COUNT(*) FROM rappels;');
+       }
+
+       /**
+        * Liste des rappels triés par cotisation
+        * @return array Liste des rappels
+        */
+       public function listByCotisation()
+       {
+               return DB::getInstance()->simpleStatementFetch('SELECT r.*,
+                       c.intitule, c.montant, c.duree, c.debut, c.fin
+                       FROM rappels AS r
+                       INNER JOIN cotisations AS c ON c.id = r.id_cotisation
+                       ORDER BY r.id_cotisation, r.delai, r.sujet;');
+       }
+
+       /**
+        * Liste des rappels pour une cotisation donnée
+        * @param  integer $id Numéro du rappel
+        * @return array     Liste des rappels
+        */
+       public function listForCotisation($id)
+       {
+               return DB::getInstance()->simpleStatementFetch('SELECT * FROM rappels 
+                       WHERE id_cotisation = ? ORDER BY delai, sujet;', \SQLITE3_ASSOC, (int)$id);
+       }
+
+       /**
+        * Envoi des rappels automatiques par e-mail
+        * @return boolean TRUE en cas de succès
+        */
+       public function sendPending()
+       {
+               $db = DB::getInstance();
+               $config = Config::getInstance();
+
+               // Requête compliquée qui fait tout le boulot
+               // la logique est un JOIN des tables rappels, cotisations, cotisations_membres et membres
+               // pour récupérer la liste des membres qui doivent recevoir une cotisation
+               $query = '
+               SELECT 
+                       *,
+                       /* Nombre de jours avant ou après expiration */
+                       (julianday(date()) - julianday(expiration)) AS nb_jours,
+                       /* Date de mise en œuvre du rappel */
+                       date(expiration, delai || \' days\') AS date_rappel
+               FROM (
+                       SELECT m.*, r.delai, r.sujet, r.texte, r.id_cotisation,
+    &nbs