Changes In Branch dev Excluding Merge-Ins

This is equivalent to a diff from 2899c91022 to 8817cad74d

2023-09-27
15:22
Fix typo : parent -> parente Leaf check-in: 8817cad74d user: bohwaz tags: dev
14:52
Fix breadcrumbs when selecting a parent page check-in: 1cf59f1e3c user: bohwaz tags: dev, 1.3.0-rc14
2023-09-13
15:03
Merge with trunk check-in: 03930d1c1f user: bohwaz tags: dev
2023-09-10
16:54
Use Date object for dates without timestamp Leaf check-in: 2899c91022 user: bohwaz tags: trunk, stable
2023-09-04
23:27
Rebuild users indexes after the collation has change in version 1.2.10 check-in: ab68937246 user: bohwaz tags: trunk, stable, 1.2.11

Modified .fossil-settings/ignore-glob from [97a282b8eb] to [962019af50].

1
2
3
4
5
6








src/data/*
src/*.tar.gz
src/*.asc
src/*.log
src/include/lib/KD2
src/debug_sql.sqlite














>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
src/data/*
src/*.tar.gz
src/*.asc
src/*.log
src/include/lib/KD2
src/debug_sql.sqlite
src/modules/*
src/config.local.php
build/windows/*.exe
build/windows/php.zip
build/windows/install_dir
build/*.tar.gz*
build/debian/*.deb
src/psalm.phar

Modified build/debian/config.debian.php from [88a7161654] to [f3119a8708].

1
2
3
4

5











6
7
8
9
10
11
12
<?php

namespace Garradin;


const ENABLE_UPGRADES = false;












if (!empty($_ENV['PAHEKO_STANDALONE']))
{
	$home = $_ENV['HOME'];

	// Config directory
	if (empty($_ENV['XDG_CONFIG_HOME']))


|

>

>
>
>
>
>
>
>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace Paheko;

const SQLITE_JOURNAL_MODE = 'WAL';
const ENABLE_UPGRADES = false;

if (shell_exec('which pdftotext')) {
	define('Paheko\PDFTOTEXT_COMMAND', 'pdftotext');
}

if (shell_exec('which ssconvert')) {
	define('Paheko\CALC_CONVERT_COMMAND', 'ssconvert');
}
elseif (shell_exec('which unoconv')) {
	define('Paheko\CALC_CONVERT_COMMAND', 'unoconv');
}

if (!empty($_ENV['PAHEKO_STANDALONE']))
{
	$home = $_ENV['HOME'];

	// Config directory
	if (empty($_ENV['XDG_CONFIG_HOME']))
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106







107
108
109
110
111
112
113
114
115






		rename($_ENV['XDG_DATA_HOME'] . '/garradin', $_ENV['XDG_DATA_HOME'] . '/paheko');
	}

	if (!file_exists($_ENV['XDG_DATA_HOME'] . '/paheko')) {
		mkdir($_ENV['XDG_DATA_HOME'] . '/paheko', 0700, true);
	}

	if (!defined('Garradin\DATA_ROOT')) {
		define('Garradin\DATA_ROOT', $_ENV['XDG_DATA_HOME'] . '/paheko');
	}

	// Cache directory: temporary stuff
	if (empty($_ENV['XDG_CACHE_HOME']))
	{
		$_ENV['XDG_CACHE_HOME'] = $home . '/.cache';
	}

	if (file_exists($_ENV['XDG_CACHE_HOME'] . '/garradin')) {
		rename($_ENV['XDG_CACHE_HOME'] . '/garradin', $_ENV['XDG_CACHE_HOME'] . '/paheko');
	}

	if (!file_exists($_ENV['XDG_CACHE_HOME'] . '/paheko'))
	{
		mkdir($_ENV['XDG_CACHE_HOME'] . '/paheko', 0700, true);
	}

	if (!defined('Garradin\CACHE_ROOT')) {
		define('Garradin\CACHE_ROOT', $_ENV['XDG_CACHE_HOME'] . '/paheko');
	}

	if (!defined('Garradin\DB_FILE')) {
		$last_file = $_ENV['XDG_CONFIG_HOME'] . '/paheko/last';

		if ($_ENV['PAHEKO_STANDALONE'] != 1)
		{
			$last_sqlite = trim($_ENV['PAHEKO_STANDALONE']);
		}
		else if (file_exists($last_file))
		{
			$last_sqlite = trim(file_get_contents($last_file));

		}
		else
		{
			$last_sqlite = $_ENV['XDG_DATA_HOME'] . '/paheko/association.sqlite';
		}

		file_put_contents($last_file, $last_sqlite);

		define('Garradin\DB_FILE', $last_sqlite);
	}

	if (!defined('Garradin\LOCAL_LOGIN')) {
		define('Garradin\LOCAL_LOGIN', -1);
	}
}
elseif (isset($_SERVER['SERVER_NAME'])) {
	if (file_exists('/etc/paheko/config.php')) {
		require_once '/etc/paheko/config.php';
	}

	if (!defined('Garradin\DATA_ROOT')) {
		define('Garradin\DATA_ROOT', '/var/lib/paheko');
	}

	if (!defined('Garradin\CACHE_ROOT')) {
		define('Garradin\CACHE_ROOT', '/var/cache/paheko');
	}
}








if (!defined('Garradin\SECRET_KEY')) {
	if (file_exists(CACHE_ROOT . '/key')) {
		define('Garradin\SECRET_KEY', trim(file_get_contents(CACHE_ROOT . '/key')));
	}
	else {
		define('Garradin\SECRET_KEY', base64_encode(random_bytes(64)));
		file_put_contents(CACHE_ROOT . '/key', SECRET_KEY);
	}
}













|
|

















|
|


|









>








|


|
|


|




|
|


|
|



>
>
>
>
>
>
>
|

|


|



>
>
>
>
>
>
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
		rename($_ENV['XDG_DATA_HOME'] . '/garradin', $_ENV['XDG_DATA_HOME'] . '/paheko');
	}

	if (!file_exists($_ENV['XDG_DATA_HOME'] . '/paheko')) {
		mkdir($_ENV['XDG_DATA_HOME'] . '/paheko', 0700, true);
	}

	if (!defined('Paheko\DATA_ROOT')) {
		define('Paheko\DATA_ROOT', $_ENV['XDG_DATA_HOME'] . '/paheko');
	}

	// Cache directory: temporary stuff
	if (empty($_ENV['XDG_CACHE_HOME']))
	{
		$_ENV['XDG_CACHE_HOME'] = $home . '/.cache';
	}

	if (file_exists($_ENV['XDG_CACHE_HOME'] . '/garradin')) {
		rename($_ENV['XDG_CACHE_HOME'] . '/garradin', $_ENV['XDG_CACHE_HOME'] . '/paheko');
	}

	if (!file_exists($_ENV['XDG_CACHE_HOME'] . '/paheko'))
	{
		mkdir($_ENV['XDG_CACHE_HOME'] . '/paheko', 0700, true);
	}

	if (!defined('Paheko\CACHE_ROOT')) {
		define('Paheko\CACHE_ROOT', $_ENV['XDG_CACHE_HOME'] . '/paheko');
	}

	if (!defined('Paheko\DB_FILE')) {
		$last_file = $_ENV['XDG_CONFIG_HOME'] . '/paheko/last';

		if ($_ENV['PAHEKO_STANDALONE'] != 1)
		{
			$last_sqlite = trim($_ENV['PAHEKO_STANDALONE']);
		}
		else if (file_exists($last_file))
		{
			$last_sqlite = trim(file_get_contents($last_file));
			$last_sqlite = str_replace('.local/share/garradin', '.local/share/paheko', $last_sqlite);
		}
		else
		{
			$last_sqlite = $_ENV['XDG_DATA_HOME'] . '/paheko/association.sqlite';
		}

		file_put_contents($last_file, $last_sqlite);

		define('Paheko\DB_FILE', $last_sqlite);
	}

	if (!defined('Paheko\LOCAL_LOGIN')) {
		define('Paheko\LOCAL_LOGIN', -1);
	}
}
else {
	if (file_exists('/etc/paheko/config.php')) {
		require_once '/etc/paheko/config.php';
	}

	if (!defined('Paheko\DATA_ROOT')) {
		define('Paheko\DATA_ROOT', '/var/lib/paheko');
	}

	if (!defined('Paheko\CACHE_ROOT')) {
		define('Paheko\CACHE_ROOT', '/var/cache/paheko');
	}
}

if (file_exists(DATA_ROOT . '/plugins')) {
	define('Paheko\PLUGINS_ROOT', DATA_ROOT  . '/plugins');
}
else {
	define('Paheko\PLUGINS_ROOT', __DIR__ . '/plugins');
}

if (!defined('Paheko\SECRET_KEY')) {
	if (file_exists(CACHE_ROOT . '/key')) {
		define('Paheko\SECRET_KEY', trim(file_get_contents(CACHE_ROOT . '/key')));
	}
	else {
		define('Paheko\SECRET_KEY', base64_encode(random_bytes(64)));
		file_put_contents(CACHE_ROOT . '/key', SECRET_KEY);
	}
}

// Disable PDF for CLI server
if (PHP_SAPI == 'cli-server' && !defined('Paheko\PDF_COMMAND') && !file_exists(PLUGINS_ROOT . '/dompdf')) {
	define('Paheko\PDF_COMMAND', null);
}

Modified build/debian/makedeb.sh from [ed98f1ed48] to [dd8d4f904f].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
# Ripped from fossil makdedeb.sh

DEB_REV=${1-1} # .deb package build/revision number.
PACKAGE_DEBNAME=paheko
THISDIR=${PWD}

DEB_ARCH_NAME=all

PACKAGE_VERSION=`cat ../../src/VERSION`

[ ! -f ../../src/paheko-${PACKAGE_VERSION}.tar.gz ] && (cd ../../src; make release)

tar xzvf ../../src/paheko-${PACKAGE_VERSION}.tar.gz -C /tmp

SRCDIR="/tmp/paheko-${PACKAGE_VERSION}"

test -e ${SRCDIR} || {
    echo "This script must be run from a BUILT copy of the source tree."
    exit 1
}











|

|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
# Ripped from fossil makdedeb.sh

DEB_REV=${1-1} # .deb package build/revision number.
PACKAGE_DEBNAME=paheko
THISDIR=${PWD}

DEB_ARCH_NAME=all

PACKAGE_VERSION=`cat ../../src/VERSION`

[ ! -f ../paheko-${PACKAGE_VERSION}.tar.gz ] && (cd ../../src; make release)

tar xzvf ../paheko-${PACKAGE_VERSION}.tar.gz -C /tmp

SRCDIR="/tmp/paheko-${PACKAGE_VERSION}"

test -e ${SRCDIR} || {
    echo "This script must be run from a BUILT copy of the source tree."
    exit 1
}
34
35
36
37
38
39
40

41
42
43
44
45
46
47
48
mkdir -p "${DEBLOCALPREFIX}/share/applications"
cp ${THISDIR}/paheko.desktop "${DEBLOCALPREFIX}/share/applications/"

CODEDIR=${DEBLOCALPREFIX}/share/${PACKAGE_DEBNAME}
mkdir -p ${CODEDIR}
cp -r ${SRCDIR}/* ${CODEDIR}
cp ${THISDIR}/config.debian.php ${CODEDIR}/config.local.php

rm -rf ${CODEDIR}/*.sqlite ${CODEDIR}/cache ${CODEDIR}/www/squelettes ${CODEDIR}/www/plugins/*
cp ${THISDIR}/paheko.png "${CODEDIR}"

mkdir -p "${DEBROOT}/var/lib/${PACKAGE_DEBNAME}"
mkdir -p "${DEBROOT}/var/cache/${PACKAGE_DEBNAME}"
mkdir -p "${DEBROOT}/etc/${PACKAGE_DEBNAME}"

# Cleaning files that will be copied to /usr/share/doc







>
|







34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
mkdir -p "${DEBLOCALPREFIX}/share/applications"
cp ${THISDIR}/paheko.desktop "${DEBLOCALPREFIX}/share/applications/"

CODEDIR=${DEBLOCALPREFIX}/share/${PACKAGE_DEBNAME}
mkdir -p ${CODEDIR}
cp -r ${SRCDIR}/* ${CODEDIR}
cp ${THISDIR}/config.debian.php ${CODEDIR}/config.local.php
mv ${CODEDIR}/data/plugins ${CODEDIR}/plugins
rm -rf ${CODEDIR}/*.sqlite ${CODEDIR}/data
cp ${THISDIR}/paheko.png "${CODEDIR}"

mkdir -p "${DEBROOT}/var/lib/${PACKAGE_DEBNAME}"
mkdir -p "${DEBROOT}/var/cache/${PACKAGE_DEBNAME}"
mkdir -p "${DEBROOT}/etc/${PACKAGE_DEBNAME}"

# Cleaning files that will be copied to /usr/share/doc
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
    echo "Generating ${CONTROL}..."
    cat <<EOF > ${CONTROL}
Package: ${PACKAGE_DEBNAME}
Section: web
Priority: optional
Maintainer: Paheko <paheko@paheko.eu>
Architecture: ${DEB_ARCH_NAME}
Depends: dash | bash, php-cli (>=7.4), php-sqlite3, php-intl, sensible-utils
Version: ${PACKAGE_DEB_VERSION}
Suggests: php-gd, php-imagick
Replaces: garradin (<< 1.2.3~)
Breaks: garradin (<< 1.2.3~)
Homepage: https://fossil.kd2.org/paheko/
Description: Paheko is a tool to manage non-profit organizations.
 It's only available in french.
Description-fr: Gestionnaire d'association en interface web ou CLI.
 Paheko est un gestionnaire d'association à but non lucratif.







|

|







124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
    echo "Generating ${CONTROL}..."
    cat <<EOF > ${CONTROL}
Package: ${PACKAGE_DEBNAME}
Section: web
Priority: optional
Maintainer: Paheko <paheko@paheko.eu>
Architecture: ${DEB_ARCH_NAME}
Depends: dash | bash, php-cli (>=7.4), php-sqlite3, php-intl, php-mbstring, sensible-utils
Version: ${PACKAGE_DEB_VERSION}
Suggests: php-imagick
Replaces: garradin (<< 1.2.3~)
Breaks: garradin (<< 1.2.3~)
Homepage: https://fossil.kd2.org/paheko/
Description: Paheko is a tool to manage non-profit organizations.
 It's only available in french.
Description-fr: Gestionnaire d'association en interface web ou CLI.
 Paheko est un gestionnaire d'association à but non lucratif.

Modified build/debian/paheko from [e989bd5817] to [a6de924cf5].

41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
    -h|--help)
      cat <<EOF
Usage : $0 [COMMANDE] [PROJET]

Où COMMANDE peut être :

        server [-p|--port PORT] [-b|--bind IP]
                Démarre un serveur web Garradin sur le port spécifié
                (8081 par défaut) et l'IP spécifiée (127.0.0.1 par défaut)

        ui [-p|--port PORT] [-b|--bind IP]
                Idem que 'server' mais démarre ensuite le navigateur web par défaut
                et connecte automatiquement avec le premier administrateur
                de l'association.








|







41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
    -h|--help)
      cat <<EOF
Usage : $0 [COMMANDE] [PROJET]

Où COMMANDE peut être :

        server [-p|--port PORT] [-b|--bind IP]
                Démarre un serveur web Paheko sur le port spécifié
                (8081 par défaut) et l'IP spécifiée (127.0.0.1 par défaut)

        ui [-p|--port PORT] [-b|--bind IP]
                Idem que 'server' mais démarre ensuite le navigateur web par défaut
                et connecte automatiquement avec le premier administrateur
                de l'association.

92
93
94
95
96
97
98


99
100
101
102
103
104
105
PROJECT="$2"

[ "$PROJECT" = "" ] && PROJECT="1"

export PAHEKO_STANDALONE="$PROJECT"

[ -f $PID_FILE ] && kill `cat $PID_FILE` > /dev/null 2>&1 && rm -f $PID_FILE



[ $VERBOSE = 1 ] && {
    php -S ${ADDRESS}:${PORT} -t ${ROOT} -d variables_order=EGPCS ${ROUTER} &
} || {
    php -S ${ADDRESS}:${PORT} -t ${ROOT} -d variables_order=EGPCS ${ROUTER} > /dev/null 2>&1 &
}








>
>







92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
PROJECT="$2"

[ "$PROJECT" = "" ] && PROJECT="1"

export PAHEKO_STANDALONE="$PROJECT"

[ -f $PID_FILE ] && kill `cat $PID_FILE` > /dev/null 2>&1 && rm -f $PID_FILE

PHP_CLI_SERVER_WORKER=2

[ $VERBOSE = 1 ] && {
    php -S ${ADDRESS}:${PORT} -t ${ROOT} -d variables_order=EGPCS ${ROUTER} &
} || {
    php -S ${ADDRESS}:${PORT} -t ${ROOT} -d variables_order=EGPCS ${ROUTER} > /dev/null 2>&1 &
}

Modified build/windows/Makefile from [151de88204] to [7b464ebe75].

1
2


3
4
5
6
7
8
9
.PHONY := php installer clean publish
PHP_ARCHIVE := https://windows.php.net/downloads/releases/php-8.0.27-nts-Win32-vs16-x64.zip



php.zip:
	wget ${PHP_ARCHIVE} -O php.zip

php: php.zip
	mkdir -p install_dir/php
	unzip -o php.zip -d install_dir/php > /dev/null

|
>
>







1
2
3
4
5
6
7
8
9
10
11
.PHONY := php installer clean publish
PHP_ARCHIVE := https://windows.php.net/downloads/releases/php-8.2.10-nts-Win32-vs16-x64.zip

all: installer

php.zip:
	wget ${PHP_ARCHIVE} -O php.zip

php: php.zip
	mkdir -p install_dir/php
	unzip -o php.zip -d install_dir/php > /dev/null
53
54
55
56
57
58
59


60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
		php_xsl.dll \
		php_zend_test.dll

	du -hs install_dir/php

installer: clean php
	$(eval VERSION=$(shell cat ../../src/VERSION))


	mkdir -p install_dir
	cp ../../src/paheko-${VERSION}.tar.gz install_dir/
	cd install_dir && tar xzf paheko-${VERSION}.tar.gz && mv paheko-${VERSION} paheko
	cp config.local.php install_dir/paheko/
	cp php.ini install_dir/php
	cp launch.bat install_dir
	cp paheko.ico install_dir
	rm -f install_dir/paheko-${VERSION}.tar.gz
	makensis -V3 -DVERSION=${VERSION} paheko.nsis

clean:
	rm -rf install_dir

publish:
	$(eval VERSION=$(shell cat ../../src/VERSION))
	fossil uv ls | grep '^paheko-.*\.exe' | xargs fossil uv rm
	fossil uv add paheko-${VERSION}.exe
	fossil uv sync







>
>

|






|









55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
		php_xsl.dll \
		php_zend_test.dll

	du -hs install_dir/php

installer: clean php
	$(eval VERSION=$(shell cat ../../src/VERSION))
	# NSIS only accepts numbers as version
	$(eval NSIS_VERSION=$(shell sed -E 's/-(alpha|beta|rc)[0-9]+//' ../../src/VERSION))
	mkdir -p install_dir
	cp ../paheko-${VERSION}.tar.gz install_dir/
	cd install_dir && tar xzf paheko-${VERSION}.tar.gz && mv paheko-${VERSION} paheko
	cp config.local.php install_dir/paheko/
	cp php.ini install_dir/php
	cp launch.bat install_dir
	cp paheko.ico install_dir
	rm -f install_dir/paheko-${VERSION}.tar.gz
	makensis -V3 -DNVERSION=${NSIS_VERSION} -DVERSION=${VERSION} paheko.nsis

clean:
	rm -rf install_dir

publish:
	$(eval VERSION=$(shell cat ../../src/VERSION))
	fossil uv ls | grep '^paheko-.*\.exe' | xargs fossil uv rm
	fossil uv add paheko-${VERSION}.exe
	fossil uv sync

Modified build/windows/config.local.php from [4f250a82d1] to [bce1077cb9].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

namespace Garradin;

if (!empty(getenv('LOCALAPPDATA'))) {
	// Store data in user AppData directory
	define('Garradin\DATA_ROOT', trim(getenv('LOCALAPPDATA'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'Paheko');
}

// Store secret key in user directory
if (!defined('Garradin\SECRET_KEY')) {
	if (file_exists(DATA_ROOT . '/key')) {
		define('Garradin\SECRET_KEY', trim(file_get_contents(DATA_ROOT . '/key')));
	}
	else {
		define('Garradin\SECRET_KEY', base64_encode(random_bytes(16)));
		file_put_contents(DATA_ROOT . '/key', SECRET_KEY);
	}
}

// Always log in as admin user
const LOCAL_LOGIN = -1;

// Disable PDF export
const PDF_COMMAND = null;

// Disable e-mails as Windows is not able to send e-mails
const DISABLE_EMAIL = true;


|



|



|

|


|












1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

namespace Paheko;

if (!empty(getenv('LOCALAPPDATA'))) {
	// Store data in user AppData directory
	define('Paheko\DATA_ROOT', trim(getenv('LOCALAPPDATA'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'Paheko');
}

// Store secret key in user directory
if (!defined('Paheko\SECRET_KEY')) {
	if (file_exists(DATA_ROOT . '/key')) {
		define('Paheko\SECRET_KEY', trim(file_get_contents(DATA_ROOT . '/key')));
	}
	else {
		define('Paheko\SECRET_KEY', base64_encode(random_bytes(16)));
		file_put_contents(DATA_ROOT . '/key', SECRET_KEY);
	}
}

// Always log in as admin user
const LOCAL_LOGIN = -1;

// Disable PDF export
const PDF_COMMAND = null;

// Disable e-mails as Windows is not able to send e-mails
const DISABLE_EMAIL = true;

Modified build/windows/paheko.nsis from [8c0f2b68e1] to [8f73d399b0].

18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
!define UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}"
!define REG_START_MENU "Start Menu Folder"

var SM_Folder

######################################################################

VIProductVersion  "${VERSION}.0"
VIAddVersionKey "ProductName"  "${APP_NAME}"
VIAddVersionKey "CompanyName"  "${COMP_NAME}"
VIAddVersionKey "LegalCopyright"  "${COPYRIGHT}"
VIAddVersionKey "FileDescription"  "${DESCRIPTION}"
VIAddVersionKey "FileVersion"  "${VERSION}"

######################################################################







|







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
!define UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}"
!define REG_START_MENU "Start Menu Folder"

var SM_Folder

######################################################################

VIProductVersion "${NVERSION}.0"
VIAddVersionKey "ProductName"  "${APP_NAME}"
VIAddVersionKey "CompanyName"  "${COMP_NAME}"
VIAddVersionKey "LegalCopyright"  "${COPYRIGHT}"
VIAddVersionKey "FileDescription"  "${DESCRIPTION}"
VIAddVersionKey "FileVersion"  "${VERSION}"

######################################################################

Modified build/windows/php.ini from [799dda8484] to [43ac89d677].

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
ignore_repeated_errors = Off
ignore_repeated_source = Off
report_memleaks = On
variables_order = "GPCS"
request_order = "GP"
register_argc_argv = Off
auto_globals_jit = On
post_max_size = 8M
auto_prepend_file =
auto_append_file =
default_mimetype = "text/html"
default_charset = "UTF-8"
doc_root =
user_dir =
extension_dir = "ext"
enable_dl = Off
file_uploads = On
upload_max_filesize = 2M
max_file_uploads = 20
allow_url_fopen = On
allow_url_include = Off
default_socket_timeout = 60

extension=fileinfo
extension=gd







|









|







23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
ignore_repeated_errors = Off
ignore_repeated_source = Off
report_memleaks = On
variables_order = "GPCS"
request_order = "GP"
register_argc_argv = Off
auto_globals_jit = On
post_max_size = 256M
auto_prepend_file =
auto_append_file =
default_mimetype = "text/html"
default_charset = "UTF-8"
doc_root =
user_dir =
extension_dir = "ext"
enable_dl = Off
file_uploads = On
upload_max_filesize = 256M
max_file_uploads = 20
allow_url_fopen = On
allow_url_include = Off
default_socket_timeout = 60

extension=fileinfo
extension=gd

Modified doc/admin/brindille.md from [c51352c8ee] to [a8ac5faee9].

1
2
3

4
5
6
7
8
9
10
11




12
13
14
15









16
17
18
19
20
21
22
23
24
25
26
27
28

29
30
31
32
33
34
35
36
37
38
39
40
41









42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78







































































































79
80
81
82
83
84
85
86
87
88
89
Title: Documentation du langage Brindille dans Paheko

{{{.nav

* **[Documentation Brindille](brindille.html)**
* [Fonctions](brindille_functions.html)
* [Sections](brindille_sections.html)
* [Filtres](brindille_modifiers.html)
}}}

<<toc aside>>





La syntaxe utilisée dans Paheko pour les squelettes du site web s'appelle **Brindille**. Si vous avez déjà fait de la programmation, elle ressemble à un mélange de Mustache, Smarty, Twig et PHP.

Son but est de permettre une grande flexibilité, sans avoir à utiliser un "vrai" langage de programmation, mais en s'en rapprochant suffisamment quand même.










# Syntaxe de base

## Affichage de variable

Une variable est affichée à l'aide de la syntaxe : `{{$date}}` affichera la valeur brute de la date par exemple : `2020-01-31 16:32:00`.

La variable peut être modifiée à l'aide de filtres de modification, qui sont ajoutés avec le symbole de la barre verticale (pipe `|`) : `{{$date|date_long}}` affichera une date au format long : `jeudi 7 mars 2021`.

Ces filtres peuvent accepter des paramètres, séparés par deux points `:`. Exemple : `{{$date|date:"%d/%m/%Y"}}` affichera `31/01/2020`.

Par défaut la variable sera recherchée dans le contexte actuel de la section, si elle n'est pas trouvée elle sera recherchée dans le contexte parent (section parente), etc. jusqu'à trouver la variable.

Il est possible de faire référence à une variable d'un contexte particulier avec la notation à points : `{{$article.date}}`.


Il existe deux variables de contexte spécifiques : `$_POST` et `$_GET` qui permettent d'accéder aux données envoyées dans un formulaire et dans les paramètres de la page.

Par défaut le filtre `escape` est appliqué à toutes les variables pour protéger les variables contre les injections de code HTML. Ce filtre est appliqué en dernier, après les autres filtres. Il est possible de contourner cet automatisme en rajoutant le filtre `escape` ou `raw` explicitement. `raw` désactive tout échappement, mais `escape` est utilisé pour changer l'ordre d'échappement. Exemple :

```
{{:assign text = "Coucou
ça va ?" }}
{{$text|escape|nl2br}}
```

Donnera bien `Coucou<br />ça va ?`. Sans indiquer le filtre `escape` le résultat serait `Coucou&lt;br /&gt;ça va ?`.










### Ordre de recherche des variables

Par défaut les variables sont recherchées dans l'ordre inverse, c'est à dire que sont d'abord recherchées les variables avec le nom demandé dans la section courante. Si la variable n'existe pas dans la section courante, alors elle est recherchée dans la section parente, et ainsi de suite jusqu'à ce que la variable soit trouvée, où qu'il n'y ait plus de section parente.

Prenons cet exemple :

```
{{#articles uri="Actualite"}}
  <h1>{{$title}}</h1>
    {{#images parent=$path limit=1}}
      <img src="{{$thumb_url}}" alt="{{$title}}" />
    {{/images}}
{{/articles}}
```

Dans la section `articles`, `$title` est une variable de l'article, donc la variable est celle de l'article.

Dans la section `images`, les images n'ayant pas de titre, la variable sera celle de l'article de la section parente, alors que `$thumb_url` sera lié à l'image.

### Conflit de noms de variables

Imaginons que nous voulions mettre un lien vers l'article sur l'image de l'exemple précédent :

```
{{#articles uri="Actualite"}}
  <h1>{{$title}}</h1>
    {{#images parent=$path limit=1}}
      <a href="{{$url}}"><img src="{{$thumb_url}}" alt="{{$title}}" /></a>
    {{/images}}
{{/articles}}
```

Problème, ici `$url` fera référence à l'URL de l'image elle-même, et non pas l'URL de l'article.

La solution est d'ajouter un point au début du nom de variable : `{{$.url}}`.

Un point au début d'un nom de variable signifie que la variable est recherchée à partir de la section précédente. Il est possible d'utiliser plusieurs points, chaque point correspond à un niveau à remonter. Ainsi `$.url` cherchera la variable dans la section parente (et ses sections parentes si elle n'existe pas, etc.). De même, `$..url` cherchera dans la section parente de la section parente.








































































































## Conditions

Il est possible d'utiliser des conditions de type "si" (`if`), "sinon si" (`elseif`) et "sinon" (`else`). Celles-ci sont terminées par un block "fin si" (`/if`).

```
{{if $date|date:"%Y" > 2020}}
    La date est en 2020
{{elseif $article.status == 'draft'}}
    La page est un brouillon
{{else}}



>








>
>
>
>
|



>
>
>
>
>
>
>
>
>












|
>



|


|
<



|

>
>
>
>
>
>
>
>
>
|


















|







|









>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>



|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
Title: Documentation du langage Brindille dans Paheko

{{{.nav
* [Modules](modules.html)
* **[Documentation Brindille](brindille.html)**
* [Fonctions](brindille_functions.html)
* [Sections](brindille_sections.html)
* [Filtres](brindille_modifiers.html)
}}}

<<toc aside>>

# Introduction

La syntaxe utilisée dans les squelettes du site web et des modules s'appelle **Brindille**.

Si vous avez déjà fait de la programmation, elle ressemble à un mélange de Mustache, Smarty, Twig et PHP.

Son but est de permettre une grande flexibilité, sans avoir à utiliser un "vrai" langage de programmation, mais en s'en rapprochant suffisamment quand même.

## Fichiers

Un fichier texte contenant du code Brindille est appelé un **squelette**.

Seuls les fichiers ayant une des extensions `.tpl`, `.html`, `.htm`, `.skel` ou `.xml` seront traités par Brindille.
De même, les fichiers qui n'ont pas d'extension seront également traités par Brindille.

Les autres types de fichiers seront renvoyés sans traitement, comme des fichiers "bruts". En d'autres termes, il n'est pas possible de mettre du code *Brindille* dans des fichiers qui ne sont pas des fichiers textes.

# Syntaxe de base

## Affichage de variable

Une variable est affichée à l'aide de la syntaxe : `{{$date}}` affichera la valeur brute de la date par exemple : `2020-01-31 16:32:00`.

La variable peut être modifiée à l'aide de filtres de modification, qui sont ajoutés avec le symbole de la barre verticale (pipe `|`) : `{{$date|date_long}}` affichera une date au format long : `jeudi 7 mars 2021`.

Ces filtres peuvent accepter des paramètres, séparés par deux points `:`. Exemple : `{{$date|date:"%d/%m/%Y"}}` affichera `31/01/2020`.

Par défaut la variable sera recherchée dans le contexte actuel de la section, si elle n'est pas trouvée elle sera recherchée dans le contexte parent (section parente), etc. jusqu'à trouver la variable.

Il est possible de faire référence à une variable d'un contexte particulier avec la notation à points : `{{$article.date}}`.  
La même syntaxe est utilisée pour accéder aux membres d'un tableau : `{{$labels.new_page}}`.

Il existe deux variables de contexte spécifiques : `$_POST` et `$_GET` qui permettent d'accéder aux données envoyées dans un formulaire et dans les paramètres de la page.

Par défaut le filtre `escape` est appliqué à toutes les variables pour protéger les variables contre les injections de code HTML. Ce filtre est appliqué en dernier, après les autres filtres. Il est possible de contourner cet automatisme en rajoutant le filtre `escape` ou `raw` explicitement. `raw` désactive tout échappement, alors que `escape` est utilisé pour changer l'ordre d'échappement. Exemple :

```
{{:assign text = "Coucou\nça va ?" }}

{{$text|escape|nl2br}}
```

Donnera bien `Coucou<br />ça va ?`. Si on n'avait pas indiqué le filtre `escape` le résultat serait `Coucou&lt;br /&gt;ça va ?`.

### Échappement des caractères spéciaux dans les chaînes de caractère

Pour inclure un caractère spécial (retour de ligne, guillemets ou apostrophe) dans une chaîne de caractère il suffit d'utiliser un antislash :

```
{{:assign text="Retour \n à la ligne"}}
{{:assign text="Utiliser des \"apostrophes\"}}
```

## Ordre de recherche des variables

Par défaut les variables sont recherchées dans l'ordre inverse, c'est à dire que sont d'abord recherchées les variables avec le nom demandé dans la section courante. Si la variable n'existe pas dans la section courante, alors elle est recherchée dans la section parente, et ainsi de suite jusqu'à ce que la variable soit trouvée, où qu'il n'y ait plus de section parente.

Prenons cet exemple :

```
{{#articles uri="Actualite"}}
  <h1>{{$title}}</h1>
    {{#images parent=$path limit=1}}
      <img src="{{$thumb_url}}" alt="{{$title}}" />
    {{/images}}
{{/articles}}
```

Dans la section `articles`, `$title` est une variable de l'article, donc la variable est celle de l'article.

Dans la section `images`, les images n'ayant pas de titre, la variable sera celle de l'article de la section parente, alors que `$thumb_url` sera lié à l'image.

## Conflit de noms de variables

Imaginons que nous voulions mettre un lien vers l'article sur l'image de l'exemple précédent :

```
{{#articles uri="Actualite"}}
  <h1>{{$title}}</h1>
    {{#images parent=$path limit=1}}
      
    {{/images}}
{{/articles}}
```

Problème, ici `$url` fera référence à l'URL de l'image elle-même, et non pas l'URL de l'article.

La solution est d'ajouter un point au début du nom de variable : `{{$.url}}`.

Un point au début d'un nom de variable signifie que la variable est recherchée à partir de la section précédente. Il est possible d'utiliser plusieurs points, chaque point correspond à un niveau à remonter. Ainsi `$.url` cherchera la variable dans la section parente (et ses sections parentes si elle n'existe pas, etc.). De même, `$..url` cherchera dans la section parente de la section parente.

## Création manuelle de variable

### Variable simple

La création d'une variable se fait via l'appel de la fonction `{{:assign}}`.

Exemple :

```
{{:assign source='wiki'}}
{{* est identique à : *}}
{{:assign var='source' value='wiki'}}
```

Un deuxième appel à `{{:assign}}` avec le même nom de variable écrase la valeur précédente

```
{{:assign var='source' value='wiki'}}
{{:assign var='source' value='documentation'}}

{{$source}}
{{* => Affiche documentation *}}
```

### Nom de variable dynamique

Il est possible de créer une variable dont une partie du nom est dynamique.

```
{{:assign type='user'}}
{{:assign var='allowed_%s'|args:$type value='jeanne'}}
{{:assign type='side'}}
{{:assign var='allowed_%s'|args:$type value='admin'}}

{{$allowed_user}} => jeanne
{{$allowed_side}} => admin
```

[Documentation complète de la fonction {{:assign}}](brindille_functions.html#assign).

### Tableaux *(array)*

Pour créer des tableaux, il suffit d'utiliser des points `.` dans le nom de la variable (ex : `colors.yellow`). Il n'y a pas besoin d'initialiser le tableau avant de le remplir.

```
{{:assign var='colors.admin' value='blue'}}
{{:assign var='colors.website' value='grey'}}
{{:assign var='colors.debug' value='yellow'}}
```

On accède ensuite à la valeur d'un élément du tableau avec la même syntaxe : `{{$colors.website}}`

Méthode rapide de création du même tableau :

```
{{:assign var='colors' admin='blue' website='grey' debug='yellow'}}
```

Pour ajouter un élément à la suite du tableau sans spécifier de clef *(push)*, il suffit de terminer le nom de la variable par un point `.` sans suffixe.

Exemple :

```
{{* Ajouter les valeurs 17, 43 et 214 dans $processed_ids *}}

{{:assign var='processed_ids.' value=17}}
{{:assign var='processed_ids.' value=43}}
{{:assign var='processed_ids.' value=214}}
```

#### Clef dynamique de tableau

Il est possible d'accéder dynamiquement à un des éléments d'un tableau de la manière suivante :

```
{{:assign location='admin'}}
{{:assign var='location_color' from='colors.%s'|args:$location}}

{{$location_color}} => blue
```

Exemple plus complexe :

```
{{:assign var='type_whitelist.text' value=1}}
{{:assign var='type_whitelist.html' value=1}}

{{#foreach from=$documents item='document'}}
  {{:assign var='allowed' value='type_whitelist.%s'|args:$document->type}}
  {{if $allowed !== null}}
    {{:include file='document/'|cat:$type:'.tpl' keep='document'}}
  {{/if}}
{{/foreach}}
```

Il est également possible de créer un membre dynamique d'un tableau en conjuguant les syntaxes précédentes.

Exemple :

```
{{:assign var='type_whitelist.%s'|args:$type value=1}}
```

## Conditions

Il est possible d'utiliser des conditions de type **"si"** (`if`), **"sinon si"** (`elseif`) et **"sinon"** (`else`). Celles-ci sont terminées par un block **"fin si"** (`/if`).

```
{{if $date|date:"%Y" > 2020}}
    La date est en 2020
{{elseif $article.status == 'draft'}}
    La page est un brouillon
{{else}}
130
131
132
133
134
135
136














137
138


139


140
141
142
143
144
145
146
| `||` | Vrai si une des conditions à gauche ou à droite est vraie |

Exemples :

* `false && true` : sera évalué comme faux
* `false || true` : sera évalué comme vrai















## Fonctions



Une fonction va répondre à certains paramètres et renvoyer un résultat ou réaliser une action. Un bloc de fonction commence par le signe deux points `:` :



```
{{:http code=404}}
```

Contrairement aux autres types de blocs, et comme pour les variables, il n'y a pas de bloc fermant (avec un slash `/`).








>
>
>
>
>
>
>
>
>
>
>
>
>
>


>
>
|
>
>







256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
| `||` | Vrai si une des conditions à gauche ou à droite est vraie |

Exemples :

* `false && true` : sera évalué comme faux
* `false || true` : sera évalué comme vrai


### Tester si une variable existe

Brindille ne fait pas de différences entre une variable qui n'existe pas, et une variable définie à `null`.
On peut donc tester l'existence d'une variable en la comparant à `null` comme ceci :

```
{{if $session !== null}}
  Session en cours pour l'utilisateur/trice {{$session.user.name}}.
{{else}}
  Session inexistante.
{{/if}}
```

## Fonctions

### Fonctions natives

Une fonction va répondre à certains paramètres et renvoyer un résultat ou réaliser une action.

**Un bloc de fonction commence par le signe deux points `:`.**

```
{{:http code=404}}
```

Contrairement aux autres types de blocs, et comme pour les variables, il n'y a pas de bloc fermant (avec un slash `/`).

161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192

193
194
195
196
197
198
199
200
201
202
203
204
205
206

207
208
209
210
211
212
213
214
215
216
217
218
219



220
221
222
223
224
225
226


















































Un exemple de sous-section

```
{{#categories uri=$_GET.uri}}
    <h1>{{$title}}</h1>

    {{#articles parent=$path order="published DESC" limit="10"}}
        <h2><a href="{{$url}}">{{$title}}</a></h2>
        <p>{{$content|truncate:600:"..."}}</p>
    {{else}}
        <p>Aucun article trouvé.</p>
    {{/articles}}

{{/categories}}
```

Voir la référence des sections pour voir quelles sont les sections possibles et quel est leur comportement.

## Sections litérales

Pour qu'une partie du code ne soit pas interprété, pour éviter les conflits avec certaines syntaxes, il est possible d'utiliser un bloc `literal` :

```
{{literal}}
<script>
// Ceci ne sera pas interprété
function test (a) {{
}}
</script>
{{/literal}}
```


### Commentaires

Les commentaires sont figurés dans des blocs qui commencent et se terminent par une étoile (`*`) :

```
{{* Ceci est un commentaire
Il sera supprimé du résultat final
Il peut contenir du code qui ne sera pas interprété :
{{if $test}}
OK
{{/if}}
*}}
```


## Référence des variables définies par défaut

Ces variables sont définies tout le temps :

| Nom | Contenu |
| - | - |
| `$_GET` | Alias de la super-globale _GET de PHP. |
| `$_POST` | Alias de la super-globale _POST de PHP. |
| `$root_url` | Adresse racine du site web Paheko. |
| `$request_url` | Adresse de la page courante. |
| `$admin_url` | Adresse de la racine de l'administration Paheko. |
| `$visitor_lang` | Langue préférée du visiteur, sur 2 lettres (exemple : `fr`, `en`, etc.). |
| `$logged_user` | Informations sur le membre actuellement connecté dans l'administration (vide si non connecté). |



| `$config.org_name` | Nom de l'association |
| `$config.org_email` | Adresse e-mail de l'association |
| `$config.org_phone` | Numéro de téléphone de l'association |
| `$config.org_address` | Adresse postale de l'association |
| `$config.org_web` | Adresse du site web de l'association |
| `$config.files.logo` | Adresse du logo de l'association, si définit dans la personnalisation |
| `$config.files.favicon` | Adresse de l'icône de favoris de l'association, si définit dans la personnalisation |

























































|










|













>
|













>
|



|
|







>
>
>






|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
Un exemple de sous-section

```
{{#categories uri=$_GET.uri}}
    <h1>{{$title}}</h1>

    {{#articles parent=$path order="published DESC" limit="10"}}
        <h2></h2>
        <p>{{$content|truncate:600:"..."}}</p>
    {{else}}
        <p>Aucun article trouvé.</p>
    {{/articles}}

{{/categories}}
```

Voir la référence des sections pour voir quelles sont les sections possibles et quel est leur comportement.

## Bloc litéral

Pour qu'une partie du code ne soit pas interprété, pour éviter les conflits avec certaines syntaxes, il est possible d'utiliser un bloc `literal` :

```
{{literal}}
<script>
// Ceci ne sera pas interprété
function test (a) {{
}}
</script>
{{/literal}}
```


## Commentaires

Les commentaires sont figurés dans des blocs qui commencent et se terminent par une étoile (`*`) :

```
{{* Ceci est un commentaire
Il sera supprimé du résultat final
Il peut contenir du code qui ne sera pas interprété :
{{if $test}}
OK
{{/if}}
*}}
```


# Liste des variables définies par défaut

Ces variables sont définies tout le temps :

| Nom de la variable | Valeur |
| :- | :- |
| `$_GET` | Alias de la super-globale _GET de PHP. |
| `$_POST` | Alias de la super-globale _POST de PHP. |
| `$root_url` | Adresse racine du site web Paheko. |
| `$request_url` | Adresse de la page courante. |
| `$admin_url` | Adresse de la racine de l'administration Paheko. |
| `$visitor_lang` | Langue préférée du visiteur, sur 2 lettres (exemple : `fr`, `en`, etc.). |
| `$logged_user` | Informations sur le membre actuellement connecté dans l'administration (vide si non connecté). |
| `$dialog` | Vaut `TRUE` si la page est dans un dialogue (iframe sous forme de pop-in dans l'administration). |
| `$now` | Contient la date et heure courante. |
| `$legal_line` | Contient la ligne de bas de page des mentions légales (sous forme de code HTML) qui doit être présente en bas des pages publiques. |
| `$config.org_name` | Nom de l'association |
| `$config.org_email` | Adresse e-mail de l'association |
| `$config.org_phone` | Numéro de téléphone de l'association |
| `$config.org_address` | Adresse postale de l'association |
| `$config.org_web` | Adresse du site web de l'association |
| `$config.files.logo` | Adresse du logo de l'association, si définit dans la personnalisation |
| `$config.files.favicon` | Adresse de l'icône de favoris de l'association, si défini dans la personnalisation |
| `$config.files.signature` | Adresse de l'image de signature, si défini dans la personnalisation |

À celles-ci s'ajoutent [les variables spéciales des modules](modules.html#variables_speciales) lorsque le script est chargé dans un module.

# Erreurs

Si une erreur survient dans un squelette, que ça soit au niveau d'une erreur de syntaxe, ou une erreur dans une fonction, filtre ou section, alors elle sera affichée selon les règles suivantes :

* si le membre connecté est administrateur, une erreur est affichée avec le code du squelette ;
* sinon l'erreur est affichée sans le code.


# Avertissement sur la sécurité des requêtes SQL

Attention, en utilisant la section `{{#select ...}}`, ou une des sections SQL (voir plus bas), avec des paramètres qui ne seraient pas protégés, il est possible qu'une personne mal intentionnée ait accès à des parties de la base de données à laquelle vous ne désirez pas donner accès.

Pour protéger contre cela il est essentiel d'utiliser les paramètres nommés.

Exemple de requête dangereuse :

```
{{#sql select="*" tables="users" where="id = %s"|args:$_GET.id}}
...
{{/sql}}
```

On se dit que la requête finale sera donc : `SELECT * FROM users WHERE id = 42;` si le numéro 42 est passé dans le paramètre `id` de la page.

Imaginons qu'une personne mal-intentionnée indique dans le paramètre `id` de la page la chaîne de caractère suivante : `0 OR 1`. Dans ce cas la requête exécutée sera  `SELECT * FROM users WHERE id = 0 OR 1;`. Cela aura pour effet de lister tous les membres, au lieu d'un seul.

Pour protéger contre cela il convient d'utiliser un paramètre nommé :

```
{{#sql select="*" tables="users" where="id = :id" :id=$_GET.id}}
```

Dans ce cas la requête malveillante générée sera `SELECT * FROM users WHERE id = '0 OR 1';`. Ce qui aura pour effet de ne lister aucun membre.

## Mesures prises pour la sécurité des données

Dans Brindille, il n'est pas possible de modifier ou supprimer des éléments dans la base de données avec les requêtes SQL directement. Seules les requêtes SQL en lecture (`SELECT`) sont permises.

Cependant certaines fonctions permettent de modifier ou créer des éléments précis (écritures par exemple), ce qui peut avoir un effet de remplir ou modifier des données par une personne mal-intentionnée, donc attention à leur utilisation.

Les autres mesures prises sont :

* impossibilité d'accéder à certaines données sensibles (mot de passe, logs de connexion, etc.)
* incitation forte à utiliser les paramètres nommés dans la documentation
* protection automatique des variables dans la section `{{#select}}`
* fourniture de fonctions pour protéger les chaînes de caractères contre l'injection SQL

Modified doc/admin/brindille_functions.md from [5a9c2c0ee5] to [4ffcccd4be].

1
2
3

4
5
6
7
8
9
10
11


12
13
14











15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32














33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58














































59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76





















77
78
79
80
81
82
83
84
85
86

87
88
89
90
91
92
93
94

95
96
97
98
99
100
101
102


103
104
105
106


107
108

109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141

142





143

144



145

146

147


148



149


150
151


















































152
153
154











155
156
157
158
159
160
161
162
163
164
165

166
167
168
169
170
171
172
173
174
175
176
177
178













































































































































































































































































































Title: Référence des fonctions Brindille

{{{.nav

* [Documentation Brindille](brindille.html)
* **[Fonctions](brindille_functions.html)**
* [Sections](brindille_sections.html)
* [Filtres](brindille_modifiers.html)
}}}

<<toc aside>>



# assign

Permet d'assigner une valeur dans une variable :












```
{{:assign blabla="Coucou"}}

{{$blabla}}
```

Attention : certains noms de variables ne sont pas assignables : `value` et `var` (voir ci-dessous).

Il est possible d'assigner toutes les variables d'une section dans une variable en utilisant la syntaxe `.` et en inversant (`.="nom_de_variable"`). Cela permet de capturer le contenu d'une section pour le réutiliser à un autre endroit.

```
{{#pages uri="Informations" limit=1}}
{{:assign .="infos"}}
{{/pages}}

{{$infos.title}}
```















En utilisant le paramètre spécial `var`, tous les autres paramètres passés sont ajoutés à la variable donnée en valeur :

```
{{:assign var="tableau" label="Coucou" name="Pif le chien"}}
{{$tableau.label}}
{{$tableau.name}}
```

De la même manière on peut écraser une variable avec le paramètre spécial `value`:

```
{{:assign var="tableau" value=$infos}}
```

Il est également possible de créer des tableaux avec la syntaxe `[]` dans le nom de la variable :

```
{{:assign var="liste[comptes][530]" label="Caisse"}}
{{:assign var="liste[comptes][512]" label="Banque"}}

{{#foreach from=$liste.comptes}}
{{$key}} = {{$value.label}}
{{/foreach}}
```















































# debug

Cette fonction permet d'afficher le contenu d'une ou plusieurs variables :

```
{{:debug test=$title}}
```

Affichera :

```
array(1) {
  ["test"] => string(6) "coucou"
}
```

Si aucun paramètre n'est spécifié, alors toutes les variables définies sont renvoyées. Utile pour découvrir quelles sont les variables accessibles dans une section par exemple.






















# http

Permet de modifier les entêtes HTTP renvoyés par la page. Cette fonction doit être appelée au tout début du squelette, avant tout autre code ou ligne vide.

| Paramètre | Fonction |
| - | - |
| `code` | Modifie le code HTTP renvoyé. [Liste des codes HTTP](https://fr.wikipedia.org/wiki/Liste_des_codes_HTTP) |
| `redirect` | Rediriger vers l'adresse URI indiquée en valeur. Seules les adresses internes sont acceptées, il n'est pas possible de rediriger vers une adresse extérieure. |
| `type` | Modifie le type MIME renvoyé |
| `download` | Force la page à être téléchargée sous le nom indiqué. |


Note : si le type `application/pdf` est indiqué, la page sera convertie en PDF à la volée. Il est possible de forcer le téléchargement du fichier en utilisant le paramètre `download`.

Exemples :

```
{{:http code=404}}
{{:http redirect="/Nos-Activites/"}}

{{:http type="application/svg+xml"}}
{{:http type="application/pdf" download="liste_membres_ca.pdf"}}
```

# include

Permet d'inclure un autre squelette.



| Paramètre | Fonction |
| - | - |
| `file` | obligatoire | Nom du squelette à inclure |
| `keep` | optionnel | Liste de noms de variables à conserver |



```

{{:include file="./navigation.html"}}
=> Affiche le contenu du squelette "navigation.html" dans le même répertoire que le squelette d'origine
```

Par défaut, les variables du squelette parent sont transmis au squelette inclus, mais les variables définies dans le squelette inclus ne sont pas transmises au squelette parent. Exemple :

```
{{* Squelette page.html *}}
{{:assign title="Super titre !"}}
{{:include file="./_head.html"}}
{{$nav}}
```
```
{{* Squelette _head.html *}}
<h1>{{$title}}</h1>
{{:assign nav="Accueil > %s"|args:$title}}
```

Dans ce cas, la dernière ligne du premier squelette (`{{$nav}}`) n'affichera rien, car la variable définie dans le second squelette n'en sortira pas. Pour indiquer qu'une variable doit être incluse dans le squelette parent, il faut utiliser le paramètre `keep`:

```
{{:include file="./_head.html" keep="nav"}}
```

On peut spécifier plusieurs noms de variables, séparés par des virgules, et utiliser la notation à points :

```
{{:include file="./_head.html" keep="nav,article.title,name"}}
{{$nav}}
{{$article.title}}
{{$name}}
```


# mail







Permet d'envoyer un e-mail à une adresse indiquée. Le message est toujours envoyé en format texte et l'expéditeur est l'adresse de l'association.





Attention à l'utilisation de cette fonction il n'existe pas de limite d'envoi.




Paramètres requis :






| Paramètre | Fonction |
| - | - |


















































| `to` | Adresse email destinataire (seule l'adresse e-mail elle-même est acceptée, pas de nom) |
| `subject` | Sujet du message |
| `body` | Corps du message |












Exemple de formulaire de contact :

```
{{if !$_POST.email|check_email}}
<p class="alert">L'adresse e-mail indiquée est invalide.</p>
{{elseif $_POST.antispam != 42}}
<p class="alert">La réponse permettant de savoir si vous êtes un robot a échoué. Vous êtes donc un robot ?</p>
{{elseif $_POST.message|trim == ''}}
<p class="alert">Le message est vide</p>
{{elseif $_POST.send}}

{{:mail to=$config.org_email subject="Formulaire de contact" body="%s a écrit : %s"|args:$_POST.email:$_POST.message}}
<p class="ok">Votre message nous a bien été transmis !</p>
{{/if}}

<form method="post" action="">
<dl>
  <dt><label>Votre e-mail : <input type="email" required name="email" /></label></dt>
  <dt><label>Votre message : <textarea required name="message" cols="50" rows="5"></textarea></label></dt>
  <dt><label>Merci d'écrire "quarante-deux" en chiffres pour confirmer que vous n'êtes pas un robot : <input type="text" name="antispam" required /></label></dt>
</dl>
<p><input type="submit" name="send" value="Envoyer !" /></p>
</form>
```
















































































































































































































































































































>








>
>
|

|
>
>
>
>
>
>
>
>
>
>
>







<
<
|








>
>
>
>
>
>
>
>
>
>
>
>
>
>















|


|
|






>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|

















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|



|
|
|
|
|
|
>

|






>

|


|



>
>
|
|
|
|
>
>


>

<
















|














>
|
>
>
>
>
>

>
|
>
>
>

>
|
>

>
>
|
>
>
>

>
>

|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
>
>
>
>
>
>
>
>
>
>
>





|
<
<

|

>
|
|






|




>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35


36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209

210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339


340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
Title: Référence des fonctions Brindille

{{{.nav
* [Modules](modules.html)
* [Documentation Brindille](brindille.html)
* **[Fonctions](brindille_functions.html)**
* [Sections](brindille_sections.html)
* [Filtres](brindille_modifiers.html)
}}}

<<toc aside>>

# Fonctions généralistes

## assign

Permet d'assigner une valeur dans une variable.

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `.` | optionnel | Assigner toutes les variables du contexte (section) actuel |
| `var` | optionnel | Nom de la variable à créer ou modifier |
| `value` | optionnel | Valeur de la variable |
| `from` | optionnel | Recopier la valeur depuis la variable ayant le nom fourni dans ce paramètre. |

Tous les autres paramètres sont considérés comme des variables à assigner.

Exemple :

```
{{:assign blabla="Coucou"}}

{{$blabla}}
```



Il est possible d'assigner toutes les variables d'une section dans une variable en utilisant le paramètre point `.` (`.="nom_de_variable"`). Cela permet de capturer le contenu d'une section pour le réutiliser à un autre endroit.

```
{{#pages uri="Informations" limit=1}}
{{:assign .="infos"}}
{{/pages}}

{{$infos.title}}
```

Il est aussi possible de remonter dans les sections parentes en utilisant plusieurs points. Ainsi deux points remonteront à la section parente, trois points à la section parente de la section parente, etc.

```
{{#foreach from=$infos item="info"}}
  {{#foreach from=$info item="sous_info"}}
    {{if $sous_info.titre == 'Coucou'}}
      {{:assign ..="info_importante"}}
    {{/if}}
  {{/foreach}}
{{/foreach}}

{{$info_importante.titre}}
```

En utilisant le paramètre spécial `var`, tous les autres paramètres passés sont ajoutés à la variable donnée en valeur :

```
{{:assign var="tableau" label="Coucou" name="Pif le chien"}}
{{$tableau.label}}
{{$tableau.name}}
```

De la même manière on peut écraser une variable avec le paramètre spécial `value`:

```
{{:assign var="tableau" value=$infos}}
```

Il est également possible de créer des tableaux avec la syntaxe `.` dans le nom de la variable :

```
{{:assign var="liste.comptes.530" label="Caisse"}}
{{:assign var="liste.comptes.512" label="Banque"}}

{{#foreach from=$liste.comptes}}
{{$key}} = {{$value.label}}
{{/foreach}}
```

Il est possible de rajouter des éléments à un tableau simplement en utilisant un point seul :

```
{{:assign var="liste.comptes." label="530 - Caisse"}}
{{:assign var="liste.comptes." label="512 - Banque"}}
```

Enfin, il est possible de faire référence à une variable de manière dynamique en utilisant le paramètre spécial `from` :

```
{{:assign var="tableau" a="Coucou" b="Test !"}}
{{:assign var="titre" from="tableau.%s"|args:"b"}}
{{$titre}} -> Affichera "Test !", soit la valeur de {{$tableau.b}}
```

## break

Interrompt une section.

## continue

Passe à l'itération suivante d'une section. Le code situé entre cette instruction et la fin de la section ne sera pas exécuté.

```
{{#foreach from=$list item="event"}}
  {{if $event.date == '2023-01-01'}}
    {{:continue}}
  {{/if}}
  {{$event.title}}
{{/foreach}}
```

Il est possible de passer à l'itération suivante d'une section parente en utilisant un chiffre en paramètre :

```
{{#foreach from=$list item="event"}}
  {{$event.title}}
  {{#foreach from=$event.people item="person"}}
    {{if $person.name == 'bohwaz'}}
      {{:continue 2}}
    {{/if}}
    - {{$person.name}}
  {{/foreach}}
{{/foreach}}
```

## debug

Cette fonction permet d'afficher le contenu d'une ou plusieurs variables :

```
{{:debug test=$title}}
```

Affichera :

```
array(1) {
  ["test"] => string(6) "coucou"
}
```

Si aucun paramètre n'est spécifié, alors toutes les variables définies sont renvoyées. Utile pour découvrir quelles sont les variables accessibles dans une section par exemple.


## error

Affiche un message d'erreur et arrête le traitement à cet endroit.

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `message` | **obligatoire** | Message d'erreur à afficher |

Exemple :

```
{{if $_POST.nombre != 42}}
	{{:error message="Le nombre indiqué n'est pas 42"}}
{{/if}}
```

## form_errors

Affiche les erreurs du formulaire courant (au format HTML).

## http

Permet de modifier les entêtes HTTP renvoyés par la page. Cette fonction doit être appelée au tout début du squelette, avant tout autre code ou ligne vide.

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `code` | *optionnel* | Modifie le code HTTP renvoyé. [Liste des codes HTTP](https://fr.wikipedia.org/wiki/Liste_des_codes_HTTP) |
| `redirect` | *optionnel* | Rediriger vers l'adresse URL indiquée en valeur. |
| `type` | *optionnel* | Modifie le type MIME renvoyé |
| `download` | *optionnel* | Force la page à être téléchargée sous le nom indiqué. |
| `inline` | *optionnel* | Force la page à être affichée, et peut ensuite être téléchargée sous le nom indiqué (utile pour la généraion de PDF : permet d'afficher le PDF dans le navigateur avant de le télécharger). |

Note : si le type `application/pdf` est indiqué (ou juste `pdf`), la page sera convertie en PDF à la volée. Il est possible de forcer le téléchargement du fichier en utilisant le paramètre `download`.

Exemples :

```
{{:http code=404}}
{{:http redirect="/Nos-Activites/"}}
{{:http redirect="https://mon-site-web.tld/"}}
{{:http type="application/svg+xml"}}
{{:http type="pdf" download="liste_membres_ca.pdf"}}
```

## include

Permet d'inclure un autre squelette.

Paramètres :

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `file` | **obligatoire** | Nom du squelette à inclure |
| `keep` | *optionnel* | Liste de noms de variables à conserver |
| `capture` | *optionnel* | Si renseigné, au lieu d'afficher le squelette, son contenu sera enregistré dans la variable de ce nom. |
| … | *optionnel* | Tout autre paramètre sera utilisé comme variable qui n'existea qu'à l'intérieur du squelette inclus. |

```
{{* Affiche le contenu du squelette "navigation.html" dans le même répertoire que le squelette d'origine *}}
{{:include file="./navigation.html"}}

```

Par défaut, les variables du squelette parent sont transmis au squelette inclus, mais les variables définies dans le squelette inclus ne sont pas transmises au squelette parent. Exemple :

```
{{* Squelette page.html *}}
{{:assign title="Super titre !"}}
{{:include file="./_head.html"}}
{{$nav}}
```
```
{{* Squelette _head.html *}}
<h1>{{$title}}</h1>
{{:assign nav="Accueil > %s"|args:$title}}
```

Dans ce cas, la dernière ligne du premier squelette (`{{$nav}}`) n'affichera rien, car la variable définie dans le second squelette n'en sortira pas. Pour indiquer qu'une variable doit être transmise au squelette parent, il faut utiliser le paramètre `keep`:

```
{{:include file="./_head.html" keep="nav"}}
```

On peut spécifier plusieurs noms de variables, séparés par des virgules, et utiliser la notation à points :

```
{{:include file="./_head.html" keep="nav,article.title,name"}}
{{$nav}}
{{$article.title}}
{{$name}}
```

On peut aussi capturer le résultat d'un squelette dans une variable :

```
{{:include file="./_test.html" capture="test"}}
{{:assign var="test" value=$test|replace:'TITRE':'Ceci est un titre'}}
{{$test}}
```

Il est possible d'assigner de nouvelles variables au contexte du include en les déclarant comme paramètres tout comme on le ferait avec `{{:assign}}` :

```
{{:include file="./_head.html" title='%s documentation'|args:$doc.label visitor=$user}}
```

## captcha

Permet de générer une question qui doit être répondue correctement par l'utilisateur pour valider une action. Utile pour empêcher les robots spammeurs d'effectuer une action.

L'utilisation simplifiée utilise un de ces deux paramètres :

| Paramètre | Fonction |
| :- | :- |
| `html` | Si `true`, crée un élément de formulaire HTML et le texte demandant à l'utilisateur de répondre à la question |
| `verify` | Si `true`, vérifie que l'utilisateur a correctement répondu à la question |

L'utilisation avancée utilise d'abord ces deux paramètres :

| Paramètre | Fonction |
| :- | :- |
| `assign_hash` | Nom de la variable où assigner le hash (à mettre dans un `<input type="hidden" />`) |
| `assign_number` | Nom de la variable où assigner le nombre de la question (à afficher à l'utilisateur) |

Puis on vérifie :

| Paramètre | Fonction |
| :- | :- |
| `verify_hash` | Valeur qui servira comme hash de vérification (valeur du `<input type="hidden" />`) |
| `verify_number` | Valeur qui représente la réponse de l'utilisateur |
| `assign_error` | Si spécifié, le message d'erreur sera placé dans cette variable, sinon il sera affiché directement. |

Exemple :

```
{{if $_POST.send}}
  {{:captcha verify_hash=$_POST.h verify_number=$_POST.n assign_error="error"}}
  {{if $error}}
    <p class="alert">Mauvaise réponse</p>
  {{else}}
    ...
  {{/if}}
{{/if}}

<form method="post" action="">
{{:captcha assign_hash="hash" assign_number="number"}}
<p>Merci de recopier le nombre suivant en chiffres : <tt>{{$number}}</tt></p>
<p>
  <input type="text" name="n" placeholder="1234" />
  <input type="hidden" name="h" value="{{$hash}}" />
  <input type="submit" name="send" />
</p>
</form>
```

## mail

Permet d'envoyer un e-mail à une ou des adresses indiquées (sous forme de tableau).

Restrictions :

* le message est toujours envoyé en format texte ;
* l'expéditeur est toujours l'adresse de l'association ;
* l'envoi est limité à une seule adresse e-mail externe (adresse qui n'est pas celle d'un membre) dans une page ;
* l'envoi est limité à maximum 10 adresses e-mails internes (adresses de membres) dans une page ;
* un message envoyé à une adresse e-mail externe ne peut pas contenir une adresse web (`https://...`) autre que celle de l'association.

Note : il est également conseillé d'utiliser la fonction `captcha` pour empêcher l'envoi de spam.

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `to` | **obligatoire** | Adresse email destinataire (seule l'adresse e-mail elle-même est acceptée, pas de nom) |
| `subject` | **obligatoire** | Sujet du message |
| `body` | **obligatoire** | Corps du message |
| `block_urls` | *optionnel* | (`true` ou `false`) Permet de bloquer l'envoi si le message contient une adresse `https://…` |
| `attach_file` | *optionnel* | Chemin vers un ou plusieurs documents à joindre au message (situé dans les documents) |
| `attach_from` | *optionnel* | Chemin vers un ou plusieurs squelettes à joindre au message (par exemple pour joindre un document généré) |

Pour le destinataire, il est possible de spécifier un tableau :

```
{{:assign var="recipients[]" value="membre1@framasoft.net"}}
{{:assign var="recipients[]" value="membre2@chatons.org"}}
{{:mail to=$recipients subject="Coucou" body="Contenu du message\nNouvelle ligne"}}
```

Exemple de formulaire de contact :

```
{{if !$_POST.email|check_email}}
  <p class="alert">L'adresse e-mail indiquée est invalide.</p>


{{elseif $_POST.message|trim == ''}}
  <p class="alert">Le message est vide</p>
{{elseif $_POST.send}}
  {{:captcha verify=true}}
  {{:mail to=$config.org_email subject="Formulaire de contact" body="%s a écrit :\n\n%s"|args:$_POST.email:$_POST.message block_urls=true}}
  <p class="ok">Votre message nous a bien été transmis !</p>
{{/if}}

<form method="post" action="">
<dl>
  <dt><label>Votre e-mail : <input type="email" required name="email" /></label></dt>
  <dt><label>Votre message : <textarea required name="message" cols="50" rows="5"></textarea></label></dt>
  <dt>{{:captcha html=true}}</dt>
</dl>
<p><input type="submit" name="send" value="Envoyer !" /></p>
</form>
```

## redirect

Redirige vers une nouvelle page.

Avec le paramètre `force`, si la page actuelle est ouverte dans une fenêtre modale (grâce à la cible `_dialog`), alors la fenêtre modale est fermée, et la redirection se passe dans la page parente.

Avec le paramètre `to`, si la page actuelle est ouverte dans une fenêtre modal (grâce à la cible `_dialog`), alors la fenêtre modale est fermée, et  la page parente est rechargée. Si la page n'est pas ouvertre dans dans une fenêtre modale, la redirection est effectuée.

Seules les adresses internes sont acceptées, il n'est pas possible de rediriger vers une adresse extérieure.

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `force` | optionnel | Adresse de redirection forcée |
| `to` | optionnel | Adresse de redirection si pas dans une fenêtre modale |

Si `to=null` est utilisé, alors la fenêtre modale sera fermée. Ou, si la page n'est pas dans une fenêtre modale, la page courante sera rechargée.

# Fonctions relatives aux Modules

## save

Enregistre des données, sous la forme d'un document, dans la base de données, pour le module courant.

Note : un appel à cette fonction depuis le code du site web provoquera une erreur, elle ne peut être appelée que depuis un module.

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `key` | optionnel | Clé unique du document |
| `id` | optionnel | Numéro unique du document |
| `validate_schema` | optionnel | Fichier de schéma JSON à utiliser pour valider les données avant enregistrement |
| `validate_only` | optionnel | Liste des paramètres à valider (par exemple pour ne faire qu'une mise à jour partielle), séparés par des virgules. |
| `assign_new_id` | optionnel | Si renseigné, le nouveau numéro unique du document sera indiqué dans cette variable. |
| … | optionnel | Autres paramètres : traités comme des valeurs à enregistrer dans le document |

Si ni `key` ni `id` ne sont indiqués, un nouveau document sera créé avec un nouveau numéro (ID) unique.

Si le document indiqué existe déjà, il sera mis à jour. Les valeurs nulles (`NULL`) seront effacées.

```
{{:save key="facture_43" nom="Atelier mobile" montant=250}}
```

Enregistrera dans la base de données le document suivant sous la clé `facture_43` :

```
{"nom": "Atelier mobile", "montant": 250}
```

Exemple de mise à jour :

```
{{:save key="facture_43" montant=300}}
```

Exemple de récupération du nouvel ID :

```
{{:save titre="Coucou !" assign_new_id="id"}}
Le document n°{{$id}} a bien été enregistré.
```

### Validation avec un schéma JSON

```
{{:save titre="Coucou" texte="Très long" validate_schema="./document.schema.json"}}
```

Pour ne valider qu'une partie du schéma, par exemple si on veut faire une mise à jour du document :

```
{{:save key="test" titre="Coucou" validate_schema="./document.schema.json" validate_only="titre"}}
```

## delete

Supprime un document lié au module courant.

Note : un appel à cette fonction depuis le code du site web provoquera une erreur, elle ne peut être appelée que depuis un module.

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `key` | optionnel | Clé unique du document |
| `id` | optionnel | Numéro unique du document |

Il est possible de spécifier d'autres paramètres, ou une clause `where` et des paramètres dont le nom commence par deux points.

* Supprimer le document avec la clé `facture_43` : `{{:delete key="facture_43"}}`
* Supprimer le document avec la clé `ABCD` et dont la propriété `type` du document correspond à la valeur `facture` : `{{:delete key="ABCD" type="facture"}}`
* Supprimer tous les documents : `{{:delete}}`
* Supprimer tous les documents ayant le type `facture` : `{{:delete type="facture"}}`
* Supprimer tous les documents de type `devis` ayant une date dans le passé : `{{:delete :type="devis" where="$$.type = :type AND $$.date < datetime()"}}`

## admin_header

Affiche l'entête de l'administration de l'association.

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `title` | *optionnel* | Titre de la page |
| `layout` | *optionnel* | Aspect de la page. Peut être `public` pour une page publique simple (sans le menu), ou `raw` pour une page vierge (sans aucun menu ni autre élément). Défaut : vide (affichage du menu) |
| `current` | *optionnel* | Indique quel élément dans le menu de gauche doit être marqué comme sélectionné |
| `custom_css` | *optionnel* | Fichier CSS supplémentaire à appeler dans le `<head>` |

```
{{:admin_header title="Gestion des dons" current="acc"}}
```

Liste des choix possibles pour `current` :

* `home` : menu Accueil
* `users` : menu Membres
* `users/new` : sous-menu "Ajouter" de Membres
* `users/services` : sous-menu "Activités et cotisations" de Membres
* `users/mailing` : sous-menu "Message collectif" de Membres
* `acc` : menu Comptabilité
* `acc/new` : sous-menu "Saisie" de Comptabilité
* `acc/accounts` : sous-menu "Comptes"
* `acc/simple` : sous-menu "Suivi des écritures"
* `acc/years` : sous-menu "Exercices et rapports"
* `docs` : menu Documents
* `web` : menu Site web
* `config` : menu Configuration
* `me` : menu "Mes infos personnelles"
* `me/services` : sous-menu "Mes activités et cotisations"

Exemple d'utilisation de `custom_css` depuis un module :

```
{{:admin_header title="Mon module" custom_css="./style.css"}}
```

## admin_footer

Affiche le pied de page de l'administration de l'association.

```
{{:admin_footer}}
```

## delete_form

Affiche un formulaire demandant la confirmation de suppression d'un élément.

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `legend` | **obligatoire** | Libellé de l'élément `<legend>` du formulaire |
| `warning` | **obligatoire** | Libellé de la question de suppression (en gros en rouge) |
| `alert` | *optionnel* | Message d'alerte supplémentaire (bloc jaune) |
| `info` | *optionnel* | Informations liées à la suppression (expliquant ce qui va être impacté par la suppression) |
| `confirm` | *optionnel* | Libellé de la case à cocher pour la suppression, si ce paramètre est absent ou `NULL`, la case à cocher ne sera pas affichée. |

Le formulaire envoie un `POST` avec le bouton ayant le nom `delete`. Si le paramètre `confirm` est renseigné, alors la case à cochée aura le nom `confirm_delete`.

Exemple :

```
{{#load id=$_GET.id assign="invoice"}}
{{else}}
  {{:error message="Facture introuvable"}}
{{/load}}

{{#form on="delete"}}
  {{if !$_POST.confirm_delete}}
    {{:error message="Merci de cocher la case"}}
  {{/if}}
  {{:delete id=$invoice.id}}
{{/form}}

{{:form_errors}}

{{:delete_form
  legend="Suppression d'une facture"
  warning="Supprimer la facture n°%d ?"|args:$invoice.id
  info="Le devis lié sera également supprimé"
  alert="La facture sera définitivement perdue !"
  confirm="Cocher cette case pour confirmer la suppression de la facture"
}}
```

## input

Crée un champ de formulaire HTML. Cette fonction est une extension à la balise `<input>` en HTML, mais permet plus de choses.

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `name` | **obligatoire** | Nom du champ |
| `type` | **obligatoire** | Type de champ |
| `required` | *optionnel* | Mettre à `true` si le champ est obligatoire |
| `label` | *optionnel* | Libellé du champ |
| `help` | *optionnel* | Texte d'aide, affiché sous le champ |
| `default` | *optionnel* | Valeur du champ par défaut, si le formulaire n'a pas été envoyé, et que la valeur dans `source` est vide |
| `source` | *optionnel* | Source de pré-remplissage du champ. Si le nom du champ est `montant`, alors la valeur de `[source].montant` sera affichée si présente. |

Si `label` ou `help` sont spécifiés, le champ sera intégré à une balise HTML `<dd>`, et le libellé sera intégré à une balise `<dt>`. Dans ce cas il faut donc que le champ soit dans une liste `<dl>`. Si ces deux paramètres ne sont pas spécifiés, le champ sera le seul tag HTML.

```
<dl>
	{{:input name="amount" type="money" label="Montant" required=true}}
</dl>
```

Note : le champ aura comme `id` la valeur `f_[name]`. Ainsi un champ avec `amount` comme `name` aura `id="f_amount"`.

### Valeur du champ

La valeur du champ est remplie avec :

* la valeur dans `$_POST` qui correspond au `name` ;
* sinon la valeur dans `source` (tableau) avec le même nom (exemple : `$source[name]`) ;
* sinon la valeur de `default` est utilisée.

Note : le paramètre `value` n'est pas supporté sauf pour checkbox et radio.

### Types de champs supportés

* les types classiques de `input` en HTML : text, search, email, url, file, date, checkbox, radio, password, etc.
  * Note : pour checkbox et radio, il faut utiliser le paramètre `value` en plus pour spécifier la valeur.
* `textarea`
* `money` créera un champ qui attend une valeur de monnaie au format décimal
* `datetime` créera un champ date et un champ texte pour entrer l'heure au format `HH:MM`
* `radio-btn` créera un champ de type radio mais sous la forme d'un gros bouton
* `select` crée un sélecteur de type `<select>`. Dans ce cas il convient d'indiquer un tableau associatif dans le paramètre `options`.
* `select_groups` crée un sélecteur de type `<select>`, mais avec des `<optgroup>`. Dans ce cas il convient d'indiquer un tableau associatif à deux niveaux dans le paramètre `options`.
* `list` crée un champ permettant de sélectionner un ou des éléments (selon si le paramètre `multiple` est `true` ou `false`) dans un formulaire externe. Le paramètre `can_delete` indique si l'utilisateur peut supprimer l'élément déjà sélectionné (si `multiple=false`). La sélection se fait à partir d'un  formulaire  dont l'URL doit être spécifiée dans le paramètre `target`. Les formulaires actuellement supportés sont :
  * `!acc/charts/accounts/selector.php?targets=X` pour sélectionner un compte du plan comptable, où X est une liste de types de comptes qu'il faut permettre de choisir (séparés par des `:`)
  * `!users/selector.php` pour sélectionner un membre

## button

Affiche un bouton, similaire à `<button>` en HTML, mais permet d'ajouter une icône par exemple.

```
{{:button type="submit" name="save" label="Créer ce membre" shape="plus" class="main"}}
```

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `type` | optionnel | Type du bouton |
| `name` | optionnel | Nom du bouton |
| `label` | optionnel | Label du bouton |
| `shape` | optionnel | Affiche une icône en préfixe du label |
| `class` | optionnel | Classe CSS |
| `title` | optionnel | Attribut HTML `title` |
| `disabled` | optionnel | Désactive le bouton si `true` |


## link

Affiche un lien.

```
{{:link href="!users/new.php" label="Créer un nouveau membre"}}
```

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `href` | **obligatoire** | Adresse du lien |
| `label` | **obligatoire** | Libellé du lien |
| `target` | *optionnel* | Cible du lien, utiliser `_dialog` pour que le lien s'ouvre dans une fenêtre modale. |


Préfixer l'adresse par "!" donnera une URL absolue en préfixant l'adresse par l'URL de l'administration.
Sans "!", l'adresse générée sera relative au contexte d'appel (module/plugin ou squelette site web).


## linkbutton

Affiche un lien sous forme de faux bouton, avec une icône si le paramètre `shape` est spécifié.

```
{{:linkbutton href="!users/new.php" label="Créer un nouveau membre" shape="plus"}}
```

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `href` | **obligatoire* | Adresse du lien |
| `label` | **obligatoire** | Libellé du bouton |
| `target` | *optionnel* | Cible de l'ouverture du lien |
| `shape` | *optionnel* | Affiche une icône en préfixe du label |

Si on utilise `target="_dialog"` alors le lien s'ouvrira dans une fenêtre modale (iframe) par dessus la page actuelle.

Si on utilise `target="_blank"` alors le lien s'ouvrira dans un nouvel onglet.

## icon

Affiche une icône.

```
{{:icon shape="print"}}
```

| Paramètre | Obligatoire ou optionnel ? | Fonction |
| :- | :- | :- |
| `shape` | **obligatoire** | Forme de l'icône. |


# Formes d'icônes disponibles

![](shapes.png)

Modified doc/admin/brindille_modifiers.md from [dc87155bc4] to [6c3b819d22].

1
2
3

4
5
6
7
8
9
10
11
12
13
14























































































































































15
16
17




18

19

20

21






22

23

24
25

26
27
28

29
30


31
32

33
34
35
36




37
38
39
40
41

42




43

44

45




46

47

48



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

66




67

68



69

70

71

72



73
74
75
76
77
78
79
80
81
82

83
84

85




86




87



88
89






90



















































91


92
93

94








































































95
96



97

98




99



100
































101




























102

103

104























































105


106

107




108








109
110
111
112
113
114
115
Title: Référence des filtres Brindille

{{{.nav

* [Documentation Brindille](brindille.html)
* [Fonctions](brindille_functions.html)
* [Sections](brindille_sections.html)
* **[Filtres](brindille_modifiers.html)**
}}}

<<toc aside>>

# Filtres PHP

Ces filtres viennent directement de PHP et utilisent donc les mêmes paramètres :
























































































































































* strtolower
* strtoupper




* ucfirst

* ucwords

* htmlentities

* htmlspecialchars






* trim, ltrim, rtrim

* lcfirst

* md5
* sha1

* metaphone
* soundex
* str_split

* str_word_count
* strrev


* strlen
* strip_tags

* nl2br
* wordwrap
* strlen
* abs





# Filtres de texte

## truncate


Arguments :






* nombre : longueur en nombre de caractères (défaut = 80)

* texte : texte à placer à la fin (si tronqué) (défaut = …)




* booléen : coupure stricte, si `true` alors un mot pourra être coupé en deux, sinon le texte sera coupé au dernier mot complet



Tronque un texte à une longueur définie.




## excerpt

Produit un extrait d'un texte.

Supprime les tags HTML, tronque au nombre de caractères indiqué en second argument (si rien n'est indiqué, alors 600 est utilisé), et englobe dans un paragraphe `<p>...</p>`.

Équivalent de :

```
<p>{{$html|strip_tags|truncate:600|nl2br}}</p>
```

## protect_contact

Crée un lien protégé pour une adresse email, pour éviter que l'adresse ne soit recueillie par les robots spammeurs (empêche également le copier-coller et le lien ne fonctionnera pas avec javascriptsactivé).


## escape






Échappe le contenu pour un usage dans un document HTML. Ce filtre est appliqué par défaut à tout ce qui est affiché (variables, etc.) sauf à utiliser le filtre `raw` (voir plus bas).





## xml_escape



Échappe le contenu pour un usage dans un document XML.




## raw

Passer ce filtre désactive la protection automatique contre le HTML (échappement) dans le texte. À utiliser en connaissance de cause avec les contenus qui contiennent du HTML et sont déjà filtrés !

```
{{"<b>Test"}} = &lt;b&gt;Test
{{"<b>Test"|raw}} = <b>Test
```


## args


Remplace des arguments dans le texte selon le schéma utilisé par [sprintf](https://www.php.net/sprintf).









```



{{"Il y a %d résultats dans la recherche sur le terme '%s'."|args:$results_count:$query}}
= Il y a 5 résultat dans la recherche sur le terme 'test'.






```






















































## count


Compte le nombre d'entrées dans un tableau.









































































```



{{$products|count}}

= 5




```




































## cat






























Concaténer un texte avec un autre.

























































```


{{"Tangerine"|cat:" Dream"}}

= Tangerine Dream




```









## math

Réalise un calcul mathématique. Cette fonction accepte :

* les nombres: `42`, `13,37`, `14.05`
* les signes : `+ - / *` pour additionner, diminuer, diviser ou multiplier



>










|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

|
|
>
>
>
>
|
>
|
>
|
>
|
>
>
>
>
>
>
|
>
|
>
|
|
>
|
|
|
>
|
<
>
>
|
|
>
|
|
|
<
>
>
>
>



|

>
|
>
>
>
>

>
|
>
|
>
>
>
>
|
>

>
|
>
>
>













|

|

>
|
>
>
>
>

>
|
>
>
>

>
|
>

>
|
>
>
>










>
|

>
|
>
>
>
>

>
>
>
>

>
>
>
|
|
>
>
>
>
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
|

>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>


>
>
>
|
>
|
>
>
>
>

>
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
|
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
|
>
|
>
>
>
>

>
>
>
>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198

199
200
201
202
203
204
205
206

207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
Title: Référence des filtres Brindille

{{{.nav
* [Modules](modules.html)
* [Documentation Brindille](brindille.html)
* [Fonctions](brindille_functions.html)
* [Sections](brindille_sections.html)
* **[Filtres](brindille_modifiers.html)**
}}}

<<toc aside>>

# Filtres PHP

Ces filtres viennent directement de PHP et utilisent donc les mêmes paramètres. Voir la [documentation PHP](https://www.php.net/manual/fr/function.htmlspecialchars.php) pour plus de détails.

| Nom | Description |
| :- | :- |
| `htmlentities` | Convertit tous les caractères éligibles en entités HTML |
| `htmlspecialchars` | Convertit les caractères spéciaux en entités HTML |
| `trim` | Supprime les espaces et lignes vides au début et à la fin d'un texte |
| `ltrim` | Supprime les espaces et lignes vides au début d'un texte | [Documentation](https://www.php.net/ltrim) |
| `rtrim` | Supprime les espaces et lignes vides à la fin d'un texte | [Documentation](https://www.php.net/rtrim) |
| `md5` | Génère un hash MD5 d'un texte |
| `sha1` | Génère un hash SHA1 d'un texte |
| `strlen` | Nombre de caractères dans une chaîne de texte |
| `strpos` | Position d'un élément dans une chaîne de texte |
| `strrpos` | Position d'un dernier élément dans une chaîne de texte |
| `strip_tags` | Supprime les tags HTML |
| `nl2br` | Remplace les retours à la ligne par des tags HTML `<br/>` |
| `wordwrap` | Ajoute des retours à la ligne tous les 75 caractères |
| `substr` | Découpe une chaîne de caractère |
| `abs` | Renvoie la valeur absolue d'un nombre (exemple : -42 sera transformé en 42) |
| `intval` | Transforme une valeur en entier (integer) |
| `boolval` | Transforme une valeur en booléen (true ou false) |
| `floatval` | Transforme une valeur en nombre flottant (à virgule) |
| `strval` | Transforme une valeur en chaîne de texte |
| `arrayval` | Transforme une valeur en tableau |
| `json_decode` | Transforme une chaîne JSON en tableau |
| `json_encode` | Transforme une valeur en chaîne JSON |

# Filtres utiles pour les e-mails

## check_email

Permet de vérifier la validité d'une adresse email. Cette fonction vérifie la syntaxe de l'adresse mais aussi que le nom de domaine indiqué possède bien un enregistrement de type MX.

Renvoie `true` si l'adresse est valide.

```
{{if !$_POST.email|check_email}}
<p class="alert">L'adresse e-mail indiquée est invalide.</p>
{{/if}}
```

## protect_contact

Crée un lien protégé pour une adresse email, pour éviter que l'adresse ne soit recueillie par les robots spammeurs (empêche également le copier-coller et le lien ne fonctionnera pas avec javascript désactivé).

# Filtres de tableaux

## has

Renvoie vrai si le tableau contient l'élément passé en paramètre.

```
{{:assign var="table" a="bleu" b="orange"}}
{{if $table|has:"bleu"}}
	Oui, il y a du bleu
{{/if}}
```

## in

Renvoie vrai si l'élément fait partie du tableau passé en paramètre.

C'est exactement la même chose que `has`, mais exprimé à l'envers.

```
{{:assign var="table" a="bleu" b="orange"}}
{{if "bleu"|in:$table}}
	Oui, il y a du bleu
{{/if}}
```

## keys

Renvoie les clés du tableau, sous forme de tableau.

```
{{:assign var="table" a="bleu" b="orange"}}
{{:assign var="cles" value=$table|keys}}
{{$cles|implode:","}}
```

Donnera :

```
a,b
```

## values

Renvoie les valeurs du tableau, sous forme de tableau.

Cela revient en fait à supprimer les clés associatives.

```
{{:assign var="table" a="bleu" b="orange"}}
{{#foreach from=$table key="cle" item="valeur"}}
	{{$cle}} = {{$valeur}}
{{/foreach}}
--
{{:assign var="valeurs" value=$table|values}}
{{#foreach from=$valeurs key="cle" item="valeur"}}
	{{$cle}} = {{$valeur}}
{{/foreach}}
```

Donnera :

```
a = bleu
b = orange
--
0 = bleu
1 = orange
```

## count

Compte le nombre d'entrées dans un tableau.

```
{{$products|count}}
= 5
```

## explode

Sépare une chaîne de texte en tableau à partir d'une chaîne de séparation.

```
{{:assign var="table" value="a,b,c"|explode:","}}
- {{$table.0}}
- {{$table.1}}
- {{$table.2}}
```

Affichera :

```
- a
- b
- c
```

## implode

Réunit un tableau sous forme de chaîne de texte en utilisant éventuellement une chaîne de liaison entre chaque élément du tableau.

```
{{:assign var="table" a="bleu" b="orange"}}
{{$table|implode}}
{{$table|implode:" - "}}
```

Affichera :

```
bleuorange
bleu - orange
```

## map

Applique un filtre sur chaque élément du tableau.

Le premier paramètre doit être le nom du filtre. Les autres paramètres seront passés au filtre.

```
{{:assign var="table" a="01" b="02"}}
{{:assign var="table" value=$table|map:intval}}
- {{$table.a}}
- {{$table.b}}
```

Affichera :

```
- 1
- 2
```

## ksort, sort

Trie un tableau par ordre alpha-numérique, sans tenir compte des majuscules/minuscules. `ksort` trie le tableau en utilisant les clés, et `sort` trie le tableau en utilisant les valeurs.


```
{{:assign var="table" b="3" a="2" c="1"}}
{{$table|sort|implode:","}}
{{$table|ksort|implode:","}}
```

Affichera :


```
1,2,3
2,3,1
```

# Filtres de texte

## args

Remplace des arguments dans le texte selon le schéma utilisé par [sprintf](https://www.php.net/sprintf).

```
{{"Il y a %d résultats dans la recherche sur le terme '%s'."|args:$results_count:$query}}
= Il y a 5 résultat dans la recherche sur le terme 'test'.
```

## cat

Concaténer un texte avec un autre.

```
{{"Tangerine"|cat:" Dream"}}
= Tangerine Dream
```

## count_words

Compte le nombre de mots dans un texte.

## escape

Échappe le contenu pour un usage dans un document HTML. Ce filtre est appliqué par défaut à tout ce qui est affiché (variables, etc.) sauf à utiliser le filtre `raw` (voir plus bas).

## excerpt

Produit un extrait d'un texte.

Supprime les tags HTML, tronque au nombre de caractères indiqué en second argument (si rien n'est indiqué, alors 600 est utilisé), et englobe dans un paragraphe `<p>...</p>`.

Équivalent de :

```
<p>{{$html|strip_tags|truncate:600|nl2br}}</p>
```

## extract_leading_number

Extrait le numéro aubut d'une chaîne de texte.

Exemple :

```
{{:assign title="02. Cours sur la physique nucléaire"}}
{{$title|extract_leading_number}}
```

Affichera :

```
02
```

## markdown

Transforme un texte en HTML en utilisant la syntaxe Markdown.

Il est conseillé de rajouter le filtre `|raw` pour ne pas échapper le HTML produit, si on veut afficher le texte formatté dans une page HTML.

```
{{$texte|markdown|raw}}
```

## raw

Passer ce filtre désactive la protection automatique contre le HTML (échappement) dans le texte. À utiliser en connaissance de cause avec les contenus qui contiennent du HTML et sont déjà filtrés !

```
{{"<b>Test"}} = &lt;b&gt;Test
{{"<b>Test"|raw}} = <b>Test
```


## replace

Remplace des parties du texte par une autre partie.

```
{{"Tata yoyo"|replace:"yoyo":"yaya"}}
= Tata yaya
```

## regexp_replace

Remplace des valeurs en utilisant une expression rationnelles (regexp) ([documentation PHP](https://www.php.net/manual/fr/regexp.introduction.php)).

```
{{"Tartagueule"|regexp_replace:"/ta/i":"tou"}}
= tourtougueule
```


## remove_leading_number

Supprime le numéro au début d'un titre.

Cela permet de définir un ordre spécifique aux pages et catégories dans les listes.

```
{{"03. Beau titre"|remove_leading_number}}
Beau titre
```


## truncate

Tronque un texte à une longueur définie.

| Argument | Fonction | Valeur par défaut (si omis) |
| :- | :- | :- |
| 1 | longueur en nombre de caractères | 80 |
| 2 | texte à placer à la fin (si tronqué) | … |
| 3 | coupure stricte, si `true` alors un mot pourra être coupé en deux, si `false` le texte sera coupé au dernier mot complet | `false` |

```
{{:assign texte="Ceci n'est pas un texte."}}
{{$texte|truncate:19:"(...)":true}}
{{$texte|truncate:19:"":false}}
```

Affichera :

```
Ceci n'est pas un (...)
Ceci n'est pas un t
```

## typo

Formatte un texte selon les règles typographiques françaises : ajoute des espaces insécables devant ou derrière les ponctuations françaises (`« » ? ! :`).

## urlencode

Encode une chaîne de texte pour utilisation dans une adresse URL (alias de `rawurlencode` en PHP).

## xml_escape

Échappe le contenu pour un usage dans un document XML.

## Autres filtres de texte

Les filtres suivants modifient la casse (majuscule/minuscules) d'un texte et ne fonctionneront correctement que si l'extension `mbstring` est installée sur le serveur. Sinon les lettres accentuées ne seront pas modifiées.

Note : il est donc préférable d'utiliser la propriété CSS [`text-transform`](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform) pour modifier la casse si l'usage n'est que pour l'affichage, et non pas pour enregistrer les données.

* `tolower` : transforme un texte en minuscules
* `toupper` : transforme un texte en majuscules
* `ucfirst` : met la première lettre du texte en majuscule
* `ucwords` : met la première lettre de chaque mot en majuscule
* `lcfirst` : met la première lettre du texte en minuscule

# Filtres sur les sommes en devises

## money

Formatte une valeur de monnaie pour l'affichage.

Une valeur de monnaie doit **toujours** inclure les cents (exprimée sous forme d'entier). Ainsi `15,02` doit être exprimée sous la forme `1502`.

Paramètres optionnels :

1. `true` (défaut) pour ne rien afficher si la valeur est zéro, ou `false` pour afficher `0,00`
2. `true` pour afficher le signe `+` si le nombre est positif (`-` est toujours affiché si le nombre est négatif)

```
{{* 12 345,67 = 1234567 *}}
{{:assign amount=1234567}}
{{$amount|money}}
12 345,67
```

## money_currency

Comme `money` (même paramètres), formatte une valeur de monnaie (entier) pour affichage, mais en ajoutant la devise.

```
{{:assign amount=1502}}
{{$amount|money_currency}}
15,02 €
```

## money_html

Idem que `money`, mais pour l'affichage en HTML :

```
{{* 12 345,67 = 1234567 *}}
{{:assign amount=1234567}}
{{$amount|money_html}}
<span class="money">12&nbsp;345,67</span>
```

## money_currency_html

Idem que `money_currency`, mais pour l'affichage en HTML :

```
{{:assign amount=1502}}
{{$amount|money_currency_html}}
<span class="money">15,02&nbsp;€</span>
```

## money_raw

Formatte une valeur de monnaie (entier) de manière brute : les milliers n'auront pas de séparateur.

```
{{:assign amount=1234567}}
{{$amount|money_raw}}
12345,67
```

## money_int

Transforme un nombre à partir d'une chaîne de caractère (par exemple `12345,67`) en entier (`1234567`) pour stocker une valeur de monnaie.

```
{{:assign montant=$_POST.montant|trim|money_int}}
```

# Filtres SQL

## quote_sql

Protège une chaîne contre les attaques SQL, pour l'utilisation dans une condition.

**Note : il est FORTEMENT déconseillé d'intégrer directement des sources extérieures dans les requêtes SQL, il est préférable d'utiliser les paramètres dans la boucle `sql` et ses dérivées, comme ceci : `{{#sql select="id, nom" tables="users" where="lettre_infos = :lettre" :lettre=$_GET.lettre}}`.**

Exemple :

```
{{:assign nom=$_GET.nom|quote_sql}}
{{#sql select="id, nom" tables="users" where="nom = %s"|args:$nom}}
```

## quote_sql_identifier

La même chose que `quote_sql`, mais pour les identifiants (par exemple nom de table ou de colonne).

Exemple :

```
{{:assign colonne=$_GET.colonne|quote_sql_identifier}}
{{#sql select="id, %s"|args:$colonne tables="users"}}
```

Il est possible d'utiliser un préfixe en argument, utile par exemple quand on a plusieurs tables avec le même nom de colonne :

```
{{:assign colonne=$_GET.colonne|quote_sql_identifier:"u1"}}
{{#sql select="u1.id, %s"|args:$colonne tables="users AS u1 INNER JOIN users AS u2 ON u2.id_parent = u1.id"}}
```

## sql_where

Permet de créer une partie d'une clause SQL `WHERE` complexe.

Le premier paramètre est le nom de la colonne (sans préfixe).

Paramètres :

1. Comparateur : `=, !=, IN, NOT IN, >, >=, <, <=`
2. Valeur à comparer (peut être un tableau)

Exemple pour afficher la liste des membres des catégories n°1 et n°2:

```
{{:assign var="list." value=1}}
{{:assign var="list." value=2}}
{{#sql select="nom" tables="users" where="id_category"|sql_where:'IN':$id_list}}
    {{$nom}}
{{/sql}}
```

Le requête SQL générée sera alors `SELECT nom FROM users WHERE id_category IN (1, 2)`.

# Filtres de date

## date

Formatte une date selon le format spécifié en premier paramètre.

Le format est identique au [format utilisé par PHP](https://www.php.net/manual/fr/datetime.format.php).

Si aucun format n'est indiqué, le défaut sera `d/m/Y à H:i`. (en français)

## strftime

Formatte une date selon un format spécifié en premier paramètre.

Le format à utiliser est identique [au format utilisé par la fonction strftime de PHP](https://www.php.net/strftime).

Un format doit obligatoirement être spécifié.

En passant un code de langue en second paramètre, cette langue sera utilisée. Sont supportés le français (`fr`) et l'anglais (`en`). Le défaut est le français si aucune valeur n'est passée en second paramètre .

## relative_date 

Renvoie une date relative à la date du jour : `aujourd'hui`, `hier`, `demain`, ou sinon `mardi 2 janvier` (si la date est de l'année en cours) ou `2 janvier 2021` (si la date est d'une autre année).

En spécifiant `true` en premier paramètre, l'heure sera ajoutée au format `14h34`.

## date_short

Formatte une date au format court : `d/m/Y`.

En spécifiant `true` en premier paramètre l'heure sera ajoutée : `à H\hi`.

## date_long

Formatte une date au format long : `lundi 2 janvier 2021`.

En spécifiant `true` en premier paramètre l'heure sera ajoutée : `à 20h42`.

## date_hour

Formatte une date en renvoyant l'heure uniquement : `20h00`.

En passant `true` en premier paramètre, les minutes seront omises si elles sont égales à zéro : `20h`.

## atom_date

Formatte une date au format ATOM : `Y-m-d\TH:i:sP`

## parse_date

Vérifie le format d'une chaîne de texte représentant la date et la transforme en chaîne de date standardisée au format `AAAA-MM-JJ`.

Les formats acceptés sont :

* `AAAA-MM-JJ`
* `JJ/MM/AAAA`
* `JJ/MM/AA`

## parse_datetime

Vérifie le format d'une chaîne de texte représentant la date et l'heure et la transforme en chaîne de date et heure standardisée au format `AAAA-MM-JJ HH:mm`.

Les formats acceptés sont :

* `AAAA-MM-JJ HH:mm:ss`
* `AAAA-MM-JJ HH:mm`
* `JJ/MM/AAAA HH:mm`

## parse_time

Vérifie le format d'une chaîne de texte représentant l'heure et la transforme en chaîne de date standardisée au format `HH:MM`.

Les formats acceptés sont :

* `HH:MM`
* `H:M`
* `H:MM`
* `HH:M`

Le séparateur peut être `:` ou `h`.

# Filtres de condition

Ces filtres sont à utiliser dans les conditions

## match

Renvoie `true` si le texte indiqué en premier paramètre est trouvé dans la variable.

Ce filtre est insensible à la casse.

```
{{if $page.path|match:"/aide"}}Bienvenue dans l'aide !{{/if}}
```

## regexp_match

Renvoie `true` si l'expression régulière indiquée en premier paramètre est trouvée dans la variable.

Exemple pour voir si le texte contient les mots "Bonjour" ou "Au revoir" (insensible à la casse) :

```
{{if $texte|regexp_match:"/Bonjour|Au revoir/i"}}
	Trouvé !
{{else}}
	Rien trouvé :-(
{{/if}}
```

# Autres filtres

## math

Réalise un calcul mathématique. Cette fonction accepte :

* les nombres: `42`, `13,37`, `14.05`
* les signes : `+ - / *` pour additionner, diminuer, diviser ou multiplier
130
131
132
133
134
135
136
137
138
139
140
141

142
143
144
145


146
147
148
149
150
151
152
153
154


155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215

216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
{{"1+%d"|math:$age}}
= 43
{{:assign prix=39.99 tva=19.1}}
{{"round(%f*%f, 2)"|math:$prix:$tva}}
= 47.63
```

Il est aussi possible d'utiliser 

## replace

Remplace des parties du texte par une autre partie.


```
{{"Tata yoyo"|replace:"yoyo":"yaya"}}
= Tata yaya


```

## regexp_replace

Remplace des valeurs en utilisant une expression rationnelles (regexp) ([documentation PHP](https://www.php.net/manual/fr/regexp.introduction.php)).

```
{{"Tartagueule"|regexp_replace:"/ta/i":"tou"}}
= tourtougueule


```

## size_in_bytes

Renvoie une taille en octets, Ko, Mo, ou Go à partir d'une taille en octets.

```
{{100|size_in_bytes}} = 100 o
{{1500|size_in_bytes}} = 1,50 Ko
{{1048576|size_in_bytes}} = 1 Mo
```

## typo

Pour le français.

Ajoute des espaces insécables (`&nbsp;`) devant ou derrière les ponctuations françaises (`« » ? ! :`).

## money

Formatte une valeur de monnaie (exprimée avec les cents inclus : `1502` = 15,02) pour l'affichage :

```
{{$amount|money}}
15,02
```

## money_currency

Formatte une valeur de monnaie en ajoutant la devise :

```
{{$amount|money_currency}}
15,02 €
```

## remove_leading_number

Supprime le numéro au début d'un titre.

Cela permet de définir un ordre spécifique aux pages et catégories dans les listes.

```
{{"03. Beau titre"|remove_leading_number}}
Beau titre
```

## extract_leading_number

Extrait le numéro du titre.

## check_email

Permet de vérifier la validité d'une adresse email. Cette fonction vérifie la syntaxe de l'adresse mais aussi que le nom de domaine indiqué possède bien un enregistrement de type MX.

Renvoie `true` si l'adresse est valide.

```
{{if !$_POST.email|check_email}}
<p class="alert">L'adresse e-mail indiquée est invalide.</p>
{{/if}}

```

## Filtres de date

* `date` : formatte une date selon un format spécifié (identique au [format utilisé par PHP](https://www.php.net/manual/fr/datetime.format.php)). Si aucun format n'est utilisé, le défaut sera `d/m/Y à H:i`. (en français)
* `strftime` : formatte une date selon un format spécifié, identique [au format utilisé par la fonction strftime de PHP](https://www.php.net/strftime). Un format doit obligatoirement être spécifié. En passant un code de langue en second paramètre, cette langue sera utilisée. Sont supportés le français (`fr`) et l'anglais (`en`). Le défaut est le français si aucune valeur n'est passée en second paramètre .
* `relative_date` : renvoie une date relative à la date du jour : `aujourd'hui`, `hier`, `demain`, ou sinon `mardi 2 janvier` (si la date est de l'année en cours) ou `2 janvier 2021` (si la date est d'une autre année). En spécifiant `true` en second paramètre, l'heure sera ajoutée au format `14h34`.
* `date_short` : date au format court : `d/m/Y`, en spécifiant `true` en second paramètre l'heure sera ajoutée : `à H\hi`.
* `date_long` : date au format long : `lundi 2 janvier 2021`. En spécifiant `true` en second paramètre l'heure sera ajoutée : `à 20h42`.
* `date_hour` : renvoie l'heure uniquement à partir d'une date : `20h00`. En passant `true` en second paramètre, les minutes seront omises si elles sont égales à zéro : `20h`.
* `atom_date` : formatte une date au format ATOM : `Y-m-d\TH:i:sP`

## Filtres de condition

Ces filtres renvoient `1` si la condition est remplie, ou `0` sinon. Ils peuvent être utilisés dans les conditions :

```
{{if $page.path|match:"/aide"}}Bienvenue dans l'aide !{{/if}}
```

* `match` renvoie `1` si le texte indiqué est trouvé (insensible à la casse)
* `regexp_match`, idem mais avec une expression régulière (de type `/Bonjour|revoir/i`)







<
<
|

<
>


<
<
>
>


<
|
<


<
<
>
>












<
|
<

<
|
<

<
|
<
<
<
<

<
<
<
<

<
<
<
|
<
<
<
<
<
<

<
<
<
<
<

<
|
<

<
<
<
<

<
<
<
>


<
|
<
<
<
<
<
<
<

<
|
<
<
<
<
<
<
<
<
623
624
625
626
627
628
629


630
631

632
633
634


635
636
637
638

639

640
641


642
643
644
645
646
647
648
649
650
651
652
653
654
655

656

657

658

659

660




661




662



663






664





665

666

667




668



669
670
671

672







673

674








{{"1+%d"|math:$age}}
= 43
{{:assign prix=39.99 tva=19.1}}
{{"round(%f*%f, 2)"|math:$prix:$tva}}
= 47.63
```



## or


Si la variable passée est évalue comme `false` (c'est à dire que sa valeur est un texte vide, ou un nombre qui vaut zéro, ou la valeur `false`), alors le premier paramètre sera utilisé.

```


{{:assign texte=""}}
{{$texte|or:"Le texte est vide"}}
```


Il est possible de chaîner les appels à `or` :


```


{{:assign texte1="" texte2="0"}}
{{$texte1|or:$texte2|or:"Aucun texte"}}
```

## size_in_bytes

Renvoie une taille en octets, Ko, Mo, ou Go à partir d'une taille en octets.

```
{{100|size_in_bytes}} = 100 o
{{1500|size_in_bytes}} = 1,50 Ko
{{1048576|size_in_bytes}} = 1 Mo
```


## spell_out_number



Épelle un nombre en toutes lettres.



Le premier paramètre peut être utilisé pour spécifier le code de la langue à utiliser (par défaut c'est le français, donc le code `fr`).









```



{{42|spell_out_number}}






```







Donnera :






```



quarante deux
```


## uuid









Renvoie un identifiant unique au format UUIDv4.








Modified doc/admin/brindille_sections.md from [3025661395] to [7106a855ae].

1
2
3

4
5
6
7
8
9
10
11
12
13
14
15
16
17








18






19
20
21
22

















































23
24
25
26
27
28
29
30
31
32
33



34
35
36
37
38
39
40
41
42


43
44
45
46
47
48
49
50


51
52
53

54












55







56

57







58
































59
60
61
62



63

64






65

66



67

68

69




70











71
72

73
74
75
76
77
78
79
80




81

82


83
84
85
86


87





88

89



90

91

92

93

94



95

96




97


98




99
100



101

102

103

104




105

106
107
108
109




110
111
112

113




114

115

116
117
118

119
120

121
122
123
124
125
126
127
128
129
130

131
132
133
134
135

136

137
138

































































































































































139
140
141


142













143

144










145

























































































































































































































146





Title: Référence des sections Brindille

{{{.nav

* [Documentation Brindille](brindille.html)
* [Fonctions](brindille_functions.html)
* **[Sections](brindille_sections.html)**
* [Filtres](brindille_modifiers.html)
}}}

<<toc aside level=2>>

# Sections généralistes

## foreach

Permet d'itérer sur un tableau par exemple :









```






{{#foreach from=$variable key="key" item="value"}}
{{$key}} = {{$value}}
{{/foreach}}
```


















































## restrict

Permet de limiter (restreindre) une partie de la page aux membres qui sont connectés et/ou qui ont certains droits.

Deux paramètres optionnels peuvent être utilisés ensemble (il n'est pas possible d'utiliser seulement un des deux) :

| Paramètre | Fonction |
| - | - |
| `level` | Niveau d'accès : read, write, admin |
| `section` | Section où le niveau d'accès doit s'appliquer : users, accounting, web, documents, config |




```
{{#restrict}}
	Un membre est connecté, mais on ne sait pas avec quels droits.
{{else}}
	Aucun membre n'est connecté.
{{/restrict}}
```



```
{{#restrict section="users" level="admin"}}
	Un membre est connecté, et il a le droit d'administrer les membres.
{{else}}
	Aucun membre n'est connecté, ou un membre est connecté mais n'est pas administrateur des membres.
{{/if}}
```



```
{{#restrict block=true section="accounting" level="write"}}
{{/restrict}}

Si le membre n'est pas connecté ou n'a pas le droit de modifier la compta, il aura une page d'erreur.












```









# Sections SQL








































Dans toutes les sections héritées de `sql` suivantes il est possible d'utiliser les paramètres suivants :

| Paramètre | Fonction |
| - | - |



| `where` | Condition de sélection des résultats |

| `begin` | Début des résultats, si vide une valeur de `0` sera utilisée. |






| `limit` | Limitation des résultats. Si vide, une valeur de `1000` sera utilisée. |

| `order` | Ordre de tri des résultats. Si vide le tri sera fait par ordre d'ajout dans la base de données. |



| `debug` | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. |



Il est également possible de passer des arguments dans les paramètres à l'aides des arguments nommés qui commencent par deux points `:` : `{{#articles where="title = :montitre" :montitre="Actualité"}}`
















## sql


Effectue une requête SQL de type `SELECT` dans la base de données.

```
{{#sql select="*, julianday(date) AS day" tables="membres" where="id_categorie = :id_categorie" :id_categorie=$_GET.id_categorie order="numero DESC" begin=":page*100" limit=100 :page=$_GET.page}}
…
{{/sql}}
```





### Paramètres possibles




| Paramètre | Fonction |
| - | - |
| `tables` | Liste des tables à utiliser dans la requête. Ce paramètre est obligatoire. |
| `select` | Liste des colonnes à sélectionner, si non spécifié, toutes les colonnes (`*`) seront sélectionnées |


| `group` | Contenu de la clause `GROUP BY` |







## pages, articles, categories (héritent de sql)





* `pages` renvoie une liste de pages, qu'elles soient des articles ou des catégories

* `categories` ne renvoie que des catégories

* `articles` ne renvoie que des articles





À part cela les trois types de section se comportent de manière identique






### Paramètres possibles







| Paramètre | Fonction |
| - | - |



| `search` | Renseigner ce paramètre avec un terme à rechercher dans le texte ou le titre. Dans ce cas par défaut le tri des résultats se fait sur la pertinence, sauf si le paramètre `order` est spécifié. Dans ce cas une variable `$snippet` sera disponible à l'intérieur de la boucle, contenant les termes trouvés. |

| `future` | Renseigner ce paramètre à `false` pour que les articles dont la date est dans le futur n'apparaissent pas, `true` pour ne renvoyer QUE les articles dans le futur, et `null` (ou ne pas utiliser ce paramètre) pour que tous les articles, passés et futur, apparaissent. |

| `parent` | Indiquer ici le chemin d'article ou de catégorie parente. Utile pour renvoyer par exemple la liste des articles d'une catégorie. |






## files, documents, images (héritent de sql)


* `files` renvoie une liste de fichiers
* `documents` renvoie une liste de fichiers qui ne sont pas des images
* `images` renvoie une liste de fichiers qui sont des images





À part cela les trois types de section se comportent de manière identique


Note : seul les fichiers de la section site web sont accessibles, les fichiers de membres, de comptabilité, etc. ne sont pas disponibles.






### Paramètres possibles


| Paramètre | Fonction |
| - | - |

| `parent` (obligatoire) | Chemin (adresse unique) de l'article ou catégorie parente dont ont veut lister les fichiers |
| `except_in_text` | passer `true` à ce paramètre , et seuls les fichiers qui ne sont pas liés dans le texte de la page seront renvoyés |


## breadcrumbs

Permet de récupérer la liste des pages parentes d'une page afin de constituer un [fil d'ariane](https://fr.wikipedia.org/wiki/Fil_d'Ariane_(ergonomie)) permettant de remonter dans l'arborescence du site

Un seul paramètre est possible :

| Paramètre | Fonction |
| - | - |
| `path` (obligatoire) | Chemin (adresse unique) de la page parente |


Chaque itération renverra trois variables :

| Variable | Contenu |
| - | - |

| `$title` | Titre de la page ou catégorie |

| `$url` | Adresse HTTP de la page ou catégorie |
| `$path` | Chemin (adresse unique) de la page ou catégorie |


































































































































































### Exemple



```













{{#breadcrumbs path=$page.path}}

&rarr; <a href="{{ $url }}">{{ $title }}</a><br />










{{/breadcrumbs}}

























































































































































































































```








>












|

>
>
>
>
>
>
>
>

>
>
>
>
>
>
|



>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







|
|
|
|
>
>
>









>
>





|


>
>



>
|
>
>
>
>
>
>
>
>
>
>
>
>

>
>
>
>
>
>
>

>
|
>
>
>
>
>
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|


|
>
>
>
|
>
|
>
>
>
>
>
>
|
>
|
>
>
>
|
>

>
|
>
>
>
>

>
>
>
>
>
>
>
>
>
>
>


>
|







>
>
>
>
|
>

>
>

|
<
|
>
>

>
>
>
>
>

>
|
>
>
>

>
|
>
|
>
|
>

>
>
>
|
>

>
>
>
>
|
>
>

>
>
>
>
|
|
>
>
>
|
>
|
>
|
>

>
>
>
>
|
>

|
|
<
>
>
>
>

|

>
|
>
>
>
>

>
|
>


|
>
|
<
>








|
|
>




|
>

>

|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>



>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
|
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254

255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317

318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339

340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
Title: Référence des sections Brindille

{{{.nav
* [Modules](modules.html)
* [Documentation Brindille](brindille.html)
* [Fonctions](brindille_functions.html)
* **[Sections](brindille_sections.html)**
* [Filtres](brindille_modifiers.html)
}}}

<<toc aside level=2>>

# Sections généralistes

## foreach

Permet d'itérer sur un tableau par exemple. Ainsi chaque élément du tableau exécutera une fois le contenu de la section.

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `from` | **obligatoire** | Variable sur laquelle effectuer l'itération |
| `key` | **optionnel** | Nom de la variable à utiliser pour la clé de l'élément |
| `value` | **optionnel** | Nom de la variable à utiliser pour la valeur de l'élément |

Considérons ce tableau :

```
{{:assign var="tableau" a="bleu" b="orange"}}
```

On peut alors itérer pour récupérer les clés (`a` et `b` ainsi que les valeurs `bleu` et `orange`) :

```
{{#foreach from=$tableau key="key" item="value"}}
{{$key}} = {{$value}}
{{/foreach}}
```

Cela affichera :

```
a = bleu
b = orange
```

Si on a un tableau à plusieurs niveaux, les éléments du tableau sont automatiquement transformés en variable :

```
{{:assign var="tableau.a" couleur="bleu"}}
{{:assign var="tableau.b" couleur="orange"}}
```

```
{{#foreach from=$variable}}
{{$couleur}}
{{/foreach}}
```

Affichera :

```
bleu
orange
```

### Itérer sans tableau

Il est aussi possible de faire `X` itérations, arbitrairement, sans avoir de tableau en entrée, en utilisant le paramètre `count`.

C'est l'équivalent des boucles `for` dans les autres langages de programmation.

Exemple :

```
{{#foreach count=3 key="i"}}
- {{$i}}
{{/foreach}}
```

Affichera :

```
- 0
- 1
- 2
```

## restrict

Permet de limiter (restreindre) une partie de la page aux membres qui sont connectés et/ou qui ont certains droits.

Deux paramètres optionnels peuvent être utilisés ensemble (il n'est pas possible d'utiliser seulement un des deux) :

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `level` | *optionnel* | Niveau d'accès : `read`, `write`, `admin` |
| `section` | *optionnel* | Section où le niveau d'accès doit s'appliquer : `users`, `accounting`, `web`, `documents`, `config` |
| `block` | *optionnel* | Si ce paramètre est présent et vaut `true`, alors l'accès sera interdit si les conditions d'accès demandées ne sont pas remplies : une page d'erreur sera renvoyée. |

Exemple pour voir si un membre est connecté :

```
{{#restrict}}
	Un membre est connecté, mais on ne sait pas avec quels droits.
{{else}}
	Aucun membre n'est connecté.
{{/restrict}}
```

Exemple pour voir si un membre qui peut administrer les membres est connecté :

```
{{#restrict section="users" level="admin"}}
	Un membre est connecté, et il a le droit d'administrer les membres.
{{else}}
	Aucun membre n'est connecté, ou un membre est connecté mais n'est pas administrateur des membres.
{{/restrict}}
```

Pour bloquer l'accès aux membres non connectés, ou qui n'ont pas accès en écriture à la comptabilité.

```
{{#restrict block=true section="accounting" level="write"}}
{{/restrict}}
```

Le mieux est de mettre ce code au début d'un squelette.

# Requêtes SQL

## select

Exécute une requête SQL `SELECT` et effectue une itération pour chaque résultat de la requête.

Pour une utilisation plus simplifiée des requêtes, voir aussi la section [sql](#sql).

Attention : la syntaxe de cette section est différente des autres sections Brindille. En effet après le début (`{{#select`) doit suivre la suite de la requête, et non pas les paramètres :

```
Liste des membres inscrits à la lettre d'informations :
{{#select nom, prenom FROM users WHERE lettre_infos = 1;}}
    - {{prenom}} {{$nom}}<br />
{{else}}
    Aucun membre n'est inscrit à la lettre d'information.
{{/select}}
```

Des paramètres nommés de SQL peuvent être présentés après le point-virgule marquant la fin de la requête SQL :

```
{{:assign prenom="Karim"}}
{{#select * FROM users WHERE prenom = :prenom;
    :prenom=$prenom}}
...
{{/select}}
```

Notez les deux points avant le nom du paramètre. Ces paramètres sont protégés contre les injections SQL (généralement appelés paramètres nommés).

Pour intégrer des paramètres qui ne sont pas protégés (**attention !**), il faut utiliser le point d'exclamation :

```
{{:assign var="categories." value=1}}
{{:assign var="categories." value=2}}
{{#select * FROM users WHERE !categories;
    !categories='id_category'|sql_where:'IN':$categories}}
```

Cela créera la requête suivante : `SELECT * FROM users WHERE id_category IN (1, 2);`

Il est aussi possible d'intégrer directement des variables dans la requête, en utilisant la syntaxe `{$variable|filtre:argument1:argument2}`, comme une variable classique donc, mais au lieu d'utiliser des doubles accolades, on utilise ici des accolades simples. Ces variables seront automatiquement protégées contre les injections SQL.

```
{{:assign prenom="Camille"}}
{{#select * FROM users WHERE initiale_prenom = {$prenom|substr:0:1};}}
```

Cependant, pour plus de lisibilité il est conseillé d'utiliser la syntaxe des paramètres nommés SQL (voir ci-dessus).

Il est aussi possible d'insérer directement du code SQL (attention aux problèmes de sécurité dans ce cas !), pour cela il faut rajouter un point d'exclamation après l'accolade ouvrante :

```
{{:assign var="prenoms." value="Karim"}}
{{:assign var="prenoms." value="Camille"}}
{{#select * FROM users WHERE {!"prenom"|sql_where:"IN":$prenoms};}}
...
{{/select}}
```

Il est aussi possible d'utiliser les paramètres suivants :

| Paramètre | Fonction |
| :- | :- |
| `debug` | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. |
| `explain` | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. | 
| `assign` | Si renseigné, une variable de ce nom sera créée, et le contenu de la ligne y sera assigné. | 

Exemple avec `debug` :

```
{{:assign prenom="Karim"}}
{{#select * FROM users WHERE prenom = :prenom; :prenom=$prenom debug=true}}
...
{{/select}}
```

Affichera juste au dessus du résultat la requête exécutée :

```
SELECT * FROM users WHERE nom = 'Karim'
```

### Paramètre assign

Exemple avec `assign` :

```
{{#select * FROM users WHERE prenom = 'Camille' LIMIT 1; assign="membre"}}{{/select}}
{{$membre.nom}}
```

Il est possible d'utiliser un point final pour que toutes les lignes soient mises dans un tableau :

```
{{#select * FROM users WHERE prenom = 'Camille' LIMIT 10; assign="membres."}}{{/select}}

{{#foreach from=$membres}}
	Nom : {{$nom}}<br />
	Adresse : {{$adresse}}
{{/foreach}}
```

## sql


Effectue une requête SQL de type `SELECT` dans la base de données, mais de manière simplifiée par rapport à `select`.

```
{{#sql select="*, julianday(date) AS day" tables="membres" where="id_categorie = :id_categorie" :id_categorie=$_GET.id_categorie order="numero DESC" begin=":page*100" limit=100 :page=$_GET.page}}
…
{{/sql}}
```

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `tables` | **obligatoire** | Liste des tables à utiliser dans la requête (séparées par des virgules). |
| `select` | *optionnel* | Liste des colonnes à sélectionner, si non spécifié, toutes les colonnes (`*`) seront sélectionnées |

### Sections qui héritent de `sql`

Certaines sections (voir plus bas) héritent de `sql` et rajoutent des fonctionnalités. Dans toutes ces sections, il est possible d'utiliser les paramètres suivants :

| Paramètre | Fonction |
| :- | :- |

| `where` | Condition de sélection des résultats |
| `begin` | Début des résultats, si vide une valeur de `0` sera utilisée. |
| `limit` | Limitation des résultats. Si vide, une valeur de `1000` sera utilisée. |
| `group` | Contenu de la clause `GROUP BY` |
| `having` | Contenu de la clause `HAVING` |
| `order` | Ordre de tri des résultats. Si vide le tri sera fait par ordre d'ajout dans la base de données. |
| `assign` | Si renseigné, une variable de ce nom sera créée, et le contenu de la ligne du résultat y sera assigné. | 
| `debug` | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. |
| `explain` | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. | 

Il est également possible de passer des arguments dans les paramètres à l'aides des arguments nommés qui commencent par deux points `:` :

```
{{#articles where="title = :montitre" :montitre="Actualité"}}
```

# Membres

## users

Liste les membres.

Paramètres possibles :

| `id` | optionnel | Identifiant unique du membre, ou tableau contenant une liste d'identifiants. |
| `search_name` | optionnel | Ne lister que les membres dont le nom correspond au texte passé en paramètre. |
| `id_parent` | optionnel | Ne lister que les membres rattachés à l'identifiant unique du membre responsable indiqué. |

Chaque itération renverra la fiche du membre, ainsi que ces variables :

| `$id` | Identifiant unique du membre |
| `$_name` | Nom du membre, tel que défini dans la configuration |
| `$_login` | Identifiant de connexion du membre, tel que défini dans la configuration |
| `$_number` | Numéro du membre, tel que défini dans la configuration |


## subscriptions

Liste les inscriptions à une ou des activités.

Paramètres possibles :

| Paramètre | | Fonction |
| :- | :- | :- |
| `user` | optionnel | Identifiant unique du membre |
| `active` | optionnel | Si `TRUE`, seules les inscriptions à jour sont listées |
| `id_service` | optionnel | Ne renvoie que les inscriptions à l'activité correspondant à cet ID. |

# Comptabilité

## accounts

Liste les comptes d'un plan comptable.

| Paramètre | Fonction |
| :- | :- |
| `codes` (optionel) | Ne renvoyer que les comptes ayant ces codes (séparer par des virgules). |
| `id` (optionel) | Ne renvoyer que le compte ayant cet ID. |

## balances

Renvoie la balance des comptes.


| Paramètre | Fonction |
| :- | :- |
| `codes` (optionel) | Ne renvoyer que les balances des comptes ayant ces codes (séparer par des virgules). |
| `year` (optionel) | Ne renvoyer que les balances des comptes utilisés sur l'année (indiquer ici un ID de year). |

## transactions

Renvoie des écritures.

| Paramètre | | Fonction |
| :- | :- | :- |
| `id` | optionnel | Indiquer un ID d'écriture pour récupérer ses informations. |
| `user` | optionnel | Indiquer ici un ID utilisateur pour lister les écritures liées à un membre. |

## years

Liste les exercices comptables

| Paramètre | Fonction |
| :- | :- |
| `closed` (optionel) | Mettre `closed=true` pour ne lister que les exercices clôturés, ou `closed=false` pour ne lister que les exercices ouverts. |


# Pour le site web

## breadcrumbs

Permet de récupérer la liste des pages parentes d'une page afin de constituer un [fil d'ariane](https://fr.wikipedia.org/wiki/Fil_d'Ariane_(ergonomie)) permettant de remonter dans l'arborescence du site

Un seul paramètre est possible :

| Paramètre | Fonction |
| :- | :- |
| `uri` (obligatoire) | Adresse unique de la page parente |
| ou `id_page` (obligatoire) | Numéro unique (ID) de la page parente |

Chaque itération renverra trois variables :

| Variable | Contenu |
| :- | :- |
| `$id` | Numéro unique (ID) de la page ou catégorie |
| `$title` | Titre de la page ou catégorie |
| `$uri` | Nom unique de la page ou catégorie |
| `$url` | Adresse HTTP de la page ou catégorie |

### Exemple

```
<ul>
{{#breadcrumbs id_page=$page.id}}
	<li>{{$title}}</li>
{{/breadcrumbs}}
</ul>
```

## pages, articles, categories <sup>(sql)</sup>

Note : ces sections héritent de `sql` (voir plus haut).

* `pages` renvoie une liste de pages, qu'elles soient des articles ou des catégories
* `categories` ne renvoie que des catégories
* `articles` ne renvoie que des articles

À part cela ces trois types de section se comportent de manière identique.

| Paramètre | Fonction |
| :- | :- |
| `search` | Renseigner ce paramètre avec un terme à rechercher dans le texte ou le titre. Dans ce cas par défaut le tri des résultats se fait sur la pertinence, sauf si le paramètre `order` est spécifié. |
| `future` | Renseigner ce paramètre à `false` pour que les articles dont la date est dans le futur n'apparaissent pas, `true` pour ne renvoyer QUE les articles dans le futur, et `null` (ou ne pas utiliser ce paramètre) pour que tous les articles, passés et futur, apparaissent. |
| `uri` | Adresse unique de la page/catégorie à retourner. |
| `id_parent` | Numéro unique (ID) de la catégorie parente. Utiliser `null` pour n'afficher que les articles ou catégories de la racine du site. |
| `parent` | Adresse unique (URI) de la catégorie parente. Exemple pour renvoyer la liste des articles de la sous-catégorie "Événements" de la catégorie "Notre atelier" :  `evenements`. Utiliser `null` pour n'afficher que les articles ou catégories de la racine du site. Ajouter un point d'exclamation au début de la valeur pour inverser la condition. |

Par exemple lister 5 articles de la catégorie "Actualité", qui ne sont pas dans le futur, triés du plus récent au plus ancien :

```
{{#articles future=false parent="actualite" order="published DESC" limit=5}}
	<h3>{{$title}}</h3>
{{/articles}}
```

Chaque élément de ces boucles contiendra les variables suivantes :

| Nom de la variable | Description | Exemple |
| :- | :- | :- |
| `id` | Numéro unique de la page (ID) | `1312` |
| `id_parent` | Numéro unique de la catégorie parente (ID) | `42` |
| `type` | Type de page : `1` = catégorie, `2` = article | `2` |
| `uri` | Adresse unique de la page | `bourse-aux-velos` |
| `url` | Adresse HTTP de la page | `https://site.association.tld/bourse-aux-velos` |
| `path` | Chemin complet de la page | `actualite/atelier/bourse-aux-velos` |
| `parent` | Chemin de la catégorie parente | `actualite/atelier`|
| `title` | Titre de la page | `Bourse aux vélos` |
| `content` | Contenu brut de la page | `# Titre …` |
| `html` | Rendu HTML du contenu de la page | `<div class="web-content"><h1>Titre</h1>…</div>` |
| `has_attachments` | `true` si la page a des fichiers joints, `false` sinon | `true` |
| `published` | Date de publication | `2023-01-01 01:01:01` |
| `modified` | Date de modification | `2023-01-01 01:01:01` |

Si une recherche a été effectuée, deux autres variables sont fournies :

| Nom de la variable | Description | Exemple |
| :- | :- | :- |
| `snippet` | Extrait du contenu contenant le texte recherché (entouré de balises `<mark>`) | `L’ONU appelle la France à s’attaquer aux « profonds problèmes » de <mark>racisme</mark> au sein des forces de…` |
| `url_highlight` | Adresse de la page, où le texte recherché sera mis en évidence | `https://.../onu-racisme#:~:text=racisme%20au%20sein` |


## files, documents, images <sup>(sql)</sup>

Note : ces sections héritent de `sql` (voir plus haut).

* `files` renvoie une liste de fichiers
* `documents` renvoie une liste de fichiers qui ne sont pas des images
* `images` renvoie une liste de fichiers qui sont des images

À part cela ces trois types de section se comportent de manière identique.

Note : seul les fichiers de la section site web sont accessibles, les fichiers de membres, de comptabilité, etc. ne sont pas disponibles.

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `parent` | **obligatoire** si `id_parent` n'est pas renseigné | Nom unique (URI) de l'article ou catégorie parente dont ont veut lister les fichiers |
| `id_parent` | **obligatoire** si `parent` n'est pas renseigné | Numéro unique (ID) de l'article ou catégorie parente dont ont veut lister les fichiers |
| `except_in_text` | *optionnel* | passer `true` à ce paramètre , et seuls les fichiers qui ne sont pas liés dans le texte de la page seront renvoyés |

# Sections relatives aux modules

## form

Permet de gérer la soumission d'un formulaire (`<form method="post"…>` en HTML).

Si l'élément dont le nom spécifié dans le paramètre `on` a été envoyé en `POST`, alors le code à l'intérieur de la section est exécuté.

Toute erreur à l'intérieur de la section arrêtera son exécution, et le message sera ajouté aux erreurs du formulaire.

Une vérification de sécurité [anti-CSRF](https://fr.wikipedia.org/wiki/Cross-site_request_forgery) est également appliquée. Si cette vérification échoue, le message d'erreur "Merci de bien vouloir renvoyer le formulaire." sera renvoyé. Pour que cela marche il faut que le formulaire dispose d'un bouton de type "submit", généré à l'aide de la fonction `button`. Exemple : `{{:button type="submit" name="save" label="Enregistrer"}}`.

En cas d'erreurs, le reste du contenu de la section ne sera pas exécuté. Les messages d'erreurs seront placés dans un tableau dans la variable `$form_errors`.

Il est aussi possible de les afficher simplement avec la fonction `{{:form_errors}}`. Cela revient à faire une boucle sur la variable `$form_errors`.

```
{{#form on="save"}}
	{{if $_POST.titre|trim === ''}}
		{{:error message="Le titre est vide."}}
	{{/if}}
	{{* La ligne suivante ne sera pas exécutée si le titre est vide. *}}
	{{:save title=$_POST.titre|trim}}
{{else}}
	{{:form_errors}}
{{/form}}
```

Il est possible d'utiliser `{{:form_errors}}` en dehors du bloc `{{else}}` :

```
{{#form on="save"}}

{{/form}}

{{:form_errors}}
```

<!--
NOTE (bohwaz, 24/05/2023) : l'utilisation des règles de validation de Laravel me semble donner du code peu lisible, ce n'est donc pas documenté/complètement implémenté pour le moment.

Si l'élément dont le nom spécifié dans le paramètre `on` a été envoyé en `POST`, alors le formulaire est vérifié selon les autres paramètres. Une vérification de sécurité anti-CSRF est également appliquée. Si cette vérification échoue, le message d'erreur "Merci de bien vouloir renvoyer le formulaire." sera renvoyé.

Chaque paramètre supplémentaire indique un champ du formulaire qui doit être récupéré et validé. Le nom du paramètre doit correspondre au nom du champ dans le formulaire. La valeur du paramètre doit contenir une liste de règles de validations, séparées par des virgules `,`. Chaque règle peut prendre des paramètres, après deux points `:`.

Exemple pour un champ de formulaire nommé `titre` dont on veut qu'il soit présent et fasse entre 5 et 100 caractères : `titre="required,min:5,max:100"`

Si le titre fait moins de 5 caractères, le message d'erreur suivant sera renvoyé : `Le champ "titre" fait moins de 5 caractères.`

On peut spécifier une règle spéciale nommée `label` pour changer le nom du champ : `titre="required,min:5,max:100,label:Titre du texte"`. Cela modifiera le message d'erreur : `Le champ "Titre du texte" fait moins de 5 caractères.`

Chacun de ces paramètres sera disponible à l'intérieur de la section sous la forme d'une variable :

```
{{#form titre="required,min:5"}}
	{{:save title=$titre}}
{{/form}}
```


Toute erreur dans le corps de la section `{{#form}}…{{/form}}` fera arrêter l'exécution, et le message d'erreur sera ajouté à la liste des erreurs du formulaire :

```
{{#form on="save"}}
	{{if !$_POST.titre|trim}}
		{{:error message="Pas de titre !"}}
	{{/if}}
	{{* La ligne suivante ne sera pas exécutée si le titre est vide. *}}
	{{:save title=$_POST.titre}}
{{/form}}
```

### Transformation des variables

Certaines règles de validation ont un effet de transformation sur les variables présentes dans le corps de la section :

* `string` s'assure que la variable est une chaîne de texte
* `int` transforme la variable en nombre entier
* `float` transforme la variable en nombre flottant
* `bool` transforme la variable en booléen
* `date` ou `date_format` transforment la variable en date

### Exemple

Considérons ce formulaire par exemple :

```
<form method="post" action="">
	<fieldset>
		<legend>Enregistrer un paiement</legend>
		<dl>
			{{:input type="text" required=true name="titre" label="Titre"}}
			{{:input type="money" required=true name="montant" label="Montant"}}
		</dl>
		<p class="submit">
			{{:button type="submit" label="Enregistrer" name="save"}}
		</p>
	</fieldset>
</form>
```

On pourrait l'enregistrer comme ceci :

```
{{if $_POST.save}}
	{{if $_POST.titre|trim === ''}}
		{{:assign error="Le titre est vide"}}
	{{elseif $_POST.montant|trim === '' || $_POST.montant|money_int < 0}}
		{{:assign error="Le montant est vide ou négatif"}}
	{{else}}
		{{:save title=$_POST.titre|trim amount=$_POST.montant|money_int}}
	{{/if}}
{{/if}}

{{if $error}}
	<p class="error block">{{$error}}</p>
{{/if}}
```

Mais alors dans ce cas il faut multiplier les conditions pour les champs.

La section `{{#form …}}` permet de simplifier ces tests, et s'assurer qu'aucune attaque CSRF n'a lieu :

```
{{#form on="save"
	titre="required,string,min:1,label:Titre"
	montant="required,money,min:0,label:Montant du paiement"
}}
	{{:save title=$titre amount=$montant}}
{{else}}
	{{:form_errors}}
{{/form}}

```

### Règles de validation

| Nom de la règle | Description | Paramètres |
| :- | :- | :- |
| `required` | ...
-->

## load <sup>(sql)</sup>

Note : cette section hérite de `sql` (voir plus haut).

Charge un ou des documents pour le module courant.

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `module` | optionnel | Nom unique du module lié (par exemple : `recu_don`). Si non spécifié, alors le nom du module courant sera utilisé. |
| `key` | optionnel | Clé unique du document |
| `id` | optionnel | Numéro unique du document |
| `each` | optionnel | Traiter une clé du document comme un tableau |

Il est possible d'utiliser d'autres paramètres : `{{#load cle="valeur"}}`. Cela va comparer `"valeur"` avec la valeur de la clé `cle` dans le document JSON. C'est l'équivalent d'écrire `where="json_extract(document, '$.cle') = 'valeur'"`.

Pour des conditions plus complexes qu'une simple égalité, il est possible d'utiliser la syntaxe courte `$$…` dans le paramètre `where`. Ainsi `where="$$.nom LIKE 'Bourse%'` est l'équivalent de `where="json_extract(document, '$.nom') LIKE 'Bourse%'"`.

Voir [la documentation de SQLite pour plus de détails sur la syntaxe de json_extract](https://www.sqlite.org/json1.html#jex).

Note : un index SQL dynamique est créé pour chaque requête utilisant une clause `json_extract`.

Chaque itération renverra ces deux variables :

| Variable | Valeur |
| :- | :- |
| `$key` | Clé unique du document |
| `$id` | Numéro unique du document |

Ainsi que chaque élément du document JSON lui-même.

### Exemples

Afficher le nom du document dont la clé est `facture_43` :

```
{{#load key="facture_43"}}
{{$nom}}
{{/load}}
```

Afficher la liste des devis du module `invoice` depuis un autre module par exemple :

```
{{#load module="invoice" type="quote"}}
<h1>Titre du devis : {{$subject}}</h1>
<h2>Montant : {{$total}}</h2>
{{/load}}
```

### Utilisation du paramètre `each`

Le paramètre `each` est utile pour faire une boucle sur un tableau contenu dans le document. Ce paramètre doit contenir un chemin JSON valide. Par exemple `membres[1].noms` pour boucler sur le tableau `noms`, du premier élément du tableau `membres`. Voir la documentation [de la fonction json_each de SQLite pour plus de détails](https://www.sqlite.org/json1.html#jeach).

Pour chaque itération de la section, la variable `{{$value}}` contiendra l'élément recherché dans le critère `each`.

Par exemple nous pouvons avoir un élément `membres` dans notre document JSON qui contient un tableau de noms de membres :

```
{{:assign var="membres." value="Greta Thunberg}}
{{:assign var="membres." value="Valérie Masson-Delmotte"}}
{{:save membres=$membres}}
```

Nous pouvons utiliser `each` pour faire une liste :

```
{{:load each="membres"}}
- {{$value}}
{{/load}}
```

Ou pour récupérer les documents qui correspondent à un critère :

```
{{:load each="membres" where="value = 'Greta Thunberg'"}}
Le document n°{{$id}} est celui qui parle de Greta.
{{/load}}
```

## list

Attention : cette section n'hérite **PAS de `sql`**.

Un peu comme `{{#load}}` cette section charge les documents d'un module, mais au sein d'une liste (tableau HTML).

Cette liste gère automatiquement l'ordre selon les préférences des utilisateurs, ainsi que la pagination.

Cette section est très puissante et permet de générer des listes simplement, une fois qu'on a saisi la logique de son fonctionnement.

| Paramètre | Optionnel / obligatoire ? | Fonction |
| :- | :- | :- |
| `schema` | **requis** si `select` n'est pas fourni | Chemin vers un fichier de schéma JSON qui représenterait le document |
| `select` | **requis** si `schema` n'est pas fourni | Liste des colonnes à sélectionner, sous la forme `$$.colonne AS "Colonne"`, chaque colonne étant séparée par un point-virgule. |
| `module` | *optionnel* | Nom unique du module lié (par exemple : `recu_don`). Si non spécifié, alors le nom du module courant sera utilisé. |
| `columns` | *optionnel* | Permet de n'afficher que certaines colonnes du schéma. Indiquer ici le nom des colonnes, séparées par des virgules. |
| `order` | *optionnel* | Colonne utilisée par défaut pour le tri (si l'utilisateur n'a pas choisi le tri sur une autre colonne). Si `select` est utilisé, il faut alors indiquer ici le numéro de la colonne, et non pas son nom. |
| `desc` | *optionnel* | Si ce paramètre est à `true`, l'ordre de tri sera inversé. |
| `max` | *optionnel* | Nombre d'éléments à afficher dans la liste, sur chaque page. |
| `where` | *optionnel* | Condition `WHERE` de la requête SQL. |
| `debug` | *optionnel* | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. |
| `explain` | *optionnel* | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. | 

Pour déterminer quelles colonnes afficher dans le tableau, il faut utiliser soit le paramètre `schema` pour indiquer un fichier de schéma JSON qui sera utilisé pour donner le libellé des colonnes (via la `description` indiquée dans le schéma), soit le paramètre `select`, où il faut alors indiquer le nom et le libellé des colonnes sous la forme `$$.colonne1 AS "Libellé"; $$.colonne2 AS "Libellé 2"`.

Comme pour `load`, il est possible d'utiliser des paramètres supplémentaires : `cle="valeur"`. Cela va comparer `"valeur"` avec la valeur de la clé `cle` dans le document JSON. C'est l'équivalent d'écrire `where="json_extract(document, '$.cle') = 'valeur'"`.

Pour des conditions plus complexes qu'une simple égalité, il est possible d'utiliser la syntaxe courte `$$…` dans le paramètre `where`. Ainsi `where="$$.nom LIKE 'Bourse%'` est l'équivalent de `where="json_extract(document, '$.nom') LIKE 'Bourse%'"`.

Voir [la documentation de SQLite pour plus de détails sur la syntaxe de json_extract](https://www.sqlite.org/json1.html#jex).

Note : un index SQL dynamique est créé pour chaque requête utilisant une clause `json_extract`.

Chaque itération renverra toujours ces deux variables :

| Variable | Valeur |
| :- | :- |
| `$key` | Clé unique du document |
| `$id` | Numéro unique du document |

Ainsi que chaque élément du document JSON lui-même.

La section ouvre un tableau HTML et le ferme automatiquement, donc le contenu de la section **doit** être une ligne de tableau HTML (`<tr>`).

Dans chaque ligne du tableau il faut respecter l'ordre des colonnes indiqué dans `columns` ou `select`. Une dernière colonne est réservée aux boutons d'action : `<td class="actions">...</td>`.

### Exemples

Lister le nom, la date et le montant des reçus fiscaux, à partir du schéma JSON suivant :

```
{
	"$schema": "https://json-schema.org/draft/2020-12/schema",
	"type": "object",
	"properties": {
		"date": {
			"description": "Date d'émission",
			"type": "string",
			"format": "date"
		},
		"adresse": {
			"description": "Adresse du bénéficiaire",
			"type": "string"
		},
		"nom": {
			"description": "Nom du bénéficiaire",
			"type": "string"
		},
		"montant": {
			"description": "Montant",
			"type": "integer",
			"minimum": 0
		}
	}
}
```

Le code de la section sera alors comme suivant :

```
{{#list schema="./recu.schema.json" columns="nom, date, montant"}}
	<tr>
		<th>{{$nom}}</th>
		<td>{{$date|date_short}}</td>
		<td>{{$montant|raw|money_currency}}</td>
		<td class="actions">
			{{:linkbutton shape="eye" label="Ouvrir" href="./voir.html?id=%d"|args:$id target="_dialog"}}
		</td>
	</tr>
{{else}}
	<p class="alert block">Aucun reçu n'a été trouvé.</p>
{{/list}}
```

Si le paramètre `columns` avait été omis, la colonne `adresse` aurait également été incluse.

Il est à noter que si l'utilisation directe du schéma est bien pratique, cela ne permet pas de récupérer des informations plus complexes dans la structure JSON, par exemple une sous-clé ou l'application d'une fonction SQL. Dans ce cas il faut obligatoirement utiliser `select`. Par exemple ici on veut pouvoir afficher l'année, et trier sur l'année par défaut :

```
{{#list select="$$.nom AS 'Nom du donateur' ; strftime('%Y', $$.date) AS 'Année'" order=2}}
	<tr>
		<th>{{$nom}}</th>
		<td>{{$col2}}</td>
		<td class="actions">
			{{:linkbutton shape="eye" label="Ouvrir" href="./voir.html?id=%d"|args:$id target="_dialog"}}
		</td>
	</tr>
{{else}}
	<p class="alert block">Aucun reçu n'a été trouvé.</p>
{{/list}}
```

On peut utiliser le nom des clés du document JSON, mais sinon pour faire référence à la valeur d'une colonne spécifique dans la boucle, il faut utiliser son numéro d'ordre (qui commence à `1`, pas zéro). Ici on veut afficher l'année, donc la seconde colonne, donc `$col1`.

Noter aussi l'utilisation du numéro de la colonne de l'année (`2`) pour le paramètre `order`, qui avec `select` doit indiquer le numéro de la colonne à utiliser pour l'ordre.

Modified doc/admin/markdown.md from [3957587aef] to [aba3e0f63e].

414
415
416
417
418
419
420






























421
422
423
424
425
426
427
428
429
430
431






















432
433
434
435
436
437
438
Il est aussi possible d'utiliser la syntaxe avec des paramètres nommés :

```
<<image file="Nom_fichier.jpg" align="center" caption="Légende">>
```

Les images qui ne sont pas mentionnées dans le texte seront affichées après le texte sous forme de galerie.































## Fichiers joints

Pour créer un bouton permettant de voir ou télécharger un fichier joint à la page web, il suffit d'utiliser la syntaxe suivante :

```
<<file|Nom_fichier.ext|Libellé>>
```

* `Nom_fichier.ext` : remplacer par le nom du fichier  (parmi les fichiers joints à la page)
* `Libellé` : indique le libellé du qui sera affiché sur le bouton, si aucun libellé n'est indiqué alors c'est le nom du fichier qui sera affiché























## Sommaire / table des matières automatique

Il est possible de placer le code `<<toc>>` pour générer un sommaire automatiquement à partir des titres et sous-titres :

```
<<toc>>







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>











>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
Il est aussi possible d'utiliser la syntaxe avec des paramètres nommés :

```
<<image file="Nom_fichier.jpg" align="center" caption="Légende">>
```

Les images qui ne sont pas mentionnées dans le texte seront affichées après le texte sous forme de galerie.

## Galerie d'images

Il est possible d'afficher une galerie d'images (sous forme d'images miniatures) avec la balise `<<gallery` qui contient la liste des images à mettre dans la galerie :

```
<<gallery
Nom_fichier.jpg
Nom_fichier_2.jpg
>>
```

Si aucun nom de fichier n'est indiqué, alors toutes les images jointes à la page seront affichées :

```
<<gallery>>
```

### Diaporama d'images

On peut également afficher cette galerie sous forme de diaporama. Dans ce cas une seule image est affichée, et on peut passer de l'une à l'autre.

La syntaxe est la même, mais on ajoute le mot `slideshow` après le mot `gallery` :

```
<<gallery slideshow
Nom_fichier.jpg
Nom_fichier_2.jpg
>>
```

## Fichiers joints

Pour créer un bouton permettant de voir ou télécharger un fichier joint à la page web, il suffit d'utiliser la syntaxe suivante :

```
<<file|Nom_fichier.ext|Libellé>>
```

* `Nom_fichier.ext` : remplacer par le nom du fichier  (parmi les fichiers joints à la page)
* `Libellé` : indique le libellé du qui sera affiché sur le bouton, si aucun libellé n'est indiqué alors c'est le nom du fichier qui sera affiché

## Vidéos

Pour inclure un lecteur vidéo dans la page web à partir d'un fichier vidéo joint à la page, il faut utiliser le code suivant :

```
<<video|Nom_du_fichier.ext>>
```

On peut aussi spécifier d'autres paramètres :

* `file` : nom du fichier vidéo
* `poster` : nom de fichier d'une image utilisée pour remplacer la vidéo avant qu'elle ne soit lue
* `subtitles` : nom d'un fichier de sous-titres au format VTT (le format SRT n'est pas géré par les navigateurs)
* `width` : largeur de la vidéo (en pixels)
* `height` : hauteur de la vidéo (en pixels)

Exemple :

```
<<video file="Ma_video.webm" poster="Ma_video_poster.jpg" width="640" height="360" subtitles="Ma_video_sous_titres.vtt">>
```

## Sommaire / table des matières automatique

Il est possible de placer le code `<<toc>>` pour générer un sommaire automatiquement à partir des titres et sous-titres :

```
<<toc>>

Added doc/admin/modules.md version [c7da91affa].









































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
Title: Développer des modules pour Paheko

{{{.nav
* **[Modules](modules.html)**
* [Documentation Brindille](brindille.html)
* [Fonctions](brindille_functions.html)
* [Sections](brindille_sections.html)
* [Filtres](brindille_modifiers.html)
}}}

<<toc aside>>

# Introduction

Depuis la version 1.3, Paheko dispose d'extensions modifiables, nommées **Modules**.

Les modules permettent de créer et modifier des formulaires, des modèles de documents simples, à imprimer, mais aussi de créer des "mini-applications" directement dans l'administration de l'association, avec le minimum de code, sans avoir à apprendre à programmer PHP.

Les modules utilisent le langage [Brindille](brindille.html), aussi utilisé pour le site web (qui est lui-même un module). Avec Brindille on parle d'un **squelette** pour un fichier texte contenant du code Brindille.

Les modules ne permettent pas d'exécuter du code PHP, ni de modifier la base de données en dehors des données du module, contrairement aux [plugins](https://fossil.kd2.org/paheko/wiki?name=Documentation/Plugin&p). Grâce à Brindille, les administrateurs de l'association peuvent modifier ou créer de nouveaux modules sans risques pour le serveur, car le code Brindille ne permet pas d'exécuter de fonctions dangereuses. Les **plugins** eux sont écrits en PHP et ne peuvent pas être modifiés par une association. Du fait des risques de sécurité, seuls les plugins officiels sont proposés sur Paheko.cloud.

# Exemples

Paheko fournit quelques modules par défaut, qui peuvent être modifiés ou servir d'inspiration pour de nouveaux modules :

* Reçu de don simple
* Reçu de paiement simple
* Reçu fiscal
* Cartes de membres
* Heures d'ouverture
* Modèles d'écritures comptables

Ces exemples sont développés directement avec Brindille et peuvent être modifiés ou lus depuis le menu **Configuration**, onglet **Extensions**.

Un module fourni dans Paheko peut être modifié, et en cas de problème il peut être remis à son état d'origine.

D'autres exemples d'utilisation sont imaginables :

* Auto-remplissage de la déclaration de la liste des dirigeants à la préfecture
* Compte de résultat et bilan conforme au modèle du plan comptable
* Formulaires partagés entre la partie privée, et le site web (voir par exemple le module "heures d'ouverture")
* Gestion de matériel prêté par l'association

# Pré-requis

Une connaissance de la programmation informatique est souhaitable pour commencer à modifier ou créer des modules, mais cela n'est pas requis, il est possible d'apprendre progressivement.

# Résumé technique

* Utilisation de la syntaxe Brindille
* Les modules peuvent utiliser toutes les fonctions et boucles de Brindille
* Les modules peuvent stocker et récupérer des données dans la base SQLite dans une table clé-valeur spécifique à chaque module
* Les données du module sont stockées en JSON, on peut faire des requêtes complètes avec l'extension [JSON de SQLite](https://www.sqlite.org/json1.html)
* Les données peuvent être validées avant enregistrement en utilisant [JSON Schema](https://json-schema.org/understanding-json-schema/)
* Un module peut également accéder aux données des autres modules
* Un module peut aussi accéder à toutes les données de la base de données, sauf certaines données à risque (voir plus bas)
* Un module ne peut pas modifier les données de la base de données
* Paheko crée automatiquement des index sur les requêtes SQL des modules, permettant de rendre les requêtes rapides

# Structure des répertoires

Chaque module a un nom unique (composé uniquement de lettres minuscules, de tirets bas et de chiffres) et dispose d'un sous-répertoire dans le dossier `modules`. Ainsi le module `recu_don` serait dans le répertoire `modules/recu_don`.

Dans ce répertoire le module peut avoir autant de fichiers qu'il veut, mais certains fichiers ont une fonction spéciale :

* `module.ini` : contient les informations sur le module, voir ci-dessous pour les détails
* `config.html` : si ce squelette existe, un bouton "Configurer" apparaîtra dans la liste des modules (Configuration -> Modules) et affichera ce squelette dans un dialogue
* `icon.svg` : icône du module, qui sera utilisée sur la page d'accueil, si le bouton est activé, et dans la liste des modules. Attention l'élément racine du fichier doit porter l'id `img` pour que l'icône fonctionne (`<svg id="img"...>`), notamment pour que les couleurs du thème s'appliquent à l'icône.
* `README.md` : si ce fichier existe, son contenu sera affiché dans les détails du module

Les modules peuvent également avoir des `snippets`, ce sont des squelettes qui seront inclus à des endroits précis de l'interface, permettant de rajouter des fonctionnalités, ils sont situés dans le sous-répertoire `snippets` du module :

* `snippets/transaction_details.html` : sera inclus en dessous de la fiche d'une écriture comptable
* `snippets/user_details.html` : sera inclus en dessous de la fiche d'un membre
* `snippets/home_button.html` : sera inclus dans la liste des boutons de la page d'accueil (ce fichier ne sera pas appelé si `home_button` est à `true` dans `module.ini`, il le remplace)

## Fichier module.ini

Ce fichier décrit le module, au format INI (`clé=valeur`), en utilisant les clés suivantes :

* `name` (obligatoire) : nom du module
* `description` : courte description de la fonctionnalité apportée par le module
* `author` : nom de l'auteur
* `author_url` : adresse web HTTP menant au site de l'auteur
* `home_button` : indique si un bouton pour ce module doit être affiché sur la page d'accueil (`true` ou `false`)
* `menu` : indique si ce module doit être listé dans le menu de gauche (`true` ou `false`)
* `restrict_section` : indique la section auquel le membre doit avoir accès pour pouvoir voir le menu de ce module, parmi `web, documents, users, accounting, connect, config`
* `restrict_level` : indique le niveau d'accès que le membre doit avoir dans la section indiquée pour pouvoir voir le menu de ce module, parmi `read, write, admin`.

Attention : les directives `restrict_section` et `restrict_level` ne contrôlent *que* l'affichage du lien vers le module dans le menu et dans les boutons de la page d'accueil, mais pas l'accès aux pages du module.

# Variables spéciales

Toutes les pages d'un module disposent de la variable `$module` qui contient l'entité du module en cours :

* `$module.name` contient le nom unique (`recu_don` par exemple)
* `$module.label` le libellé du module
* `$module.description` la description
* `$module.config` la configuration du module
* `$module.url` l'adresse URL du module (`https://site-association.tld/m/recu_don/` par exemple)

# Stockage de données

Un module peut stocker des données de deux manières : dans sa configuration, ou dans son stockage de documents JSON.

## Configuration

La première manière est de stocker des informations dans la configuration du module. Pour cela on utilise la fonction `save` et la clé `config` :

```
{{:save key="config" accounts_list="512A,512B" check_boxes=true}}
```

On pourra retrouver ces valeurs dans la variable `$module.config` :

```
{{if $module.config.check_boxes}}
  {{$module.config.accounts_list}}
{{/if}}
```

## Stockage de documents JSON

Chaque module peut stocker ses données dans une base de données clé-document qui stockera les données dans des documents au format JSON dans une table SQLite.

Grâce aux [fonctions JSON de SQLite](https://www.sqlite.org/json1.html) on pourra ensuite effectuer des recherches sur ces documents.

Pour enregistrer il suffit d'utiliser la fonction `save` :

```
{{:save key="facture001" type="facture" date="2022-01-01" label="Vente de petits pains au chocolat" total="42"}}
```

Si la clé indiquée (dans le paramètre `key`) n'existe pas, l'enregistrement sera créé, sinon il sera mis à jour avec les valeurs données.

### Validation

On peut utiliser un [schéma JSON](https://json-schema.org/understanding-json-schema/) pour valider que le document qu'on enregistre est valide :

```
{{:save validate_schema="./document.schema.json" type="facture" date="2022-01-01" label="Vente de petits pains au chocolat" total="42"}}
```

Le fichier `document.schema.json` devra être dans le même répertoire que le squelette et devra contenir un schéma valide. Voici un exemple :

```
{
	"$schema": "https://json-schema.org/draft/2020-12/schema",
	"type": "object",
	"properties": {
		"date": {
			"description": "Date d'émission",
			"type": "string",
			"format": "date"
		},
		"type": {
			"description": "Type de document",
			"type": "string",
			"enum": ["devis", "facture"]
		},
		"total": {
			"description": "Montant total",
			"type": "integer",
			"minimum": 0
		},
		"label": {
			"description": "Libellé",
			"type": "string"
		},
		"description": {
			"description": "Description",
			"type": ["string", "null"]
		}
	},
	"required": [ "type", "date", "total", "label"]
}
```

Si le document fourni n'est pas conforme au schéma, il ne sera pas enregistré et une erreur sera affichée.

#### Propriété non requise

Si vous souhaitez utiliser dans votre document une propriété non requise, il ne faut pas la fournir en paramètre de la fonction `save`.

Si elle est fournie mais vide, il faut aussi autoriser le type `null` (en minuscules) au type de votre propriété.

Exemple :  

	[...]
		"description": {
			"description": "Description",
			"type": ["string", "null"]
		}
	[...]

### Stockage JSON dans SQLite (pour information)

Explication du fonctionnement technique derrière la fonction `save`.

En pratique chaque enregistrement sera placé dans une table SQL dont le nom commence par `module_data_`. Ici la table sera donc nommée `module_data_factures` si le nom unique du module est `factures`.

Le schéma de cette table est le suivant :

```
CREATE TABLE module_data_factures (
  id INTEGER PRIMARY KEY NOT NULL,
  key TEXT NULL,
  document TEXT NOT NULL
);

CREATE UNIQUE INDEX module_data_factures_key ON module_data_factures (key);
```

Comme on peut le voir, chaque ligne dans la table peut avoir une clé unique (`key`), et un ID ou juste un ID auto-incrémenté. La clé unique n'est pas obligatoire, mais peut être utile pour différencier certains documents.

Par exemple le code suivant :

```
{{:save key="facture_43" nom="Facture de courses"}}
```

Est l'équivalent de la requête SQL suivante :

```
INSERT OR REPLACE INTO module_data_factures (key, document) VALUES ('facture_43', '{"nom": "Facture de courses"}');
```

### Récupération et liste de documents

Il sera ensuite possible d'utiliser la boucle `load` pour récupérer les données :

```
{{#load id=42}}
	Ce document est de type {{$type}} créé le {{$date}}.
	<h2>{{$label}}</h2>
	À payer : {{$total}} €
	{{else}}
	Le document numéro 42 n'a pas été trouvé.
{{/load}}
```

Cette boucle `load` permet aussi de faire des recherches sur les valeurs du document :

```
<ul>
{{#load where="$$.type = 'facture'" order="date DESC"}}
	<li>{{$label}} ({{$total}} €)</li>
{{/load}}
</ul>
```

La syntaxe `$$.type` indique d'aller extraire la clé `type` du document JSON.

C'est un raccourci pour la syntaxe SQLite `json_extract(document, '$.type')`.

# Export et import de modules

Il est possible d'exporter un module modifié. Cela créera un fichier ZIP contenant à la fois le code modifié et le code non modifié.

De la même manière il est possible d'importer un module à partir d'un fichier ZIP d'export. Si vous créez votre fichier ZIP manuellement, attention à respecter le fait que le code du module doit se situer dans le répertoire `modules/nom_du_module` du fichier ZIP. Tout fichier ou répertoire situé en dehors de cette arborescence provoquera une erreur et l'impossibilité d'importer le module.

# Restrictions

* Il n'est pas possible de télécharger ou envoyer des données depuis un autre serveur
* Il n'est pas possible d'écrire un fichier local

## Envoi d'e-mail

Voir [la documentation de la fonction `{{:mail}}`](brindille_functions.html#mail)

## Tables et colonnes de la base de données

Pour des raisons de sécurité, les modules ne peuvent pas accéder à toutes les données de la base de données.

Les colonnes suivantes de la table `users` (liste des membres) renverront toujours `NULL` :

* `password`
* `pgp_key`
* `otp_secret`

Tenter de lire les données des tables suivantes résultera également en une erreur :

* emails
* emails_queue
* compromised_passwords_cache
* compromised_passwords_cache_ranges
* api_credentials
* plugins_signals
* config
* users_sessions
* logs

Modified doc/admin/skriv.md from [2506065a25] to [840d985008].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Title: Référence rapide SkrivML - Paheko

<<toc aside>>

# Syntaxe SkrivML

Paheko propose la syntaxe [SkrivML](https://fossil.kd2.org/garradin/doc/trunk/doc/skrivml.html) pour le formatage du texte des pages du site web.

## Styles de texte

| Style | Syntaxe |
| :- | :- |
| *Italique* | `Entourer le texte de ''deux apostrophes''` |
| **Gras** | `Entourer le texte de **deux astérisques**` |






|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
Title: Référence rapide SkrivML - Paheko

<<toc aside>>

# Syntaxe SkrivML

Paheko propose la syntaxe [SkrivML](https://fossil.kd2.org/paheko/doc/trunk/doc/skrivml.html) pour le formatage du texte des pages du site web.

## Styles de texte

| Style | Syntaxe |
| :- | :- |
| *Italique* | `Entourer le texte de ''deux apostrophes''` |
| **Gras** | `Entourer le texte de **deux astérisques**` |

Modified doc/admin/web.md from [425b2a29b8] to [9859ee4f6c].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

46
47
48
49

50
51
52
53
54
55
56
57
58
59
60
61









































Title: Squelettes du site web dans Paheko

{{{.nav
* [Documentation Brindille](brindille.html)
* [Fonctions](brindille_functions.html)
* [Sections](brindille_sections.html)
* [Filtres](brindille_modifiers.html)
}}}

# Les squelettes du site web

Les squelettes sont un ensemble de fichiers (code *Brindille*, CSS, etc.) qui permettent de modéliser l'apparence du site web selon ses préférences et besoins.

La syntaxe utilisée dans les squelettes s'appelle **Brindille**.

## Exemples de sites réalisés avec Paheko

* [Faidherbe Alumni](https://www.alumni-faidherbe.fr/)
* [ASBM Mortagne](https://asbm-mortagne.fr/)
* [Vélocité 63](https://www.velocite63.fr/)
* [La rustine, Dijon](https://larustine.org/)
* [Tauto école](https://tauto-ecole.net/) [(les squelettes sont disponibles ici)](https://gitlab.com/noizette/squelettes-garradin-tauto-ecole/)
* [La boîte à vélos](https://boiteavelos.chenove.net/)
* [Jardin du bon pasteur](https://jardindubonpasteur.fr)

## Fonctionnement des squelettes

Par défaut sont fournis plusieurs squelettes qui permettent d'avoir un site web basique mais fonctionnel : page d'accueil, menu avec les catégories de premier niveau, et pour afficher les pages, les catégories, les fichiers joints et images. Il y a également un squelette `atom.xml` permettant aux visiteurs d'accéder aux dernières pages publiées.

Les squelettes peuvent être modifiés via l'onglet **Configuration** de la section **Site web** du menu principal.

Une fois un squelette modifié, il apparaît dans la liste comme étant modifié, sinon il apparaît comme *défaut*. Si vous avez commis une erreur, il est possible de restaurer le squelette d'origine.

Dans la gestion des squelettes, seuls les fichiers ayant une des extensions `tpl, btpl, html, htm, b, skel, xml` seront traités par Brindille. De même, les fichiers qui n'ont pas d'extension seront également traités par Brindille.

Les autres types de fichiers seront renvoyés sans traitement, comme des fichiers "bruts". En d'autres termes, il n'est pas possible de mettre du code *Brindille* dans des fichiers non-texte.

Ainsi, nous appelons ici *squelette* tout fichier situé dans l'onglet **Configuration**, mais seuls les fichiers traités par Brindille sont de "vrais" squelettes au sens code exécutable par *Brindille*. Les autres ne sont pas traités ni exécutés : ils ne peuvent pas contenir de code Brindille.

### Adresses des pages du site

Les squelettes sont appelés en fonction des règles suivantes (dans l'ordre) :

| Adresse | Squelette appelé |
| ---- | ---- |

| `/` (racine du site) | `index.html` |
| Toute autre adresse se terminant par un slash `/` | `category.html` |
| Toute autre adresse, si un article existe avec cette URI | `article.html` |
| Toute autre adresse, si un squelette du même nom existe | Squelettes du même nom |


Ainsi l'adresse `https://monsite.paheko.cloud/Actualite/` appellera le squelette `category.html`, mais l'adresse `https://monsite.paheko.cloud/Actualite` (sans slash à la fin) appellera le squelette `article.html` si un article avec l'URI `Actualite` existe. Sinon si un squelette `Actualite` (sans extension) existe, c'est lui qui sera appelé.

Autre exemple : `https://monsite.paheko.cloud/atom.xml` appellera le squelette `atom.xml` vu qu'il existe.

Ceci vous permet de créer de nouvelles pages dynamiques sur le site, par exemple pour notre atelier vélo nous avons une page `https://larustine.org/velos` qui appelle le squelette `velos` (sans extension), qui va afficher la liste des vélos actuellement en stock dans notre hangar.

Le type de fichier étant déterminé selon l'extension (`.html, .css, etc.`) pour les fichiers traités par Brindille, un fichier sans extension sera considéré comme un fichier texte par le navigateur. Si on veut que le squelette `velos` (sans extension) s'affiche comme du HTML il faut forcer le type en mettant le code `{{:http type="text/html"}}` au début du squelette (première ligne).

### Squelette content.css

Ce fichier est particulier, car il définit le style du contenu des pages et des catégories. Ainsi il est également utilisé quand vous éditez un contenu dans l'administration. Donc si vous souhaitez modifier le style d'un élément du texte, il vaux mieux modifier ce fichier, sinon le rendu sera différent entre l'administration et le site public.




















































|

|

|







<

|







<
<
<
<
<
<
|



|
|
>
|
|
|
<
>

|

|





|

|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

24
25
26
27
28
29
30
31
32






33
34
35
36
37
38
39
40
41
42

43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
Title: Squelettes du site web dans Paheko

{{{.nav
* [Documentation Brindille](brindille.html)
* [Fonctions](brindille_functions.html)
* [Sections](brindille_sections.html)
* [Filtres](brindille_modifiers.html)
}}}

# Les squelettes du site web

Les squelettes sont un ensemble de fichiers qui permettent de modéliser l'apparence du site web selon ses préférences et besoins.

La syntaxe utilisée dans les squelettes s'appelle **[Brindille](brindille.html)**. Voir la [documentation de Brindille](brindille.html) pour son fonctionnement.

# Exemples de sites réalisés avec Paheko

* [Faidherbe Alumni](https://www.alumni-faidherbe.fr/)
* [ASBM Mortagne](https://asbm-mortagne.fr/)
* [Vélocité 63](https://www.velocite63.fr/)
* [La rustine, Dijon](https://larustine.org/)
* [Tauto école](https://tauto-ecole.net/) [(les squelettes sont disponibles ici)](https://gitlab.com/noizette/squelettes-garradin-tauto-ecole/)
* [La boîte à vélos](https://boiteavelos.chenove.net/)


# Fonctionnement des squelettes

Par défaut sont fournis plusieurs squelettes qui permettent d'avoir un site web basique mais fonctionnel : page d'accueil, menu avec les catégories de premier niveau, et pour afficher les pages, les catégories, les fichiers joints et images. Il y a également un squelette `atom.xml` permettant aux visiteurs d'accéder aux dernières pages publiées.

Les squelettes peuvent être modifiés via l'onglet **Configuration** de la section **Site web** du menu principal.

Une fois un squelette modifié, il apparaît dans la liste comme étant modifié, sinon il apparaît comme *défaut*. Si vous avez commis une erreur, il est possible de restaurer le squelette d'origine.







## Adresses des pages du site

Les squelettes sont appelés en fonction des règles suivantes (dans l'ordre) :

| Squelette appelé | Cas où le squelette est appelé |
| :---- | :---- |
| `adresse` | Si l'adresse `adresse` est appelée, et qu'un squelette du même nom existe |
| `adresse/index.html` | Si l'adresse `adresse/` est appelée, et qu'un squelette `index.html` dans le répertoire du même nom existe |
| `category.html` | Toute autre adresse se terminant par un slash `/`, si une catégorie du même nom existe |
| `article.html` | Toute autre adresse, si une page du même nom existe | 

| `404.html` | Si aucune règle précédente n'a fonctionné |

Ainsi l'adresse `https://monsite.paheko.cloud/Actualite/` appellera le squelette `category.html`, mais l'adresse `https://monsite.paheko.cloud/Actualite` (sans slash à la fin) appellera le squelette `article.html` si un article avec l'URI `Actualite` existe. Si un squelette `Actualite` (sans extension) existe, c'est lui qui sera appelé en priorité et ni `category.html` ni `article.html` ne seront appelés.

Autre exemple : `https://monsite.paheko.cloud/atom.xml` appellera le squelette `atom.xml` s'il existe.

Ceci vous permet de créer de nouvelles pages dynamiques sur le site, par exemple pour notre atelier vélo nous avons une page `https://larustine.org/velos` qui appelle le squelette `velos` (sans extension), qui va afficher la liste des vélos actuellement en stock dans notre hangar.

Le type de fichier étant déterminé selon l'extension (`.html, .css, etc.`) pour les fichiers traités par Brindille, un fichier sans extension sera considéré comme un fichier texte par le navigateur. Si on veut que le squelette `velos` (sans extension) s'affiche comme du HTML il faut forcer le type en mettant le code `{{:http type="text/html"}}` au début du squelette (première ligne).

## Fichier content.css

Ce fichier est particulier, car il définit le style du contenu des pages et des catégories.

Ainsi il est également utilisé quand vous éditez un contenu dans l'administration. Donc si vous souhaitez modifier le style d'un élément du texte, il vaux mieux modifier ce fichier, sinon le rendu sera différent entre l'administration et le site public.

# Cache

Depuis la version 1.3, Paheko dispose d'un cache statique du site web.

Cela veut dire que les pages du site web sont enregistrées sous la forme de fichiers HTML statiques, et le serveur web renvoie directement ce fichier sans faire appel à Paheko et son code PHP.

Les fichiers liés aux pages web sont également mis en cache de cette manière, en utilisant des liens symboliques.

Ce cache permet d'avoir un site web très rapide, même s'il reçoit des millions de visites.

## Désactiver le cache

Le seul inconvénient c'est qu'une page mise en cache étant statique, si vous utilisez du contenu dynamique (par exemple afficher un texte différent selon la langue du visiteur) dans le squelette Brindille, alors cela ne fonctionnera plus.

Dans ce cas-là, vous pouvez assigner la variable `nocache` dans le squelette pour désactiver le cache pour cette page :

```
{{:assign nocache=true}}
```

Pour permettre des usages du type "affichage en temps presque réel des horaires d'ouverture", le cache d'une page HTML est effacé et remis à jour au bout d'une heure.

## Exceptions

Il est à noter que le cache n'est pas appelé dans les cas suivants :

* si la requête vers la page est d'un autre type que `GET` ou `HEAD`, ainsi par exemple l'envoi d'un formulaire (`POST`) ne sera jamais mis en cache ;
* si la requête vers la page contient des paramètres dans l'adresse (par exemple `velos.html?list=1` : cette page ne sera pas mise en cache) ;
* si le visiteur est connecté à l'administration de l'association. Ainsi si vous avez des parties du squelette qui varient en fonction de si la personne est connectée, le cache ne posera pas de problème.

Le cache est intégralement effacé à chaque modification du site web.

Le cache ne concerne que les pages et fichiers du site web public. Il ne concerne pas les modules, les extensions, ou l'administration.

Attention :

* avec un serveur sous Windows, le cache est désactivé car Windows ne sait pas gérer les liens symboliques ;
* seul Apache sait gérer le cache statique, le cache est désactivé avec les autres serveurs web (nginx, etc.).

Modified src/.htaccess.www from [3ae6d113cf] to [1fa848cf6a].

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Objectif: supprimer le /www/ de l'URL
# Note: il est probable qu'il soit nécessaire d'adapter la configuration
# à votre hébergeur !

<IfModule mod_rewrite.c>
	RewriteEngine on
	## Remplacer dans les lignes suivantes
	## /garradin/ par le nom du sous-répertoire où est installé Garradin
 	RewriteBase /garradin/
	FallbackResource /garradin/www/_route.php

	## Ne pas modifier les lignes suivantes, les décommenter simplement !
	RewriteCond %{REQUEST_URI} !www/
	RewriteRule ^(.*)$ www/$1 [QSA,L]
</IfModule>







|
|
|





20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Objectif: supprimer le /www/ de l'URL
# Note: il est probable qu'il soit nécessaire d'adapter la configuration
# à votre hébergeur !

<IfModule mod_rewrite.c>
	RewriteEngine on
	## Remplacer dans les lignes suivantes
	## /paheko/ par le nom du sous-répertoire où est installé Paheko
 	RewriteBase /paheko/
	FallbackResource /paheko/www/_route.php

	## Ne pas modifier les lignes suivantes, les décommenter simplement !
	RewriteCond %{REQUEST_URI} !www/
	RewriteRule ^(.*)$ www/$1 [QSA,L]
</IfModule>

Modified src/Makefile from [d1651bfce0] to [e7ebe9535b].

1
2

3
4
5
6
7
8
9
10
11
12
13
14





15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32








33
34
35
36
37
38
39
40
41


42
43
44
45


46
47





48




49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

64
65
66
67
68
69
70
71
72
73
74
75
76
77
.PHONY: dev-server release deps publish check-dependencies test minify phpstan
KD2_FILE := https://fossil.kd2.org/kd2fw/uv/KD2-7.4.zip


deps:
	$(eval TMP_KD2=$(shell mktemp -d))
	#cd ${TMP_KD2}

	wget ${KD2_FILE} -O ${TMP_KD2}/kd2.zip

	rm -rf "include/lib/KD2"
	unzip "${TMP_KD2}/kd2.zip" -d include/lib

	rm -rf ${TMP_KD2}






dev-server:
	php -S localhost:8082 -t www www/_route.php

test:
	find . -name '*.php' -print0 | xargs -0 -n1 php -l > /dev/null

phpstan:
	phpstan.phar analyze -c ../tests/phpstan.neon include www

psalm:
	@# This is required by psalm, but useless
	@-mkdir vendor
	@-echo '{"require": {}}' > vendor/autoload.php
	psalm.phar -c ../tests/psalm.xml

doc:
	php ../tools/doc_md_to_html.php









release: test minify doc
	$(eval VERSION=$(shell cat VERSION))
	rm -rf /tmp/paheko-build
	mkdir -p /tmp/paheko-build
	fossil zip ${VERSION} /tmp/paheko-build/src.zip --name paheko
	unzip -d /tmp/paheko-build /tmp/paheko-build/src.zip
	cd include/lib; \
		rsync --files-from=dependencies.list -r ./ /tmp/paheko-build/paheko/src/include/lib/
	mv www/admin/static/mini.css /tmp/paheko-build/paheko/src/www/admin/static/admin.css


	cd /tmp/paheko-build/paheko/src/www/admin/static; \
		rm -f font/*.css font/*.json
	cd /tmp/paheko-build/paheko/src; \
		rm -f Makefile include/lib/KD2/data/countries.en.json


	cd /tmp/paheko-build/paheko/src/data; mkdir plugins && cd plugins; \
		wget https://fossil.kd2.org/paheko-plugins/uv/welcome.tar.gz





	mv /tmp/paheko-build/paheko/src /tmp/paheko-build/paheko-${VERSION}




	@#cd /tmp/paheko-build/; zip -r -9 paheko-${VERSION}.zip paheko-${VERSION};
	@#mv -f /tmp/paheko-build/paheko-${VERSION}.zip ./
	tar czvfh paheko-${VERSION}.tar.gz --hard-dereference -C /tmp/paheko-build paheko-${VERSION}

deb:
	cd ../build/debian; ./makedeb.sh

windows:
	cd ../build/windows; make installer

publish: release deb windows
	$(eval VERSION=$(shell cat VERSION))
	gpg --armor -r dev@paheko.cloud --detach-sign paheko-${VERSION}.tar.gz
	fossil uv sync
	#fossil uv ls | fgrep -v 'paheko-0.8.5' | grep '^paheko-.*\.(tar\.bz2|deb)' | xargs fossil uv rm

	fossil uv add paheko-${VERSION}.tar.gz
	fossil uv add paheko-${VERSION}.tar.gz.asc
	cd ../build/debian && fossil uv add paheko-${VERSION}.deb
	cd ../tools && php make_installer.php > install.php && fossil uv add install.php && rm install.php
	fossil uv sync
	cd ../build/windows && make publish

check-dependencies:
	grep -hEo '^use \\?KD2\\[^; ]+|\\KD2\\[^\(:; ]+' -R include/lib/Garradin www | sed -r 's/^use \\?KD2\\|^\\KD2\\//' | sort | uniq

minify:
	cat `ls www/admin/static/styles/[0-9]*.css` | sed 's/\.\.\///' > www/admin/static/mini.css
	@# Minify is only gaining 500 gzipped bytes (4kB uncompressed) but making things hard to read/hack
	@#yui-compressor --nomunge www/admin/static/mini.css -o www/admin/static/mini.css
|

>












>
>
>
>
>

|


|













>
>
>
>
>
>
>
>









>
>




>
>
|
|
>
>
>
>
>
|
>
>
>
>
|
|
|









|


>
|
|












1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
.PHONY: dev-server release deps publish check-dependencies test minify phpstan www htaccess modules
KD2_FILE := https://fossil.kd2.org/kd2fw/uv/KD2-7.4.zip
MODULES_FILE := https://fossil.kd2.org/paheko-modules/zip/trunk/modules.zip

deps:
	$(eval TMP_KD2=$(shell mktemp -d))
	#cd ${TMP_KD2}

	wget ${KD2_FILE} -O ${TMP_KD2}/kd2.zip

	rm -rf "include/lib/KD2"
	unzip "${TMP_KD2}/kd2.zip" -d include/lib

	rm -rf ${TMP_KD2}

modules:
	wget ${MODULES_FILE} -O modules.zip
	unzip -u modules.zip
	rm -f modules.zip

dev-server:
	php -S localhost:8082 -d upload_max_filesize=256M -d post_max_size=256M -t www www/_route.php

test:
	find . -name '*.php' -not -path './data/*' -print0 | xargs -0 -n1 php -l > /dev/null

phpstan:
	phpstan.phar analyze -c ../tests/phpstan.neon include www

psalm:
	@# This is required by psalm, but useless
	@-mkdir vendor
	@-echo '{"require": {}}' > vendor/autoload.php
	psalm.phar -c ../tests/psalm.xml

doc:
	php ../tools/doc_md_to_html.php

htaccess:
	# Removing DOCUMENT_ROOT is important for the cache when using .htaccess, keep it!
	cat apache-vhost.conf \
		| sed 's/#RewriteBase/RewriteBase/' \
		| sed 's/RewriteCond %{DOCUMENT_ROOT}%{REQUEST_/RewriteCond %{REQUEST_/' \
		> www/.htaccess
	cat apache-htaccess.conf >> www/.htaccess

release: test minify doc
	$(eval VERSION=$(shell cat VERSION))
	rm -rf /tmp/paheko-build
	mkdir -p /tmp/paheko-build
	fossil zip ${VERSION} /tmp/paheko-build/src.zip --name paheko
	unzip -d /tmp/paheko-build /tmp/paheko-build/src.zip
	cd include/lib; \
		rsync --files-from=dependencies.list -r ./ /tmp/paheko-build/paheko/src/include/lib/
	mv www/admin/static/mini.css /tmp/paheko-build/paheko/src/www/admin/static/admin.css
	# Generate .htaccess file
	cd /tmp/paheko-build/paheko/src && make htaccess
	cd /tmp/paheko-build/paheko/src/www/admin/static; \
		rm -f font/*.css font/*.json
	cd /tmp/paheko-build/paheko/src; \
		rm -f Makefile include/lib/KD2/data/countries.en.json

	# Download modules and only keep the stable ones
	cd /tmp/paheko-build/paheko/src && \
		wget https://fossil.kd2.org/paheko-modules/zip/trunk/modules.zip && \
		unzip -o modules.zip && \
		rm -rf `find modules/ -name 'ignore' -type f -execdir pwd \;` && \
		rm -f modules.zip

	# Download plugins and only keep the stable ones
	cd /tmp/paheko-build/paheko/src/data && \
		wget https://fossil.kd2.org/paheko-plugins/zip/dev/plugins.zip && \
		unzip -o plugins.zip && \
		rm -rf `find plugins/ -name 'ignore' -type f -execdir pwd \;` && \
		rm -f plugins.zip

	mv /tmp/paheko-build/paheko/src /tmp/paheko-build/paheko-${VERSION}
	tar czvfh ../build/paheko-${VERSION}.tar.gz --hard-dereference -C /tmp/paheko-build paheko-${VERSION}

deb:
	cd ../build/debian; ./makedeb.sh

windows:
	cd ../build/windows; make installer

publish: release deb windows
	$(eval VERSION=$(shell cat VERSION))
	cd ../build && gpg --armor -u dev@paheko.cloud --detach-sign paheko-${VERSION}.tar.gz
	fossil uv sync
	#fossil uv ls | fgrep -v 'paheko-0.8.5' | grep '^paheko-.*\.(tar\.bz2|deb)' | xargs fossil uv rm
	cd ../build && \
		fossil uv add paheko-${VERSION}.tar.gz && \
		fossil uv add paheko-${VERSION}.tar.gz.asc
	cd ../build/debian && fossil uv add paheko-${VERSION}.deb
	cd ../tools && php make_installer.php > install.php && fossil uv add install.php && rm install.php
	fossil uv sync
	cd ../build/windows && make publish

check-dependencies:
	grep -hEo '^use \\?KD2\\[^; ]+|\\KD2\\[^\(:; ]+' -R include/lib/Garradin www | sed -r 's/^use \\?KD2\\|^\\KD2\\//' | sort | uniq

minify:
	cat `ls www/admin/static/styles/[0-9]*.css` | sed 's/\.\.\///' > www/admin/static/mini.css
	@# Minify is only gaining 500 gzipped bytes (4kB uncompressed) but making things hard to read/hack
	@#yui-compressor --nomunge www/admin/static/mini.css -o www/admin/static/mini.css

Modified src/VERSION from [3b231fab53] to [a312fdaa17].

1
1.2.11
|
1
1.3.0-rc14

Added src/apache-htaccess.conf version [de974946e4].











































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Sinon le reste marchera, sauf les clients OC/NC
<IfModule !mod_rewrite.c>
	# FallbackResource has a bug before Apache 2.4.15, requiring to disable DirectoryIndex
	# see https://bz.apache.org/bugzilla/show_bug.cgi?id=58292
	# and https://serverfault.com/questions/559067/apache-hangs-for-five-seconds-with-fallbackresource-when-accessing
	DirectoryIndex disabled
	DirectoryIndex index.php

	# Redirect non-existing URLs to the router
	FallbackResource /_route.php

	# FallbackResource does not work for URLs ending with ".php"
	# see https://stackoverflow.com/a/66136226
	ErrorDocument 404 /_route.php

	# NextCloud/ownCloud clients cannot work without mod_rewrite
	<IfModule mod_alias.c>
		Redirect 501 /remote.php
		Redirect 501 /status.php
	</IfModule>
</IfModule>

Added src/apache-vhost.conf version [04ec124c9b].

































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
Options -Indexes -Multiviews +FollowSymlinks

DirectoryIndex index.php index.html

# Some security
<IfModule mod_alias.c>
	RedirectMatch 404 _inc\.php
</IfModule>

# Recommended, if you have xsendfile module
# see https://tn123.org/mod_xsendfile/
# Also enable X-SendFile in config.local.php
#
#<IfModule mod_xsendfile.c>
#	<Files *.php>
#		XSendFile On
#		XSendFilePath /home/paheko/
#	</Files>
#</IfModule>

# This is to avoid caching mismatch when using mod_deflate
# see https://github.com/symfony/symfony-docs/issues/12644
<IfModule mod_deflate.c>
	FileETag None
</IfModule>

# Allow uploads up to 256 MB where it's required
<If "%{REQUEST_URI} =~ m!^/admin/(?:common/files|config/backup)/|^/(?:web)?dav/|^/remote\.php/(?:web)?dav/! && -n %{HTTP_COOKIE}">
	<IfModule mod_php.c>
		php_value post_max_size 256M
		php_value upload_max_filesize 256M
	</IfModule>

	<IfModule mod_php7.c>
		php_value post_max_size 256M
		php_value upload_max_filesize 256M
	</IfModule>

	<IfModule !mod_php.c>
		<IfModule !mod_php7.c>
			SetEnv PHP_VALUE "post_max_size=256M"

			# There is no way to pass multiple PHP ini settings via PHP_VALUE :-(
			# so we use PHP_ADMIN_VALUE here. It works unless we have more than 2 settings to change.
			SetEnv PHP_ADMIN_VALUE "upload_max_filesize=256M"
		</IfModule>
	</IfModule>
</If>

<IfModule mod_rewrite.c>
	AddDefaultCharset utf-8
	AddCharset utf-8 .html .css .js .txt

	RewriteEngine On
	#RewriteBase /

	RewriteRule \.cache - [R=404]
	RewriteRule \.well-known/assetlinks.json - [R=404]

	# Stop rewrite for /admin URL, except for /admin/p/ (plugins)
	RewriteCond %{REQUEST_URI} ^/?admin(?!/p/)
	RewriteRule ^ - [END]

	# Skip directly to router if possible
	# Do not try cache if method is not GET or HEAD
	RewriteCond %{REQUEST_METHOD} !GET|HEAD [OR]

	# Do not try to get from cache if URL is private, or belongs to modules/plugins
	RewriteCond %{REQUEST_URI} ^/admin|^/?(?:dav|wopi|p|m|api)/|\.php$ [OR]

	# NextCloud routes
	RewriteCond %{REQUEST_URI} ^/?(?:remote\.php|index\.php|ocs|avatars|status\.php)/ [OR]

	# Private files are not part of the cache
	RewriteCond %{REQUEST_URI} ^/?(?:documents|user|transaction|ext|attachments|versions)/

	# Skip, go to router directly
	RewriteRule ^ - [skip=8]

	# Store MD5 hashes in environment variables
	RewriteCond %{REQUEST_URI} ^(.+)(?:\?|$)
	RewriteRule ^ "-" [E=CACHE_URI:%1]
	# Extract file extension (required for Apache to serve the correct mimetype)
	RewriteCond %{REQUEST_URI} (\.[a-z0-9]+)(?:\?|$)
	RewriteRule ^ "-" [E=CACHE_EXT:%1]
	# If no extension, default to .html
	RewriteCond %{REQUEST_URI} !\.[a-z0-9]+(?:\?|$)
	RewriteRule ^ "-" [E=CACHE_EXT:.html]
	RewriteCond expr "md5(%{ENV:CACHE_URI}) =~ /^(.+)$/"
	RewriteRule ^ "-" [E=CACHE_URI_MD5:%1]
	RewriteCond expr "md5(tolower(%{HTTP_HOST})) =~ /^((.{2}).+)$/"
	RewriteRule ^ "-" [E=CACHE_HOST_MD5:%1,E=CACHE_HOST2_MD5:%2]
	RewriteCond /.cache/%{ENV:CACHE_HOST_MD5}/%{ENV:CACHE_URI_MD5} (.+)
	RewriteRule ^ "-" [E=CACHE_PATH:%1]

	# Serve symlinks for files
	RewriteCond %{QUERY_STRING} ="" [OR]
	RewriteCond %{QUERY_STRING} ^h=[a-f0-9]+$
	RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} -l
	RewriteRule ^ %{ENV:CACHE_PATH}%{ENV:CACHE_EXT} [END]

	# Do not try cache for pages if user is logged-in
	RewriteCond %{HTTP_COOKIE} !pko=
	# Serve static HTML pages
	RewriteCond %{QUERY_STRING} =""
	RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} -f
	RewriteCond %{DOCUMENT_ROOT}%{ENV:CACHE_PATH}%{ENV:CACHE_EXT} !-l
	RewriteRule ^ %{ENV:CACHE_PATH}%{ENV:CACHE_EXT} [END]

	# Redirect to router
	RewriteRule ^ /_route.php [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},END,QSA]
</IfModule>

Modified src/config.dist.php from [fee27d0d97] to [9f1cf88fe4].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31




32


33
34
35
36
37
38

39










40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111





















112
113
114
115
116
117
118
119
<?php

/**
 * Ce fichier représente un exemple des constantes de configuration
 * disponibles pour Garradin.
 *
 * NE PAS MODIFIER CE FICHIER!
 *
 * Pour configurer Garradin, copiez ce fichier en 'config.local.php'
 * puis décommentez et modifiez ce dont vous avez besoin.
 */

// Nécessaire pour situer les constantes dans le bon namespace
namespace Garradin;

/**
 * Clé secrète, doit être unique à chaque instance de Garradin
 *
 * Ceci est utilisé afin de sécuriser l'envoi de formulaires
 * (protection anti-CSRF).
 *
 * Cette valeur peut être modifiée sans autre impact que la déconnexion des utilisateurs
 * actuellement connectés.
 *
 * Si cette constante n'est définie, Garradin ajoutera automatiquement
 * une valeur aléatoire dans le fichier config.local.php.
 */

//const SECRET_KEY = '3xUhIgGwuovRKOjVsVPQ5yUMfXUSIOX2GKzcebsz5OINrYC50r';

/**




 * Se connecter automatiquement avec l'ID de membre indiqué


 * Exemple: LOCAL_LOGIN = 42 connectera automatiquement le membre 42
 * Attention à ne pas utiliser en production !
 *
 * Il est aussi possible de mettre "LOCAL_LOGIN = -1" pour se connecter
 * avec le premier membre trouvé qui peut gérer la configuration (et donc
 * modifier les droits des membres).

 *










 * Défault : false (connexion automatique désactivée)
 */

//const LOCAL_LOGIN = false;

/**
 * Autoriser (ou non) l'import de sauvegarde qui a été modifiée ?
 *
 * Si mis à true, un avertissement et une confirmation seront demandés
 * Si mis à false, tout fichier SQLite importé qui ne comporte pas une signature
 * valide (hash SHA1) sera refusé.
 *
 * Ceci ne s'applique qu'à la page "Sauvegarde et restauration" de l'admin,
 * il est toujours possible de restaurer une base de données non signée en
 * la recopiant à la place du fichier association.sqlite
 *
 * Défaut : true
 */

//const ALLOW_MODIFIED_IMPORT = true;

/**
 * Doit-on suggérer à l'utilisateur d'utiliser la version chiffrée du site ?
 *
 * 1 ou true = affiche un message de suggestion sur l'écran de connexion invitant à utiliser le site chiffré
 * (conseillé si vous avez un certificat auto-signé ou peu connu type CACert)
 * 2 = rediriger automatiquement sur la version chiffrée pour l'administration (mais pas le site public)
 * 3 = rediriger automatiquement sur la version chiffrée pour administration et site public
 * false ou 0 = aucune version chiffrée disponible, donc ne rien proposer ni rediriger
 *
 * Défaut : false
 */

//const PREFER_HTTPS = false;

/**
 * Répertoire où se situe le code source de Garradin
 *
 * Défaut : répertoire racine de Garradin (__DIR__)
 */

//const ROOT = __DIR__;

/**
 * Répertoire où sont situées les données de Garradin
 * (incluant la base de données SQLite, les sauvegardes, le cache, les fichiers locaux et les plugins)
 *
 * Défaut : sous-répertoire "data" de la racine
 */

//const DATA_ROOT = ROOT . '/data';

/**
 * Répertoire où est situé le cache,
 * exemples : graphiques de statistiques, templates Brindille, etc.
 *
 * Défaut : sous-répertoire 'cache' de DATA_ROOT
 */

//const CACHE_ROOT = DATA_ROOT . '/cache';

/**
 * Répertoire où est situé le cache partagé entre instances
 * Garradin utilisera ce répertoire pour stocker le cache susceptible d'être partagé entre instances, comme
 * le code PHP généré à partir des templates Smartyer.
 *
 * Défaut : sous-répertoire 'shared' de CACHE_ROOT
 */

//const SHARED_CACHE_ROOT = CACHE_ROOT . '/shared';

/**





















 * Emplacement du fichier de base de données de Garradin
 *
 * Défaut : DATA_ROOT . '/association.sqlite'
 */

//const DB_FILE = DATA_ROOT . '/association.sqlite';

/**




|



|




|


|







|






>
>
>
>
|
>
>
|


<
|
|
>

>
>
>
>
>
>
>
>
>
>
|


|


















<
<
<
<
<
<
<
<
<
<
<
<
<
<
|

|





|


















|








>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77














78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
<?php

/**
 * Ce fichier représente un exemple des constantes de configuration
 * disponibles pour Paheko.
 *
 * NE PAS MODIFIER CE FICHIER!
 *
 * Pour configurer Paheko, copiez ce fichier en 'config.local.php'
 * puis décommentez et modifiez ce dont vous avez besoin.
 */

// Nécessaire pour situer les constantes dans le bon namespace
namespace Paheko;

/**
 * Clé secrète, doit être unique à chaque instance de Paheko
 *
 * Ceci est utilisé afin de sécuriser l'envoi de formulaires
 * (protection anti-CSRF).
 *
 * Cette valeur peut être modifiée sans autre impact que la déconnexion des utilisateurs
 * actuellement connectés.
 *
 * Si cette constante n'est définie, Paheko ajoutera automatiquement
 * une valeur aléatoire dans le fichier config.local.php.
 */

//const SECRET_KEY = '3xUhIgGwuovRKOjVsVPQ5yUMfXUSIOX2GKzcebsz5OINrYC50r';

/**
 * @var null|int|array
 *
 * Forcer la connexion locale
 *
 * Si un numéro est spécifié, alors le membre avec l'ID correspondant à ce
 * numéro sera connecté (sans besoin de mot de passe).
 *
 * Exemple: LOCAL_LOGIN = 42 connectera automatiquement le membre avec id = 42
 * Attention à ne pas utiliser en production !
 *

 * Si le nombre spécifié est -1, alors c'est le premier membre trouvé qui
 * peut gérer la configuration (et donc modifier les droits des membres)
 * qui sera connecté.
 *
 * Si un tableau est spécifié, alors Paheko considérera que l'utilisateur
 * connecté fourni dans le tableau n'est pas un membre.
 * Voir la documentation sur l'utilisation avec SSO et LDAP pour plus de détails.
 *
 * Exemple :
 * const LOCAL_LOGIN = [
 * 	'user' => ['_name' => 'bohwaz'],
 * 	'permissions' => ['users' => 9, 'config' => 9]
 * ];
 *
 * Défault : null (connexion automatique désactivée)
 */

//const LOCAL_LOGIN = null;

/**
 * Autoriser (ou non) l'import de sauvegarde qui a été modifiée ?
 *
 * Si mis à true, un avertissement et une confirmation seront demandés
 * Si mis à false, tout fichier SQLite importé qui ne comporte pas une signature
 * valide (hash SHA1) sera refusé.
 *
 * Ceci ne s'applique qu'à la page "Sauvegarde et restauration" de l'admin,
 * il est toujours possible de restaurer une base de données non signée en
 * la recopiant à la place du fichier association.sqlite
 *
 * Défaut : true
 */

//const ALLOW_MODIFIED_IMPORT = true;

/**














 * Répertoire où se situe le code source de Paheko
 *
 * Défaut : répertoire racine de Paheko (__DIR__)
 */

//const ROOT = __DIR__;

/**
 * Répertoire où sont situées les données de Paheko
 * (incluant la base de données SQLite, les sauvegardes, le cache, les fichiers locaux et les plugins)
 *
 * Défaut : sous-répertoire "data" de la racine
 */

//const DATA_ROOT = ROOT . '/data';

/**
 * Répertoire où est situé le cache,
 * exemples : graphiques de statistiques, templates Brindille, etc.
 *
 * Défaut : sous-répertoire 'cache' de DATA_ROOT
 */

//const CACHE_ROOT = DATA_ROOT . '/cache';

/**
 * Répertoire où est situé le cache partagé entre instances
 * Paheko utilisera ce répertoire pour stocker le cache susceptible d'être partagé entre instances, comme
 * le code PHP généré à partir des templates Smartyer.
 *
 * Défaut : sous-répertoire 'shared' de CACHE_ROOT
 */

//const SHARED_CACHE_ROOT = CACHE_ROOT . '/shared';

/**
 * Motif qui détermine l'emplacement des fichiers de cache du site web.
 *
 * Le site web peut créer des fichiers de cache pour les pages et catégories.
 * Ensuite le serveur web (Apache) servira ces fichiers directement, sans faire
 * appel au PHP, permettant de supporter beaucoup de trafic si le site web
 * a une vague de popularité.
 *
 * Certaines valeurs sont remplacées :
 * %host% = hash MD5 du hostname (utile en cas d'hébergement de plusieurs instances)
 * %host.2% = 2 premiers caractères du hash MD5 du hostname
 *
 * Utiliser NULL pour désactiver le cache.
 *
 * Défault : CACHE_ROOT . '/web/%host%'
 *
 * @var null|string
 */

//const WEB_CACHE_ROOT = CACHE_ROOT . '/web/%host%';

/**
 * Emplacement du fichier de base de données de Paheko
 *
 * Défaut : DATA_ROOT . '/association.sqlite'
 */

//const DB_FILE = DATA_ROOT . '/association.sqlite';

/**
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153

154
155
156
157
158
159
160


161

162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
 * La clé est le nom du signal, et la valeur est la fonction.
 *
 * Défaut: [] (tableau vide)
 */
//const SYSTEM_SIGNALS = [['files.delete' => 'MyNamespace\Signals::deleteFile'], ['entity.Accounting\Transaction.save.before' => 'MyNamespace\Signals::saveTransaction']];

/**
 * Adresse URI de la racine du site Garradin
 * (doit se terminer par un slash)
 *
 * Défaut : découverte automatique à partir de SCRIPT_NAME
 */

//const WWW_URI = '/asso/';

/**
 * Adresse URL HTTP(S) de Garradin
 *
 * Défaut : découverte à partir de HTTP_HOST ou SERVER_NAME + WWW_URI

 */

//const WWW_URL = 'http://garradin.chezmoi.tld' . WWW_URI;

/**
 * Adresse URL HTTP(S) de l'admin Garradin
 *


 * Défaut : WWW_URL + 'admin/'

 */

//const ADMIN_URL = 'https://admin.garradin.chezmoi.tld/';

/**
 * Affichage des erreurs
 * Si "true" alors un message expliquant l'erreur et comment rapporter le bug s'affiche
 * en cas d'erreur. Sinon rien ne sera affiché.
 *
 * Défaut : false
 *
 * Il est fortement conseillé de mettre cette valeur à false en production !
 */

//const SHOW_ERRORS = false;

/**
 * Envoi des erreurs par e-mail
 *
 * Si renseigné, un email sera envoyé à l'adresse indiquée à chaque fois qu'une erreur
 * d'exécution sera rencontrée.
 * Si "false" alors aucun email ne sera envoyé.
 * Note : les erreurs sont déjà toutes loguées dans error.log à la racine de DATA_ROOT
 *
 * Défaut : false
 */

//const MAIL_ERRORS = false;

/**
 * Envoi des erreurs à une API compatible AirBrake/Errbit/Garradin
 *
 * Si renseigné avec une URL HTTP(S) valide, chaque erreur système sera envoyée
 * automatiquement à cette URL.
 *
 * Si laissé à null, aucun rapport ne sera envoyé.
 *
 * Garradin accepte aussi les rapports d'erreur venant d'autres instances.
 *
 * Pour cela utiliser l'URL https://login:password@garradin.site.tld/api/errors/report
 * (voir aussi API_USER et API_PASSWORD)
 *
 * Les erreurs seront ensuite visibles dans
 * Configuration -> Fonctions avancées -> Journal d'erreurs
 *
 * Défaut : null
 */







|








|

|
>


|


|

>
>

>


|






|

|


















|






|

|







158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
 * La clé est le nom du signal, et la valeur est la fonction.
 *
 * Défaut: [] (tableau vide)
 */
//const SYSTEM_SIGNALS = [['files.delete' => 'MyNamespace\Signals::deleteFile'], ['entity.Accounting\Transaction.save.before' => 'MyNamespace\Signals::saveTransaction']];

/**
 * Adresse URI de la racine du site Paheko
 * (doit se terminer par un slash)
 *
 * Défaut : découverte automatique à partir de SCRIPT_NAME
 */

//const WWW_URI = '/asso/';

/**
 * Adresse URL HTTP(S) publique de Paheko
 *
 * Défaut : découverte automatique à partir de HTTP_HOST ou SERVER_NAME + WWW_URI
 * @var null|string
 */

//const WWW_URL = 'http://paheko.chezmoi.tld' . WWW_URI;

/**
 * Adresse URL HTTP(S) de l'admin Paheko
 *
 * Note : il est possible d'avoir un autre domaine que WWW_URL.
 *
 * Défaut : WWW_URL + 'admin/'
 * @var null|string
 */

//const ADMIN_URL = 'https://admin.paheko.chezmoi.tld/';

/**
 * Affichage des erreurs
 * Si "true" alors un message expliquant l'erreur et comment rapporter le bug s'affiche
 * en cas d'erreur. Sinon rien ne sera affiché.
 *
 * Défaut : TRUE (pour aider le debug de l'auto-hébergement)
 *
 * Il est fortement conseillé de mettre cette valeur à FALSE en production !
 */

//const SHOW_ERRORS = false;

/**
 * Envoi des erreurs par e-mail
 *
 * Si renseigné, un email sera envoyé à l'adresse indiquée à chaque fois qu'une erreur
 * d'exécution sera rencontrée.
 * Si "false" alors aucun email ne sera envoyé.
 * Note : les erreurs sont déjà toutes loguées dans error.log à la racine de DATA_ROOT
 *
 * Défaut : false
 */

//const MAIL_ERRORS = false;

/**
 * Envoi des erreurs à une API compatible AirBrake/Errbit/Paheko
 *
 * Si renseigné avec une URL HTTP(S) valide, chaque erreur système sera envoyée
 * automatiquement à cette URL.
 *
 * Si laissé à null, aucun rapport ne sera envoyé.
 *
 * Paheko accepte aussi les rapports d'erreur venant d'autres instances.
 *
 * Pour cela utiliser l'URL https://login:password@paheko.site.tld/api/errors/report
 * (voir aussi API_USER et API_PASSWORD)
 *
 * Les erreurs seront ensuite visibles dans
 * Configuration -> Fonctions avancées -> Journal d'erreurs
 *
 * Défaut : null
 */
269
270
271
272
273
274
275

276
277
278
279
280
281
282
 * Cette option peut significativement ralentir le chargement des pages.
 *
 * Défaut : null (= désactivé)
 * @var string|null
 */
// const SQL_DEBUG = __DIR__ . '/debug_sql.sqlite';


/**
 * Mode de journalisation de SQLite
 *
 * Paheko recommande le mode 'WAL' de SQLite, qui permet à SQLite
 * d'être extrêmement rapide.
 *
 * Cependant, sur certains hébergeurs utilisant NFS, ce mode peut







>







296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
 * Cette option peut significativement ralentir le chargement des pages.
 *
 * Défaut : null (= désactivé)
 * @var string|null
 */
// const SQL_DEBUG = __DIR__ . '/debug_sql.sqlite';

/**
/**
 * Mode de journalisation de SQLite
 *
 * Paheko recommande le mode 'WAL' de SQLite, qui permet à SQLite
 * d'être extrêmement rapide.
 *
 * Cependant, sur certains hébergeurs utilisant NFS, ce mode peut
296
297
298
299
300
301
302
















303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
 * @see https://stackoverflow.com/questions/52378361/which-nfs-implementation-is-safe-for-sqlite-database-accessed-by-multiple-proces
 *
 * Défaut : 'TRUNCATE'
 * @var string
 */
//const SQLITE_JOURNAL_MODE = 'TRUNCATE';

















/**
 * Activer la possibilité de faire une mise à jour semi-automatisée
 * depuis fossil.kd2.org.
 *
 * Si mis à TRUE, alors un bouton sera accessible depuis le menu "Configuration"
 * pour faire une mise à jour en deux clics.
 *
 * Il est conseillé de désactiver cette fonctionnalité si vous ne voulez pas
 * permettre à un utilisateur de casser l'installation !
 *
 * Si cette constante est désactivée, mais que ENABLE_TECH_DETAILS est activé,
 * la vérification de nouvelle version se fera quand même, mais plutôt que de proposer
 * la mise à jour, Garradin proposera de se rendre sur le site officiel pour
 * télécharger la mise à jour.
 *
 * Défaut : true
 *
 * @var bool
 */








>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>












|







324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
 * @see https://stackoverflow.com/questions/52378361/which-nfs-implementation-is-safe-for-sqlite-database-accessed-by-multiple-proces
 *
 * Défaut : 'TRUNCATE'
 * @var string
 */
//const SQLITE_JOURNAL_MODE = 'TRUNCATE';

/**
 * Activation du log HTTP (option de développement)
 *
 * Si cette constante est renseignée par un fichier texte, *TOUTES* les requêtes HTTP
 * ainsi que leur contenu y sera enregistré.
 *
 * C'est surtout utile pour débuguer les problèmes de WebDAV par exemple.
 *
 * ATTENTION : cela signifie que des informations personnelles (mot de passe etc.)
 * peuvent se retrouver dans le log. Ne pas utiliser à moins de tester en développement.
 *
 * Default : null (= désactivé)
 * @var string|null
 */
// const HTTP_LOG_FILE = __DIR__ . '/http.log';

/**
 * Activer la possibilité de faire une mise à jour semi-automatisée
 * depuis fossil.kd2.org.
 *
 * Si mis à TRUE, alors un bouton sera accessible depuis le menu "Configuration"
 * pour faire une mise à jour en deux clics.
 *
 * Il est conseillé de désactiver cette fonctionnalité si vous ne voulez pas
 * permettre à un utilisateur de casser l'installation !
 *
 * Si cette constante est désactivée, mais que ENABLE_TECH_DETAILS est activé,
 * la vérification de nouvelle version se fera quand même, mais plutôt que de proposer
 * la mise à jour, Paheko proposera de se rendre sur le site officiel pour
 * télécharger la mise à jour.
 *
 * Défaut : true
 *
 * @var bool
 */

350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379

















380
381
382
383
384
385
386
 * - Lighttpd
 *
 * N'activer que si vous êtes sûr que le module est installé et activé (sinon
 * les fichiers ne pourront être vus ou téléchargés).
 * Nginx n'est PAS supporté, car X-Accel-Redirect ne peut gérer que des fichiers
 * qui sont *dans* le document root du vhost, ce qui n'est pas le cas ici.
 *
 * Pour activer X-SendFile mettre dans la config du virtualhost de Garradin:
 * XSendFile On
 * XSendFilePath /var/www/garradin
 *
 * (remplacer le chemin par le répertoire racine de Garradin)
 *
 * Détails : https://tn123.org/mod_xsendfile/
 *
 * Défaut : false
 */

//const ENABLE_XSENDFILE = false;

/**
 * Serveur NTP utilisé pour les connexions avec TOTP
 * (utilisé seulement si le code OTP fourni est faux)
 *
 * Désactiver (false) si vous êtes sûr que votre serveur est toujours à l'heure.
 *
 * Défaut : fr.pool.ntp.org
 */

//const NTP_SERVER = 'fr.pool.ntp.org';


















/**
 * Hôte du serveur SMTP, mettre à false (défaut) pour utiliser la fonction
 * mail() de PHP
 *
 * Défaut : false
 */







|

|

|


















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
 * - Lighttpd
 *
 * N'activer que si vous êtes sûr que le module est installé et activé (sinon
 * les fichiers ne pourront être vus ou téléchargés).
 * Nginx n'est PAS supporté, car X-Accel-Redirect ne peut gérer que des fichiers
 * qui sont *dans* le document root du vhost, ce qui n'est pas le cas ici.
 *
 * Pour activer X-SendFile mettre dans la config du virtualhost de Paheko:
 * XSendFile On
 * XSendFilePath /var/www/paheko
 *
 * (remplacer le chemin par le répertoire racine de Paheko)
 *
 * Détails : https://tn123.org/mod_xsendfile/
 *
 * Défaut : false
 */

//const ENABLE_XSENDFILE = false;

/**
 * Serveur NTP utilisé pour les connexions avec TOTP
 * (utilisé seulement si le code OTP fourni est faux)
 *
 * Désactiver (false) si vous êtes sûr que votre serveur est toujours à l'heure.
 *
 * Défaut : fr.pool.ntp.org
 */

//const NTP_SERVER = 'fr.pool.ntp.org';

/**
 * Désactiver l'envoi d'e-mails
 *
 * Si positionné à TRUE, l'envoi d'e-mail ne sera pas proposé, et il ne sera
 * pas non plus possible de récupérer un mot de passe perdu.
 * Les parties de l'interface relatives à l'envoi d'e-mail seront cachées.
 *
 * Ce réglage est utilisé pour la version autonome sous Windows, car Windows
 * ne permet pas l'envoi d'e-mails.
 *
 * Défaut : false
 * @var bool
 */

//const DISABLE_EMAIL = false;


/**
 * Hôte du serveur SMTP, mettre à false (défaut) pour utiliser la fonction
 * mail() de PHP
 *
 * Défaut : false
 */
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
 * Login utilisateur pour le server SMTP
 *
 * mettre à null pour utiliser un serveur local ou anonyme
 *
 * Défaut : null
 */

//const SMTP_USER = 'garradin@monserveur.com';

/**
 * Mot de passe pour le serveur SMTP
 *
 * mettre à null pour utiliser un serveur local ou anonyme
 *
 * Défaut : null







|







463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
 * Login utilisateur pour le server SMTP
 *
 * mettre à null pour utiliser un serveur local ou anonyme
 *
 * Défaut : null
 */

//const SMTP_USER = 'paheko@monserveur.com';

/**
 * Mot de passe pour le serveur SMTP
 *
 * mettre à null pour utiliser un serveur local ou anonyme
 *
 * Défaut : null
427
428
429
430
431
432
433
434













435
436
437
438
439
440
441
442
443
444
445
446
447
448
449


















450
451
452
453
454
455
456
 * STARTTLS = utilisation de STARTTLS (moyennement sécurisé)
 *
 * Défaut : STARTTLS
 */

//const SMTP_SECURITY = 'STARTTLS';

/**













 * Adresse e-mail destinée à recevoir les erreurs de mail
 * (adresses invalides etc.)
 *
 * Si laissé NULL, alors l'adresse email de l'association sera utilisée.
 * En cas d'hébergement de plusieurs associations, il est conseillé
 * d'utiliser une adresse par association.
 *
 * Voir la documentation de configuration sur des exemples de scripts
 * permettant de traiter les mails reçus à cette adresse.
 *
 * Défaut : null
 */

//const MAIL_RETURN_PATH = 'returns@monserveur.com';



















/**
 * Mot de passe pour l'accès à l'API permettant de gérer les mails d'erreur
 * (voir MAIL_RETURN_PATH)
 *
 * Cette adresse HTTP permet de gérer un bounce email reçu en POST.
 * C'est utile si votre serveur de mail est capable de faire une requête HTTP
 * à la réception d'un message.








>
>
>
>
>
>
>
>
>
>
>
>
>

|

|











>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
 * STARTTLS = utilisation de STARTTLS (moyennement sécurisé)
 *
 * Défaut : STARTTLS
 */

//const SMTP_SECURITY = 'STARTTLS';

/**
 * Nom du serveur utilisé dans le HELO SMTP
 *
 * Si NULL, alors le nom renseigné comme SERVER_NAME (premier nom du virtual host Apache)
 * sera utilisé.
 *
 * Defaut : NULL
 *
 * @var null|string
 */

//const SMTP_HELO_HOSTNAME = 'mail.domain.tld';

/**
 * Adresse e-mail destinée à recevoir les erreurs de mail
 * (adresses invalides etc.) — Return-Path
 *
 * Si laissé NULL, alors l'adresse e-mail de l'association sera utilisée.
 * En cas d'hébergement de plusieurs associations, il est conseillé
 * d'utiliser une adresse par association.
 *
 * Voir la documentation de configuration sur des exemples de scripts
 * permettant de traiter les mails reçus à cette adresse.
 *
 * Défaut : null
 */

//const MAIL_RETURN_PATH = 'returns@monserveur.com';


/**
 * Adresse e-mail expéditrice des messages (Sender)
 *
 * Si vous envoyez des mails pour plusieurs associations, il est souhaitable
 * de forcer l'adresse d'expéditeur des messages pour passer les règles SPF et DKIM.
 *
 * Dans ce cas l'adresse de l'association sera indiquée en "Reply-To", et
 * l'adresse contenue dans MAIL_SENDER sera dans le From.
 *
 * Si laissé NULL, c'est l'adresse de l'association indiquée dans la configuration
 * qui sera utilisée.
 *
 * Défaut : null
 */

//const MAIL_SENDER = 'associations@monserveur.com';

/**
 * Mot de passe pour l'accès à l'API permettant de gérer les mails d'erreur
 * (voir MAIL_RETURN_PATH)
 *
 * Cette adresse HTTP permet de gérer un bounce email reçu en POST.
 * C'est utile si votre serveur de mail est capable de faire une requête HTTP
 * à la réception d'un message.
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
 */
//const DISABLE_INSTALL_FORM = false;

/**
 * Stockage des fichiers
 *
 * Indiquer ici le nom d'une classe de stockage de fichiers
 * (parmis celles disponibles dans lib/Garradin/Files/Backend)
 *
 * Indiquer NULL si vous souhaitez stocker les fichier dans la base
 * de données SQLite (valeur par défaut).
 *
 * Classes de stockage possibles :
 * - SQLite : enregistre dans la base de données (défaut)
 * - FileSystem : enregistrement des fichiers dans le système de fichier







|







619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
 */
//const DISABLE_INSTALL_FORM = false;

/**
 * Stockage des fichiers
 *
 * Indiquer ici le nom d'une classe de stockage de fichiers
 * (parmis celles disponibles dans lib/Paheko/Files/Backend)
 *
 * Indiquer NULL si vous souhaitez stocker les fichier dans la base
 * de données SQLite (valeur par défaut).
 *
 * Classes de stockage possibles :
 * - SQLite : enregistre dans la base de données (défaut)
 * - FileSystem : enregistrement des fichiers dans le système de fichier
576
577
578
579
580
581
582


















583

584



585

























586
587
588
589
590
591
592
593
594
595
 *
 * Défaut : null (dans ce cas c'est le stockage qui détermine la taille disponible, donc généralement l'espace dispo sur le disque dur !)
 */

//const FILE_STORAGE_QUOTA = 10*1024*1024; // Forcer le quota alloué à 10 Mo, quel que soit le backend de stockage

/**


















 * PDF_COMMAND

 * Commande de création de PDF



 *

























 * Commande qui sera exécutée pour créer un fichier PDF à partir d'un HTML.
 *
 * Si laissé sur 'auto', Garradin essaiera de détecter une solution entre
 * PrinceXML, Chromium, wkhtmltopdf ou weasyprint (dans cet ordre).
 * Si aucune solution n'est disponible, une erreur sera affichée.
 *
 * Il est possible d'indiquer NULL pour désactiver l'export en PDF.
 *
 * Il est possible d'indiquer uniquement le nom du programme :
 * 'chromium', 'prince', 'weasyprint', ou 'wkhtmltopdf'.







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
|
>
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>


|







668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
 *
 * Défaut : null (dans ce cas c'est le stockage qui détermine la taille disponible, donc généralement l'espace dispo sur le disque dur !)
 */

//const FILE_STORAGE_QUOTA = 10*1024*1024; // Forcer le quota alloué à 10 Mo, quel que soit le backend de stockage

/**
 * FILE_VERSIONING_POLICY
 * Forcer la politique de versionnement des fichiers.
 *
 * null: laisser le choix de la politique (dans la configuration)
 * 'none': ne rien conserver
 * 'min': conserver 5 versions (1 minute, 1 heure, 1 jour, 1 semaine, 1 mois)
 * 'avg': conserver 20 versions
 * 'max': conserver 50 versions
 *
 * Note : indiquer 'none' fait qu'aucune nouvelle version ne sera créée,
 * mais les versions existantes sont conservées.
 *
 * Si ce paramètre n'est pas NULL, alors il faudra aussi définir FILE_VERSIONING_MAX_SIZE.
 *
 * Défaut : null (laisser le choix dans la configuration)
 *
 * @var null|string
 */

//const FILE_VERSIONING_POLICY = 'min';

/**
 * FILE_VERSIONING_MAX_SIZE
 * Forcer la taille maximale des fichiers à versionner (en Mio)
 *
 * N'a aucun effet si le versionnement de fichiers est désactivé.
 *
 * Défaut : null (laisser le choix de la taille dans la configuration)
 *
 * @var int|null
 */

//const FILE_VERSIONING_MAX_SIZE = 10;

/**
 * Adresse de découverte d'un client d'édition de documents (WOPI)
 * (type OnlyOffice, Collabora, MS Office)
 *
 * Cela permet de savoir quels types de fichiers sont éditables
 * avec l'éditeur web.
 *
 * Si NULL, alors l'édition de documents est désactivée.
 *
 * Défaut : null
 */

//const WOPI_DISCOVERY_URL = 'http://localhost:9980/hosting/discovery';

/**
 * PDF_COMMAND
 * Commande qui sera exécutée pour créer un fichier PDF à partir d'un HTML.
 *
 * Si laissé sur 'auto', Paheko essaiera de détecter une solution entre
 * PrinceXML, Chromium, wkhtmltopdf ou weasyprint (dans cet ordre).
 * Si aucune solution n'est disponible, une erreur sera affichée.
 *
 * Il est possible d'indiquer NULL pour désactiver l'export en PDF.
 *
 * Il est possible d'indiquer uniquement le nom du programme :
 * 'chromium', 'prince', 'weasyprint', ou 'wkhtmltopdf'.
604
605
606
607
608
609
610




611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647

648
649
650
651
652
653








































654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
 * Si vous utilisez une extension pour générer les PDF (comme DomPDF), alors
 * laisser cette constante sur 'auto'.
 *
 * Exemples :
 * 'weasyprint'
 * 'wkhtmltopdf -q --print-media-type --enable-local-file-access %s %s'
 *




 * Défaut : 'auto'
 * @var null|string
 */
//const PDF_COMMAND = 'auto';

/**
 * PDF_USAGE_LOG
 * Chemin vers le fichier où enregistrer la date de chaque export en PDF
 *
 * Ceci est utilisé notamment pour estimer le prix de la licence PrinceXML.
 *
 * Défaut : NULL
 * @var null|string
 */
//const PDF_USAGE_LOG = null;

/**
 * CALC_CONVERT_COMMAND
 * Outil de conversion de formats de tableur vers un format propriétaire
 *
 * Garradin gère nativement les exports en ODS (OpenDocument : LibreOffice)
 * et CSV, et imports en CSV.
 *
 * En indiquant ici le nom d'un outil, Garradin autorisera aussi
 * l'import en XLSX, XLS et ODS, et l'export en XLSX.
 *
 * Pour cela il procédera simplement à une conversion entre les formats natifs
 * ODS/CSV et XLSX ou XLS.
 *
 * Noter qu'installer ces commandes peut introduire des risques de sécurité sur le serveur.
 *
 * Les outils supportés sont :
 * - ssconvert (apt install gnumeric) (plus rapide)
 * - unoconv (apt install unoconv) (utilise LibreOffice)
 * - unoconvert (https://github.com/unoconv/unoserver/) en spécifiant l'interface
 *
 * Défault : null (= fonctionnalité désactivée)

 */
//const CALC_CONVERT_COMMAND = 'unoconv';
//const CALC_CONVERT_COMMAND = 'ssconvert';
//const CALC_CONVERT_COMMAND = 'unoconvert --interface localhost --port 2022';

/**








































 * API_USER et API_PASSWORD
 * Login et mot de passe système de l'API
 *
 * Une API est disponible via l'URL https://login:password@garradin.association.tld/api/...
 * Voir https://fossil.kd2.org/garradin/wiki?name=API pour la documentation
 *
 * Ces deux constantes permettent d'indiquer un nom d'utilisateur
 * et un mot de passe pour accès à l'API.
 *
 * Cet utilisateur est distinct de ceux définis dans la page de gestion des
 * identifiants d'accès à l'API, et aura accès à TOUT en écriture/administration.
 *
 * Défaut: null
 */
//const API_USER = 'coraline';
//const API_PASSWORD = 'thisIsASecretPassword42';

/**
 * DISABLE_INSTALL_PING
 *
 * Lors de l'installation, ou d'une mise à jour, la version installée de Garradin,
 * ainsi que celle de PHP et de SQLite, sont envoyées à Paheko.cloud.
 *
 * Cela permet de savoir quelles sont les versions utilisées, et également de compter
 * le nombre d'installations effectuées.
 *
 * Aucune donnée personnelle n'est envoyée. Un identifiant anonyme est envoyé,
 * permettant d'identifier l'installation et éviter les doublons.







>
>
>
>




















|


|





|







>






>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>



|
|















|







743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
 * Si vous utilisez une extension pour générer les PDF (comme DomPDF), alors
 * laisser cette constante sur 'auto'.
 *
 * Exemples :
 * 'weasyprint'
 * 'wkhtmltopdf -q --print-media-type --enable-local-file-access %s %s'
 *
 * Si vous utilisez Prince, un message mentionnant l'utilisation de Prince
 * sera joint aux e-mails utilisant des fichiers PDF, conformément à la licence :
 * https://www.princexml.com/purchase/license_faq/#non-commercial
 *
 * Défaut : 'auto'
 * @var null|string
 */
//const PDF_COMMAND = 'auto';

/**
 * PDF_USAGE_LOG
 * Chemin vers le fichier où enregistrer la date de chaque export en PDF
 *
 * Ceci est utilisé notamment pour estimer le prix de la licence PrinceXML.
 *
 * Défaut : NULL
 * @var null|string
 */
//const PDF_USAGE_LOG = null;

/**
 * CALC_CONVERT_COMMAND
 * Outil de conversion de formats de tableur vers un format propriétaire
 *
 * Paheko gère nativement les exports en ODS (OpenDocument : LibreOffice)
 * et CSV, et imports en CSV.
 *
 * En indiquant ici le nom d'un outil, Paheko autorisera aussi
 * l'import en XLSX, XLS et ODS, et l'export en XLSX.
 *
 * Pour cela il procédera simplement à une conversion entre les formats natifs
 * ODS/CSV et XLSX ou XLS.
 *
 * Note : installer ces commandes peut introduire des risques de sécurité sur le serveur.
 *
 * Les outils supportés sont :
 * - ssconvert (apt install gnumeric) (plus rapide)
 * - unoconv (apt install unoconv) (utilise LibreOffice)
 * - unoconvert (https://github.com/unoconv/unoserver/) en spécifiant l'interface
 *
 * Défault : null (= fonctionnalité désactivée)
 * @var string|null
 */
//const CALC_CONVERT_COMMAND = 'unoconv';
//const CALC_CONVERT_COMMAND = 'ssconvert';
//const CALC_CONVERT_COMMAND = 'unoconvert --interface localhost --port 2022';

/**
 * DOCUMENT_THUMBNAIL_COMMANDS
 * Indique les commandes à utiliser pour générer des miniatures pour les documents
 * (LibreOffice, OOXML, PDF, SVG, etc.)
 *
 * Les options possibles sont (par ordre de rapidité) :
 * - mupdf : les miniatures PDF/SVG/XPS/EPUB sont générées avec mutool
 *   (apt install mupdf-tools)
 * - collabora : les miniatures sont générées par le serveur Collabora, via
 *   l'API dont l'URL est  indiquée dans WOPI_DISCOVERY_URL
 * - unoconvert : les miniatures des documents Office/LO sont générées
 *   avec unoconvert <https://github.com/unoconv/unoserver/>
 *
 * Il est conseillé d'utiliser mupdf en priorité pour les PDF, il est plus rapide et léger.
 *
 * Note : cette option créera de nombreux fichiers de cache, et risque d'augmenter
 * la charge serveur de manière importante.
 *
 * Défaut : null (fonctionnalité désactivée)
 * @var null|array
 */

//const DOCUMENT_THUMBNAIL_COMMANDS = ['mupdf', 'collabora'];

/**
 * PDFTOTEXT_COMMAND
 * Outil de conversion de PDF au format texte.
 *
 * Utilisé pour indexer un fichier PDF pour pouvoir rechercher dans son contenu
 * parmi les documents.
 *
 * Il est possible de spécifier ici la commande suivante :
 * - mupdf (apt install mupdf-tools)
 *
 * Toute autre commande sera ignorée.
 *
 * Défaut : null (= fonctionnalité désactivée)
 */
//const PDFTOTEXT_COMMAND = 'pdftotext';

/**
 * API_USER et API_PASSWORD
 * Login et mot de passe système de l'API
 *
 * Une API est disponible via l'URL https://login:password@paheko.association.tld/api/...
 * Voir https://fossil.kd2.org/paheko/wiki?name=API pour la documentation
 *
 * Ces deux constantes permettent d'indiquer un nom d'utilisateur
 * et un mot de passe pour accès à l'API.
 *
 * Cet utilisateur est distinct de ceux définis dans la page de gestion des
 * identifiants d'accès à l'API, et aura accès à TOUT en écriture/administration.
 *
 * Défaut: null
 */
//const API_USER = 'coraline';
//const API_PASSWORD = 'thisIsASecretPassword42';

/**
 * DISABLE_INSTALL_PING
 *
 * Lors de l'installation, ou d'une mise à jour, la version installée de Paheko,
 * ainsi que celle de PHP et de SQLite, sont envoyées à Paheko.cloud.
 *
 * Cela permet de savoir quelles sont les versions utilisées, et également de compter
 * le nombre d'installations effectuées.
 *
 * Aucune donnée personnelle n'est envoyée. Un identifiant anonyme est envoyé,
 * permettant d'identifier l'installation et éviter les doublons.
703
704
705
706
707
708
709




























 * Il faut recopier cette clé dans le fichier config.local.php
 * dans la constante CONTRIBUTOR_LICENSE.
 *
 * Merci de ne pas essayer de contourner cette licence et de contribuer au
 * financement de notre travail :-)
 */
//const CONTRIBUTOR_LICENSE = 'XXXXX';



































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
 * Il faut recopier cette clé dans le fichier config.local.php
 * dans la constante CONTRIBUTOR_LICENSE.
 *
 * Merci de ne pas essayer de contourner cette licence et de contribuer au
 * financement de notre travail :-)
 */
//const CONTRIBUTOR_LICENSE = 'XXXXX';

/**
 * Informations légale sur l'hébergeur
 *
 * Ce texte (HTML) est affiché en bas de la page "mentions légales"
 * (.../admin/legal.php)
 *
 * S'il est omis, l'association sera indiquée comme étant auto-hébergée.
 *
 * Défaut : null
 *
 * @var  string|null
 */
//const LEGAL_HOSTING_DETAILS = 'OVH<br />5 rue de l'hébergement<br />ROUBAIX';

/**
 * Message d'avertissement
 *
 * Sera affiché en haut de toutes les pages de l'administration.
 *
 * Code HTML autorisé.
 * Utiliser NULL pour désactiver le message.
 *
 * Défaut : null
 *
 * @var null|string
 */
//const ALERT_MESSAGE = 'Ceci est un compte de test.';

Deleted src/include/data/1.0.0-beta6_migration.sql version [13c95a32fe].

1
2
3
4
5
6
7
8
9
10
11
12
13
ALTER TABLE fichiers_membres RENAME TO fichiers_membres_old;
ALTER TABLE fichiers_wiki_pages RENAME TO fichiers_wiki_pages_old;
ALTER TABLE fichiers_acc_transactions RENAME TO fichiers_acc_transactions_old;

.read 1.0.0_schema.sql

INSERT INTO fichiers_membres SELECT * FROM fichiers_membres_old;
INSERT INTO fichiers_wiki_pages SELECT * FROM fichiers_wiki_pages_old;
INSERT INTO fichiers_acc_transactions SELECT * FROM fichiers_acc_transactions_old;

DROP TABLE fichiers_membres_old;
DROP TABLE fichiers_wiki_pages_old;
DROP TABLE fichiers_acc_transactions_old;
<
<
<
<
<
<
<
<
<
<
<
<
<


























Deleted src/include/data/1.0.0-beta8_migration.sql version [ff1b70a076].

1
2
UPDATE acc_accounts SET type = 11 WHERE code = '120';
UPDATE acc_accounts SET type = 12 WHERE code = '129';
<
<




Deleted src/include/data/1.0.0-rc10_migration.sql version [ab6262425c].

1
2
3
4
UPDATE acc_accounts SET type = 8, position = 4 WHERE id_chart = (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '86_%');
UPDATE acc_accounts SET type = 8, position = 5 WHERE id_chart = (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '87_%');

UPDATE acc_accounts SET position = 3 WHERE code IN ('5112', '5115', '530') AND code IS NOT NULL;
<
<
<
<








Deleted src/include/data/1.0.0-rc14_migration.sql version [2be7aec0e4].

1
2
3
4
-- Put 890, 891 accounts in balance sheet, though it's not really correct...
UPDATE acc_accounts SET position = 3 WHERE
	code IN (890, 891)
	AND id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL);
<
<
<
<








Deleted src/include/data/1.0.0-rc16_migration.sql version [2408d33901].

1
2
UPDATE acc_accounts SET position = 1 WHERE code = '486' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCA2018');
UPDATE acc_accounts SET position = 2 WHERE code = '487' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCA2018');
<
<




Deleted src/include/data/1.0.0-rc3_migration.sql version [46b4d521e5].

1
UPDATE acc_transactions SET type = 0 WHERE type = 6;
<


Deleted src/include/data/1.0.0_migration.sql version [5b1e5033eb].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
ALTER TABLE membres_operations RENAME TO membres_operations_old;
ALTER TABLE membres_categories RENAME TO membres_categories_old;

DROP TABLE fichiers_compta_journal; -- Inutilisé à ce jour

-- Fix: comptes de clôture et fermeture
UPDATE compta_comptes SET libelle = 'Bilan d''ouverture' WHERE id = '890' AND libelle = 'Bilan de clôture';
INSERT OR REPLACE INTO compta_comptes (id, parent, libelle, position) VALUES ('891', '89', 'Bilan de clôture', 0);

-- N'est pas utilisé
DELETE FROM config WHERE cle = 'categorie_dons' OR cle = 'categorie_cotisations';

.read 1.0.0_schema.sql

-------- MIGRATION COMPTA ---------
INSERT INTO acc_charts (id, country, code, label) VALUES (1, 'FR', 'PCGA1999', 'Plan comptable associatif 1999');

-- Migration comptes de code comme identifiant à ID unique
-- Inversement valeurs actif/passif et produit/charge
INSERT INTO acc_accounts (id, id_chart, code, label, position, user)
	SELECT NULL, 1, id, libelle,
	CASE
		WHEN position = 1 THEN 2
		WHEN position = 2 THEN 1
		WHEN position = 3 THEN 3
		WHEN position = 4 THEN 5
		WHEN position = 8 THEN 4
		-- Suppression de la position "charge ou produit" qui n'a aucun sens
		WHEN position = 12 AND id LIKE '6%' THEN 4
		WHEN position = 12 AND id LIKE '7%' THEN 5
		WHEN position = 12 THEN 0
		ELSE 0
	END,
	CASE WHEN plan_comptable = 1 THEN 0 ELSE 1 END
	FROM compta_comptes;

-- Migrations projets vers comptes analytiques
INSERT INTO acc_accounts (id_chart, code, label, position, user, type)
	VALUES (1, '99', 'Projets', 0, 1, 0);

INSERT INTO acc_accounts (id_chart, code, label, position, user, type)
	SELECT 1, '99' || substr('0000' || id, -4), libelle, 0, 1, 7 FROM compta_projets;

-- Mise à jour de la position pour les comptes de tiers qui peuvent varier actif ou passif
UPDATE acc_accounts SET position = 3 WHERE code IN (4010, 4110, 4210, 428, 438);

-- Mise à jour position comptes bancaires, qui peuvent être à découvert et donc changer de côté au bilan
UPDATE acc_accounts SET position = 3 WHERE code LIKE '512%';

-- Migration comptes bancaires
UPDATE acc_accounts SET type = 1 WHERE code IN (SELECT id FROM compta_comptes_bancaires);

-- Caisse
UPDATE acc_accounts SET type = 2 WHERE code = '530';

-- Chèques et carte à encaisser
UPDATE acc_accounts SET type = 3 WHERE code = '5112' OR code = '5113';

-- Comptes d'ouverture et de clôture
UPDATE acc_accounts SET type = 9, position = 0 WHERE code = '890';
UPDATE acc_accounts SET type = 10, position = 0 WHERE code = '891';

-- Comptes de tiers
UPDATE acc_accounts SET type = 4 WHERE code IN (SELECT id FROM compta_comptes WHERE id LIKE '4%' AND plan_comptable = 0 AND desactive = 0);

-- Recopie des mouvements
INSERT INTO acc_transactions (id, label, notes, reference, date, id_year, id_creator)
	SELECT id, libelle, remarques, numero_piece, date, id_exercice, id_auteur
	FROM compta_journal;

-- Recettes
UPDATE acc_transactions SET type = 1 WHERE id IN (SELECT id FROM compta_journal WHERE id_categorie IN (SELECT id FROM compta_categories WHERE type = 1));

-- Dépenses
UPDATE acc_transactions SET type = 2 WHERE id IN (SELECT id FROM compta_journal WHERE id_categorie IN (SELECT id FROM compta_categories WHERE type = -1));

-- Virements
UPDATE acc_transactions SET type = 3 WHERE id IN (SELECT id FROM compta_journal
	WHERE (compte_credit IN ('530', '5112', '5115') OR compte_credit LIKE '512%')
	AND (compte_debit IN ('530', '5112', '5115') OR compte_debit LIKE '512%'));

-- Dettes
UPDATE acc_transactions SET type = 4 WHERE id IN (SELECT id FROM compta_journal WHERE compte_debit LIKE '6%' AND compte_credit LIKE '4%');

-- Créances
UPDATE acc_transactions SET type = 5 WHERE id IN (SELECT id FROM compta_journal WHERE compte_credit LIKE '7%' AND compte_debit LIKE '4%');

-- Création des lignes associées aux mouvements
INSERT INTO acc_transactions_lines (id_transaction, id_account, debit, credit, reference, id_analytical)
	SELECT id, (SELECT id FROM acc_accounts WHERE code = compte_credit), 0, CAST(REPLACE(montant * 100, '.0', '') AS INT), numero_cheque,
	CASE WHEN id_projet IS NOT NULL THEN (SELECT id FROM acc_accounts WHERE code = '99' || substr('0000' || id_projet, -4)) ELSE NULL END
	FROM compta_journal;

INSERT INTO acc_transactions_lines (id_transaction, id_account, debit, credit, reference, id_analytical)
	SELECT id, (SELECT id FROM acc_accounts WHERE code = compte_debit), CAST(REPLACE(montant * 100, '.0', '') AS INT), 0, numero_cheque,
	CASE WHEN id_projet IS NOT NULL THEN (SELECT id FROM acc_accounts WHERE code = '99' || substr('0000' || id_projet, -4)) ELSE NULL END
	FROM compta_journal;

-- Recopie des descriptions de catégories dans la table des comptes, et mise des comptes en signets
-- +Fix éventuels types qui ne correspondent pas à leur type… (@Fred C.) (... a.position = X)
-- Revenus
UPDATE acc_accounts SET type = 6, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = 1 AND a.position = 5);

-- Dépenses
UPDATE acc_accounts SET type = 5, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = -1 AND c.compte NOT LIKE '4%' AND a.position = 4);

-- Tiers
UPDATE acc_accounts SET type = 4, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = -1 AND c.compte LIKE '4%');

-- Recopie des exercices, mais la date de fin ne peut être nulle
INSERT INTO acc_years (id, label, start_date, end_date, closed, id_chart)
	SELECT id, libelle, debut, CASE WHEN fin IS NULL THEN date(debut, '+1 year') ELSE fin END, cloture, 1 FROM compta_exercices;

-- Recopie des catégories, on supprime la colonne id_cotisation_obligatoire
INSERT INTO membres_categories
	SELECT id, nom, droit_wiki, droit_membres, droit_compta, droit_inscription, droit_connexion, droit_config, cacher FROM membres_categories_old;

DROP TABLE membres_categories_old;

-- Transfert des rapprochements
UPDATE acc_transactions_lines SET reconciled = 1 WHERE id_transaction IN (SELECT id_operation FROM compta_rapprochement);

--------- MIGRATION COTISATIONS ----------

-- A edge-case where the end date is after the start date, let's fix it…
UPDATE cotisations SET fin = debut WHERE fin < debut;
UPDATE cotisations SET duree = NULL WHERE duree = 0;

INSERT INTO services SELECT id, intitule, description, duree, debut, fin FROM cotisations;

INSERT INTO services_fees (id, label, amount, id_service, id_account, id_year)
	SELECT id, intitule, CASE WHEN montant IS NOT NULL THEN CAST(montant*100 AS integer) ELSE NULL END, id,
		(SELECT id FROM acc_accounts WHERE code = (SELECT compte FROM compta_categories WHERE id = id_categorie_compta)),
		(SELECT MAX(id) FROM acc_years GROUP BY closed ORDER BY closed LIMIT 1)
	FROM cotisations;

INSERT INTO services_users SELECT cm.id, cm.id_membre, cm.id_cotisation,
	cm.id_cotisation,
	1,
	NULL,
	cm.date,
	CASE
		WHEN c.duree IS NOT NULL THEN date(cm.date, '+'||c.duree||' days')
		WHEN c.fin IS NOT NULL THEN c.fin
		ELSE NULL
	END
	FROM cotisations_membres cm
	INNER JOIN cotisations c ON c.id = cm.id_cotisation;

INSERT INTO services_reminders SELECT * FROM rappels;
INSERT INTO services_reminders_sent SELECT id, id_membre, id_cotisation,
	CASE WHEN id_rappel IS NULL THEN (SELECT MAX(id) FROM rappels) ELSE id_rappel END, date
	FROM rappels_envoyes
	WHERE id_rappel IS NOT NULL
	GROUP BY id_membre, id_cotisation, id_rappel;

-- Recopie des opérations par membre, mais le nom a changé pour acc_transactions_users, et il faut valider l'existence du membre ET du service
INSERT INTO acc_transactions_users
	SELECT a.* FROM membres_operations_old a
	INNER JOIN membres b ON b.id = a.id_membre
	INNER JOIN services_users c ON c.id = a.id_cotisation;

DROP TABLE cotisations;
DROP TABLE cotisations_membres;
DROP TABLE rappels;
DROP TABLE rappels_envoyes;

-- Suppression inutilisées
DROP TABLE compta_rapprochement;
DROP TABLE compta_journal;
DROP TABLE compta_categories;
DROP TABLE compta_comptes;
DROP TABLE compta_exercices;
DROP TABLE membres_operations_old;

DROP TABLE compta_projets;
DROP TABLE compta_comptes_bancaires;
DROP TABLE compta_moyens_paiement;

INSERT INTO acc_charts (country, code, label) VALUES ('FR', 'PCA2018', 'Plan comptable associatif 2018');

CREATE TEMP TABLE tmp_accounts (code,label,description,position,type);

.import charts/fr_pca_2018.csv tmp_accounts

INSERT INTO acc_accounts (id_chart, code, label, description, position, type) SELECT
	(SELECT id FROM acc_charts WHERE code = 'PCA2018'),
	code, label, description,
	CASE position
		WHEN 'Actif' THEN 1
		WHEN 'Passif' THEN 2
		WHEN 'Actif ou passif' THEN 3
		WHEN 'Charge' THEN 4
		WHEN 'Produit' THEN 5
		ELSE 0
	END,
	CASE type
		WHEN 'Banque' THEN 1
		WHEN 'Caisse' THEN 2
		WHEN 'Attente d''encaissement' THEN 3
		WHEN 'Tiers' THEN 4
		WHEN 'Dépenses' THEN 5
		WHEN 'Recettes' THEN 6
		WHEN 'Analytique' THEN 7
		WHEN 'Bénévolat' THEN 8
		WHEN 'Ouverture' THEN 9
		WHEN 'Clôture' THEN 10
		WHEN 'Résultat excédentaire' THEN 11
		WHEN 'Résultat déficitaire' THEN 12
		ELSE 0
	END
	FROM tmp_accounts;

DROP TABLE tmp_accounts;
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


















































































































































































































































































































































































































































Deleted src/include/data/1.0.0_schema.sql version [292ae06778].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
CREATE TABLE IF NOT EXISTS config (
-- Configuration de Garradin
    cle TEXT PRIMARY KEY NOT NULL,
    valeur TEXT
);

CREATE TABLE IF NOT EXISTS membres_categories
-- Catégories de membres
(
    id INTEGER PRIMARY KEY NOT NULL,
    nom TEXT NOT NULL,

    droit_wiki INTEGER NOT NULL DEFAULT 1,
    droit_membres INTEGER NOT NULL DEFAULT 1,
    droit_compta INTEGER NOT NULL DEFAULT 1,
    droit_inscription INTEGER NOT NULL DEFAULT 0,
    droit_connexion INTEGER NOT NULL DEFAULT 1,
    droit_config INTEGER NOT NULL DEFAULT 0,
    cacher INTEGER NOT NULL DEFAULT 0
);

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
(
    selecteur TEXT NOT NULL,
    hash TEXT NOT NULL,
    id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    expire INT NOT NULL,

    PRIMARY KEY (selecteur, id_membre)
);

CREATE TABLE IF NOT EXISTS services
-- Types de services (cotisations)
(
    id INTEGER PRIMARY KEY NOT NULL,

    label TEXT NOT NULL,
    description TEXT NULL,

    duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
    start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
    end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
);

CREATE TABLE IF NOT EXISTS services_fees
(
    id INTEGER PRIMARY KEY NOT NULL,

    label TEXT NOT NULL,
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL si le type n'est pas associé automatiquement à la compta
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL si le type n'est pas associé automatiquement à la compta
);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE,

    paid INTEGER NOT NULL DEFAULT 0,
    expected_amount INTEGER NULL,

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
    expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, date);

CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date);

CREATE TABLE IF NOT EXISTS services_reminders
-- Rappels de devoir renouveller une cotisation
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,

    delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel

    subject TEXT NOT NULL,
    body TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Enregistrement des rappels envoyés à qui et quand
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

--
-- WIKI
--

CREATE TABLE IF NOT EXISTS wiki_pages
-- Pages du wiki
(
    id INTEGER PRIMARY KEY NOT NULL,
    uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki)
    titre TEXT NOT NULL,
    date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation),
    date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification),
    parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent
    revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte)
    droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe)
    droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe)
);

CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri);

CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4
-- Table dupliquée pour chercher une page
(
    id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire
    titre TEXT NOT NULL,
    contenu TEXT NULL, -- Contenu de la dernière révision
    FOREIGN KEY (id) REFERENCES wiki_pages(id)
);

CREATE TABLE IF NOT EXISTS wiki_revisions
-- Révisions du contenu des pages
(
    id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
    revision INTEGER NULL,

    id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL,

    contenu TEXT NOT NULL,
    modification TEXT NULL, -- Description des modifications effectuées
    chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon
    date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),

    PRIMARY KEY(id_page, revision)
);

CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page);
CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur);

-- Triggers pour synchro avec table wiki_pages
CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages
    BEGIN
        DELETE FROM wiki_recherche WHERE id = old.id;
    END;

CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages
    BEGIN
        UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id;
    END;

-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision
CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1
    BEGIN
        UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page;
    END;

-- Si le contenu est chiffré, la recherche n'affiche pas de contenu
CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1
    BEGIN
        UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page;
    END;

--
-- COMPTA
--

CREATE TABLE IF NOT EXISTS acc_charts
-- Plans comptables : il peut y en avoir plusieurs
(
    id INTEGER NOT NULL PRIMARY KEY,
    country TEXT NOT NULL,
    code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur
    label TEXT NOT NULL,
    archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable
);

CREATE TABLE IF NOT EXISTS acc_accounts
-- Comptes des plans comptables
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE,

    code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc.

    label TEXT NOT NULL,
    description TEXT NULL,

    position INTEGER NOT NULL, -- position actif/passif/charge/produit
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1 -- 1 = fait partie du plan comptable original, 0 = a été ajouté par l'utilisateur
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);

CREATE TABLE IF NOT EXISTS acc_years
-- Exercices
(
    id INTEGER NOT NULL PRIMARY KEY,

    label TEXT NOT NULL,

    start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
    end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),

    closed INTEGER NOT NULL DEFAULT 0,

    id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
);

CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
    UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;

CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);

CREATE TABLE IF NOT EXISTS acc_transactions
-- Opérations comptables
(
    id INTEGER PRIMARY KEY NOT NULL,

    type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
    status INTEGER NOT NULL DEFAULT 0, -- Statut (bitmask)

    label TEXT NOT NULL,
    notes TEXT NULL,
    reference TEXT NULL, -- N° de pièce comptable

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),

    validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable

    hash TEXT NULL,
    prev_hash TEXT NULL,

    id_year INTEGER NOT NULL REFERENCES acc_years(id),
    id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL,
    id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette)
);

CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related);
CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);

CREATE TABLE IF NOT EXISTS acc_transactions_lines
-- Lignes d'écritures d'une opération
(
    id INTEGER PRIMARY KEY NOT NULL,

    id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
    id_account INTEGER NOT NULL REFERENCES acc_accounts (id), -- N° du compte dans le plan comptable

    credit INTEGER NOT NULL,
    debit INTEGER NOT NULL,

    reference TEXT NULL, -- Référence de paiement, eg. numéro de chèque
    label TEXT NULL,

    reconciled INTEGER NOT NULL DEFAULT 0,

    id_analytical INTEGER NULL REFERENCES acc_accounts(id) ON DELETE SET NULL,

    CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
    CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_analytical ON acc_transactions_lines (id_analytical);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);

CREATE TABLE IF NOT EXISTS acc_transactions_users
-- Liaison des écritures et des membres
(
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
    id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,

    PRIMARY KEY (id_user, id_transaction)
);

CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);

CREATE TABLE IF NOT EXISTS plugins
(
    id TEXT NOT NULL PRIMARY KEY,
    officiel INTEGER NOT NULL DEFAULT 0,
    nom TEXT NOT NULL,
    description TEXT NULL,
    auteur TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INTEGER NOT NULL DEFAULT 0,
    menu_condition TEXT NULL,
    config TEXT NULL
);

CREATE TABLE IF NOT EXISTS plugins_signaux
-- Association entre plugins et signaux (hooks)
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

CREATE TABLE IF NOT EXISTS fichiers
-- Données sur les fichiers
(
    id INTEGER NOT NULL PRIMARY KEY,
    nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
    type TEXT NULL, -- Type MIME
    image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
    datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier
    id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime);

CREATE TABLE IF NOT EXISTS fichiers_contenu
-- Contenu des fichiers
(
    id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
    taille INTEGER NOT NULL, -- Taille en octets
    contenu BLOB NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash);

CREATE TABLE IF NOT EXISTS fichiers_membres
-- Associations entre fichiers et membres (photo de profil par exemple)
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS fichiers_wiki_pages
-- Associations entre fichiers et pages du wiki
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS fichiers_acc_transactions
-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
    intitule TEXT NOT NULL,
    creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
    cible TEXT NOT NULL, -- "membres" ou "compta"
    type TEXT NOT NULL, -- "json" ou "sql"
    contenu TEXT NOT NULL
);


CREATE TABLE IF NOT EXISTS compromised_passwords_cache
-- Cache des hash de mots de passe compromis
(
    hash TEXT NOT NULL PRIMARY KEY
);

CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
-- Cache des préfixes de mots de passe compromis
(
    prefix TEXT NOT NULL PRIMARY KEY,
    date INTEGER NOT NULL
);
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/data/1.0.1_migration.sql version [d72d553917].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
UPDATE acc_accounts SET position = 3 WHERE code = '445' OR code = '444' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCGA1999');

UPDATE acc_transactions SET label = '[ERREUR ! À corriger !] ' || label, status = 8 WHERE id IN (
	SELECT DISTINCT t.id
		FROM acc_transactions t
		INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
		INNER JOIN acc_accounts a ON l.id_account = a.id
		INNER JOIN acc_years y ON y.id = t.id_year AND y.closed = 0 AND y.id_chart != a.id_chart
);

UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_account IN (
	SELECT sf.id_account FROM services_fees sf
		INNER JOIN acc_accounts a ON a.id = sf.id_account
		INNER JOIN acc_years y ON y.id = sf.id_year
	WHERE a.id_chart != y.id_chart);
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























Deleted src/include/data/1.0.3_migration.sql version [9ded2b030d].

1
2
-- Fix services connected to old closed year
UPDATE services_fees SET id_year = NULL, id_account = NULL WHERE id_year IN (SELECT id FROM acc_years WHERE closed = 1);
<
<




Deleted src/include/data/1.0.6_migration.sql version [6e98c5399a].

1
2
-- Fix credit/debt payment types
UPDATE acc_transactions SET type = 0 WHERE id_related IS NOT NULL AND type IN (4,5);
<
<




Deleted src/include/data/1.0.7_migration.sql version [f53124e2db].

1
2
3
4
5
-- Add indexes
DROP INDEX IF EXISTS acc_transactions_type;
CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
<
<
<
<
<










Deleted src/include/data/1.1.0_migration.sql version [9f5de55bec].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
-- Remove triggers in case they interact with the migration
DROP TRIGGER IF EXISTS wiki_recherche_delete;
DROP TRIGGER IF EXISTS wiki_recherche_update;
DROP TRIGGER IF EXISTS wiki_recherche_contenu_insert;
DROP TRIGGER IF EXISTS wiki_recherche_contenu_chiffre;

-- Fix some rare edge cases where date_inscription is incorrect
UPDATE membres SET date_inscription = date() WHERE date(date_inscription) IS NULL;

-- Uh, force another login id if email is not correct
UPDATE config SET valeur = 'numero' WHERE cle = 'champ_identifiant' AND valeur = 'email'
	AND (SELECT COUNT(*) FROM membres WHERE email IS NOT NULL GROUP BY LOWER(email) HAVING COUNT(*) > 1 LIMIT 1);

-- Other weird things to fix before migration
UPDATE wiki_pages SET uri = 'page_' || id WHERE uri = '' OR uri IS NULL;
DELETE FROM config WHERE cle = 'connexion' OR cle = 'wiki';

ALTER TABLE membres_categories RENAME TO membres_categories_old;

INSERT OR IGNORE INTO config (cle, valeur) VALUES ('desactiver_site', '0');
ALTER TABLE config RENAME TO config_old;

.read 1.1.0_schema.sql

INSERT INTO config SELECT * FROM config_old;
DROP TABLE config_old;

-- This is not used anymore
DELETE FROM config WHERE key = 'version';
DELETE FROM config WHERE key = 'accueil_wiki';

-- New config key
INSERT INTO config (key, value) VALUES ('telephone_asso', NULL);

-- Create directories
INSERT INTO files (parent, name, path, type) VALUES ('', 'documents', 'documents', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'config', 'config', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'transaction', 'transaction', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'skel', 'skel', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'user', 'user', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'web', 'web', 2);

-- Copy droit_wiki value to droit_web and droit_documents
INSERT INTO users_categories
	SELECT id, nom,
		droit_wiki, -- perm_web
		droit_wiki, -- perm_documents
		droit_membres,
		droit_compta,
		droit_inscription,
		droit_connexion,
		droit_config,
		cacher
	FROM membres_categories_old;

DROP TABLE membres_categories_old;

UPDATE recherches SET contenu = REPLACE(contenu, 'id_categorie', 'id_category') WHERE cible = 'membres' AND contenu LIKE '%id_categorie%';

CREATE TEMP TABLE files_transactions (old_id, old_transaction, old_name, new_path, new_id, same_name);

-- Adding an extra step as some file names can have the same name
INSERT INTO files_transactions
	SELECT f.id, t.id, f.nom, NULL, NULL, NULL
	FROM fichiers f
		INNER JOIN fichiers_acc_transactions t ON t.fichier = f.id;

UPDATE files_transactions SET same_name = old_id || '_'
	WHERE old_id IN (SELECT old_id FROM files_transactions GROUP BY old_transaction, old_name HAVING COUNT(*) > 1);

-- Make file name is unique!
UPDATE files_transactions SET new_path = 'transaction/' || old_transaction || '/' || COALESCE(old_id || '_', '') || old_name;

-- Copy existing files for transactions
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT
		ft.new_path,
		dirname(ft.new_path),
		basename(ft.new_path),
		1, f.type, f.datetime, c.taille, f.image
	FROM files_transactions ft
		INNER JOIN fichiers f ON f.id = ft.old_id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

UPDATE files_transactions SET new_id = (SELECT id FROM files WHERE path = new_path);

INSERT INTO files_contents (id, compressed, content)
	SELECT ft.new_id, 0, c.contenu
	FROM fichiers f
		INNER JOIN files_transactions ft ON ft.old_id = f.id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

-- Copy wiki pages content
CREATE TEMP TABLE wiki_as_files (old_id, new_id, path, content, title, uri,
	old_parent, new_parent, created, modified, author_id, encrypted, type, public);

INSERT INTO wiki_as_files
	SELECT
		id, NULL, '', CASE WHEN contenu IS NULL THEN '' ELSE contenu END, titre, uri,
		parent, parent, date_creation, date_modification, id_auteur, chiffrement,
		CASE WHEN (SELECT 1 FROM wiki_pages pp WHERE pp.parent = p.id LIMIT 1) THEN 1 ELSE 2 END, -- Type, 1 = category, 2 = page
		CASE WHEN droit_lecture = -1 THEN 1 ELSE 0 END -- public
	FROM wiki_pages p
	LEFT JOIN wiki_revisions r ON r.id_page = p.id AND r.revision = p.revision;

-- Build path
WITH RECURSIVE path(level, uri, parent, id) AS (
	SELECT 0, uri, old_parent, old_id
	FROM wiki_as_files
	UNION ALL
	SELECT path.level + 1,
	wiki_as_files.uri,
	wiki_as_files.old_parent,
	path.id
	FROM wiki_as_files
	JOIN path ON wiki_as_files.old_id = path.parent
	WHERE level <= 8 -- max level = 8 to avoid recursion
),
path_from_root AS (
	SELECT group_concat(uri, '/') AS path, id
	FROM (SELECT id, uri FROM path ORDER BY level DESC)
	GROUP BY id
)
UPDATE wiki_as_files SET path = (SELECT path FROM path_from_root WHERE id = wiki_as_files.old_id);

-- remove recursion
UPDATE wiki_as_files SET path = uri WHERE path IS NULL OR LENGTH(path) - LENGTH(REPLACE(path, '/', '')) >= 8;

-- Copy into files
INSERT INTO files (path, parent, name, type, mime, modified, size)
	SELECT
		'web/' || path || '/index.txt',
		'web/' || path,
		'index.txt',
		1,
		'text/plain',
		modified,
		0 -- size will be set after
	FROM wiki_as_files;

UPDATE wiki_as_files SET new_id = (SELECT id FROM files WHERE files.path = 'web/' || (CASE WHEN wiki_as_files.path IS NOT NULL THEN wiki_as_files.path || '/' ELSE '' END) || wiki_as_files.uri || '/index.txt');

-- x'0a' == \n
INSERT INTO files_contents (id, compressed, content)
	SELECT f.id, 0,
		'Title: ' || title || x'0a' || 'Published: ' || created || x'0a' || 'Status: '
		|| (CASE WHEN public THEN 'Online' ELSE 'Draft' END)
		|| x'0a' || 'Format: ' || (CASE WHEN encrypted THEN 'Skriv/Encrypted' ELSE 'Skriv' END)
		|| x'0a' || x'0a' || '----' || x'0a' || x'0a' || content
	FROM wiki_as_files waf
	INNER JOIN files f ON f.path = 'web/' || waf.path || '/index.txt';

-- Copy to search
INSERT INTO files_search (path, title, content)
	SELECT
		'web/' || path || '/index.txt',
		title,
		CASE WHEN encrypted THEN NULL ELSE content END
	FROM wiki_as_files WHERE encrypted = 0;

-- Copy to web_pages
INSERT INTO web_pages (id, parent, path, uri, file_path, type, status, title, published, modified, format, content)
	SELECT new_id,
	CASE WHEN dirname(path) = '.' THEN '' ELSE dirname(path) END,
	path,
	uri,
	'web/' || path || '/index.txt',
	type,
	CASE WHEN public THEN 'online' ELSE 'draft' END,
	title, created, modified,
	CASE WHEN encrypted THEN 'skriv/encrypted' ELSE 'skriv' END,
	content
	FROM wiki_as_files;

CREATE TEMP TABLE files_wiki (old_id, wiki_id, web_path, old_name, new_path, new_id);

-- Adding an extra step as some file names can have the same name
INSERT INTO files_wiki
	SELECT f.id, w.id, waf.path, f.nom, 'web/' || waf.path || '/' || f.id || '_' || f.nom, NULL
	FROM fichiers f
		INNER JOIN fichiers_wiki_pages w ON w.fichier = f.id
		INNER JOIN wiki_as_files waf ON w.id = waf.old_id;

-- Copy files linked to wiki pages
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT
		fw.new_path,
		dirname(fw.new_path),
		basename(fw.new_path),
		1,
		f.type,
		f.datetime,
		c.taille,
		f.image
	FROM fichiers f
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
		INNER JOIN files_wiki fw ON fw.old_id = f.id;

UPDATE files_wiki SET new_id = (SELECT id FROM files WHERE path = new_path);

INSERT INTO files_contents (id, compressed, content)
	SELECT
		fw.new_id, 0, c.contenu
	FROM files_wiki fw
		INNER JOIN fichiers f ON f.id = fw.old_id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

-- Create parent directories
INSERT INTO files (type, path, parent, name, modified)
	SELECT 2,
		'web/' || waf.path,
		dirname('web/' || waf.path),
		waf.uri,
		modified
	FROM wiki_as_files waf;

INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;

-- Copy existing config files
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT 'config/admin_bg.png', 'config', 'admin_bg.png', 1, type, datetime, c.taille, image
	FROM fichiers f
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
	WHERE f.id = (SELECT c.value FROM config c WHERE key = 'image_fond') LIMIT 1;

INSERT INTO files_contents (id, compressed, content)
	SELECT f2.id, 0, c.contenu
	FROM files AS f2
		INNER JOIN fichiers f ON f2.path = 'config/admin_bg.png'
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
		WHERE f.id = (SELECT c.value FROM config c WHERE key = 'image_fond') LIMIT 1;

-- Rename
UPDATE config SET key = 'admin_background', value = 'config/admin_bg.png' WHERE key = 'image_fond';

-- Copy connection page as a single file
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT 'config/admin_homepage.skriv', 'config', 'admin_homepage.skriv', 1, 'text/plain', datetime(), LENGTH(content), 0
	FROM wiki_as_files
	WHERE uri = (SELECT value FROM config WHERE key = 'accueil_connexion');

INSERT INTO files_contents (id, compressed, content)
	SELECT f.id, 0, waf.content
	FROM files f
		INNER JOIN wiki_as_files waf ON waf.uri = (SELECT value FROM config WHERE key = 'accueil_connexion')
	WHERE f.path = 'config/admin_homepage.skriv';

-- Rename
UPDATE config SET key = 'admin_homepage', value = 'config/admin_homepage.skriv' WHERE key = 'accueil_connexion';
UPDATE config SET key = 'site_disabled' WHERE key = 'desactiver_site';

-- Create transaction directories
INSERT INTO files (path, parent, name, type) SELECT 'transaction/' || id, 'transaction', id, 2 FROM fichiers_acc_transactions GROUP BY id;

-- Set file size
UPDATE files SET size = (SELECT LENGTH(content) FROM files_contents WHERE id = files.id) WHERE type = 1;

DELETE FROM plugins_signaux WHERE signal LIKE 'boucle.%';

DROP TABLE wiki_recherche;

DROP TABLE wiki_pages;
DROP TABLE wiki_revisions;

DROP TABLE fichiers_wiki_pages;
DROP TABLE fichiers_acc_transactions;
DROP TABLE fichiers_membres;

DROP TABLE fichiers;
DROP TABLE fichiers_contenu;
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































































































































































































































































































































































































































































































































































Deleted src/include/data/1.1.15_migration.sql version [f806b6152c].

1
2
3
4
5
UPDATE acc_accounts SET type = 8, position = 4 WHERE id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '86_%') AND user = 0;
UPDATE acc_accounts SET type = 8, position = 5 WHERE id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '87_%') AND user = 0;

-- Cohérence avec plan 2018
UPDATE acc_charts SET code = 'PCA1999' WHERE code = 'PCGA1999';
<
<
<
<
<










Deleted src/include/data/1.1.19_migration.sql version [a29354b289].

1
2
3
UPDATE acc_accounts SET type = 13 WHERE type = 0 AND code = '1068';
UPDATE acc_accounts SET type = 14 WHERE type = 0 AND code = '110';
UPDATE acc_accounts SET type = 15 WHERE type = 0 AND code = '119';
<
<
<






Deleted src/include/data/1.1.3_migration.sql version [18eac8e4eb].

1
2
3
4
-- Make sure id_account is reset when a year is deleted
CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
    UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;
<
<
<
<








Deleted src/include/data/1.1.7_migration.sql version [1dab4145d3].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
ALTER TABLE services_reminders_sent RENAME TO srs_old;

-- Add new column in services_reminders_sent
CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Enregistrement des rappels envoyés à qui et quand
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

    sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
    due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

INSERT INTO services_reminders_sent SELECT id, id_user, id_service, id_reminder, date, date FROM srs_old;
DROP TABLE srs_old;

-- Missing acc_years_delete trigger, again, because of missing symlink in previous release
-- Make sure id_account is reset when a year is deleted
CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
    UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;

<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




























































Deleted src/include/data/1.1.8_migration.sql version [cc434f178d].

1
2
3
4
5
-- Remove any leftover duplicates
DELETE FROM web_pages WHERE id IN (SELECT id FROM web_pages GROUP BY uri HAVING COUNT(*) > 1);

-- Add unique index
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
<
<
<
<
<










Deleted src/include/data/champs_membres.ini version [1af4967b02].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
;	Ce fichier contient la configuration par défaut des champs des fiches membres.
;	La configuration est ensuite enregistrée au format INI dans la table 
;	config de la base de données.
;
;	Syntaxe :
;
;	[nom_du_champ] ; Nom unique du champ, ne peut contenir que des lettres et des tirets bas
;	type = text
;	title = "Super champ trop cool"
;	mandatory = true
;	editable = false
;
;	Description des options possibles pour chaque champ :
;
;	type: (défaut: text) OBLIGATOIRE
;		certains types gérés par <input type> de HTML5 :
;		text, number, date, datetime, url, email, checkbox, file, password, tel
;		champs spécifiques :
;		- country = sélecteur de pays
;		- textarea = texte multi lignes
;		- multiple = multiples cases à cocher (jusqu'à 32, binaire)
;		- select = un choix parmis plusieurs
;	title: OBLIGATOIRE
;		Titre du champ
;	help:
;		Texte d'aide sur les fiches membres
;	options[]:
;		pour définir les options d'un champ de type select ou multiple
;	editable:
;		true = modifiable par le membre
;		false = modifiable uniquement par un admin (défaut)
;	mandatory:
;		true = obligatoire, la fiche membre ne pourra être enregistrée si ce champ est vide
;		false = facultatif (défaut)
;	private:
;		true = non visible par le membre lui-même
;		false = visible par le membre (défaut)
;	list_row:
;		Si absent ou zéro ('0') ou false, ce champ n'apparaîtra pas dans la liste des membres
;		Si présent et un chiffre supérieur à 0, alors le champ apparaîtra dans la liste des membres
;		dans l'ordre défini par le chiffre (si nom est à 2 et email à 1, alors email sera
;		la première colonne et nom la seconde)
;	install:
;		true = sera ajouté aux fiches membres à l'installation
;		false = sera seulement présent dans les champs supplémentaires possibles (défaut)

[numero]
type = number
title = "Numéro de membre"
help = "Doit être unique, laisser vide pour que le numéro soit attribué automatiquement"
mandatory = false
install = true
editable = false
list_row = 1

[nom]
type = text
title = "Nom & prénom"
mandatory = true
install = true
editable = true
list_row = 2

[email]
; ce champ est facultatif et de type 'email'
type = email
title = "Adresse E-Mail"
mandatory = false
install = true
editable = true

[passe]
; ce champ est obligatoirement présent et de type 'password'
; le titre ne peut être modifié
type = password
mandatory = false
install = true
editable = true

[adresse]
type = textarea
title = "Adresse postale"
help = "Indiquer ici le numéro, le type de voie, etc."
install = true
editable = true

[code_postal]
type = text
title = "Code postal"
install = true
editable = true
list_row = 3

[ville]
type = text
title = "Ville"
install = true
editable = true
list_row = 4

[pays]
type = country
title = "Pays"
install = true
editable = true

[telephone]
type = tel
title = "Numéro de téléphone"
install = true
editable = true

[lettre_infos]
type = checkbox
title = "Inscription à la lettre d'information"
install = true
editable = true

[groupe_travail]
type = multiple
title = "Groupes de travail"
editable = false
options[] = "Télécoms"
options[] = "Trésorerie"
options[] = "Relations publiques"
options[] = "Communication presse"
options[] = "Organisation d'événements"

[date_naissance]
type = date
title = "Date de naissance"
editable = true

[notes]
type = textarea
title = "Notes"
editable = false
private = true

[photo]
type = file
title = "Photo"
editable = false
private = false
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































































































































































































































































































Modified src/include/data/dictionary.fr from [df70718a94] to [d3245db784].

3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
magasin
association
chelou
teuf
ordinateur
tablette
smartphone
garradin
association
asso
comptabilité
compta
gestion







|





3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
magasin
association
chelou
teuf
ordinateur
tablette
smartphone
paheko
association
asso
comptabilité
compta
gestion

Modified src/include/data/schema.sql from [bb4040c594] to [57116110a2].

1
../migrations/1.2/schema.sql
|
1
../migrations/1.3/schema.sql

Added src/include/data/users_fields_presets.ini version [40e7316b2b].





































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
;	Ce fichier contient la configuration par défaut des champs des fiches membres.
;	La configuration est ensuite enregistrée au format INI dans la table 
;	config de la base de données.
;
;	Syntaxe :
;
;	[nom_du_champ] ; Nom unique du champ, ne peut contenir que des lettres et des tirets bas
;	type = text
;	label = "Super champ trop cool"
;	required = true
;
;	Description des options possibles pour chaque champ :
;
;	type: (défaut: text) OBLIGATOIRE
;		certains types gérés par <input type> de HTML5 :
;		text, number, date, datetime, url, email, checkbox, file, password, tel
;		champs spécifiques :
;		- country = sélecteur de pays
;		- textarea = texte multi lignes
;		- multiple = multiples cases à cocher (jusqu'à 32, binaire)
;		- select = un choix parmis plusieurs
;	label: OBLIGATOIRE
;		Titre du champ
;	help:
;		Texte d'aide sur les fiches membres
;	options[]:
;		pour définir les options d'un champ de type select ou multiple
;	required:
;		true = obligatoire, la fiche membre ne pourra être enregistrée si ce champ est vide
;		false = facultatif (défaut)
;	user_access_level:
;		2 = modifiable par le membre
;		1 = visible par le membre (défaut)
;		0 = visible uniquement par un admin
;	management_access_level:
;		9 = visible par les membres ayant accès en administration
;		2 = visible uniquement par les personnes ayant accès en écriture aux membres
;		1 = visible par les personnes ayant accès en lecture aux membres
;	list_table: 'true' si doit être listé par défaut dans la liste des membres
;   sql: SQL code for GENERATED columns
;	depends[]: list of fields that need to be existing in order to install this field

[numero]
type = number
label = "Numéro de membre"
required = true
list_table = true
default = true

[pronom]
type = "select"
label = "Pronom"
required = false
default = false
list_table = true
options[] = "elle"
options[] = "il"
options[] = "iel"
install_help = "Pour identifier la personne par rapport à son genre"

[nom]
type = text
label = "Nom & prénom"
required = true
list_table = true
default = true

[email]
; ce champ est facultatif et de type 'email'
type = email
label = "Adresse E-Mail"
required = false
default = true

[password]
; ce champ est obligatoirement présent et de type 'password'
; le titre ne peut être modifié
label = "Mot de passe"
type = password
required = false
default = true

[adresse]
type = textarea
label = "Adresse postale"
help = "Indiquer ici le numéro, le type de voie, etc."
default = true

[code_postal]
type = text
label = "Code postal"
default = true

[ville]
type = text
label = "Ville"
list_table = true
default = true

[pays]
type = country
label = "Pays"
default = false

[telephone]
type = tel
label = "Numéro de téléphone"
default = true

[lettre_infos]
type = checkbox
label = "Inscription à la lettre d'information"
install_help = "Case à cocher pour indiquer que le membre souhaite recevoir la lettre d'information de l'association"
default = true

[annee_naissance]
type = year
label = "Année de naissance"
install_help = "Recommandé, plutôt que la date de naissance qui est une information très sensible."
default = false

;[age_annee]
;type = generated
;label = "Âge"
;install_help = "Déterminé en utilisant l'année de naissance"
;depends[] = annee_naissance
;default = false
;sql = "strftime('%Y', date('now')) - annee_naissance"

[date_naissance]
type = date
label = "Date de naissance complète"
default = false
install_help = "Attention, cette information est très sensible, il est déconseillé par le RGPD de la demander aux membres. Il est préférable de demander seulement l'année de naissance."

;[age_date]
;type = generated
;label = "Âge"
;install_help = "Déterminé en utilisant la date de naissance"
;depends[] = date_naissance
;default = false
;sql = "CAST(strftime('%Y.%m%d', date('now')) - strftime('%Y.%m%d', date_naissance) as int)"

[photo]
type = file
label = "Photo"
default = false

[date_inscription]
type = date
label = "Date d'inscription"
help = "Date à laquelle le membre a été inscrit à l'association pour la première fois"
default = true
default_value = '=NOW'

;[anciennete]
;type = generated
;label = "Ancienneté"
;install_help = "Nombre d'années depuis la date d'inscription"
;depends[] = date_inscription
;default = false
;sql = "CAST(strftime('%Y.%m%d', date('now')) - strftime('%Y.%m%d', date_inscription) as int)"

Modified src/include/init.php from [df194f0caa] to [7293080d28].

1
2
3
4
5
6
7
8
9
10

11

12



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?php

namespace Garradin;

use KD2\ErrorManager;
use KD2\Security;
use KD2\Form;
use KD2\Translate;
use KD2\DB\EntityManager;


error_reporting(-1);





/*
 * Version de Garradin
 */

function garradin_version()
{
	if (defined('Garradin\VERSION'))
	{
		return VERSION;
	}

	$file = __DIR__ . '/../VERSION';

	if (file_exists($file))
	{
		$version = trim(file_get_contents($file));
	}
	else
	{
		$version = 'unknown';
	}

	define('Garradin\VERSION', $version);
	return $version;
}

function garradin_manifest()
{
	$file = __DIR__ . '/../../manifest.uuid';

	if (@file_exists($file))
	{
		return substr(trim(file_get_contents($file)), 0, 10);
	}

	return false;
}

/**
 * Le code de Garradin ne s'écrit pas tout seul comme par magie,
 * merci de soutenir notre travail en faisant une contribution :)
 */
function garradin_contributor_license(): ?int
{
	static $level = null;

	if (null !== $level) {
		return $level;
	}



|







>
|
>

>
>
>

|

<
|

|















|



|












|


|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php

namespace Paheko;

use KD2\ErrorManager;
use KD2\Security;
use KD2\Form;
use KD2\Translate;
use KD2\DB\EntityManager;

const CONFIG_FILE = 'config.local.php';

require_once __DIR__ . '/lib/KD2/ErrorManager.php';

ErrorManager::enable(ErrorManager::DEVELOPMENT);
ErrorManager::setLogFile(__DIR__ . '/data/error.log');

/*
 * Version de Paheko
 */

function paheko_version()
{
	if (defined('Paheko\VERSION'))
	{
		return VERSION;
	}

	$file = __DIR__ . '/../VERSION';

	if (file_exists($file))
	{
		$version = trim(file_get_contents($file));
	}
	else
	{
		$version = 'unknown';
	}

	define('Paheko\VERSION', $version);
	return $version;
}

function paheko_manifest()
{
	$file = __DIR__ . '/../../manifest.uuid';

	if (@file_exists($file))
	{
		return substr(trim(file_get_contents($file)), 0, 10);
	}

	return false;
}

/**
 * Le code de Paheko ne s'écrit pas tout seul comme par magie,
 * merci de soutenir notre travail en faisant une contribution :)
 */
function paheko_contributor_license(): ?int
{
	static $level = null;

	if (null !== $level) {
		return $level;
	}

86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115







116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186

187
188
189
190
191
192
193
194
195
196

197
198
199

200
201
202
203
204

205
206

207
208
209
210
211
212
213
214
215
216


217
218
219
220

221

222
223
224



225

226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260








261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307







308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349




350
351
352
353
354
355


356







357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385






386
387
388
389
390
391
392
393


394
395
396
397
398
399
400
401
402
403




404
405
406
407
}

/*
 * Configuration globale
 */

// Configuration externalisée
if (file_exists(__DIR__ . '/../config.local.php'))
{
	require __DIR__ . '/../config.local.php';
}

// Configuration par défaut, si les constantes ne sont pas définies dans config.local.php
// (fallback)
if (!defined('Garradin\ROOT'))
{
	define('Garradin\ROOT', dirname(__DIR__));
}

\spl_autoload_register(function (string $classname) {
	$classname = ltrim($classname, '\\');

	// Plugins
	if (substr($classname, 0, 16) == 'Garradin\\Plugin\\')
	{
		$classname = substr($classname, 16);
		$plugin_name = substr($classname, 0, strpos($classname, '\\'));
		$filename = str_replace('\\', '/', substr($classname, strpos($classname, '\\')+1));

		$path = Plugin::getPath(strtolower($plugin_name)) . '/lib/' . $filename . '.php';







	}
	else
	{
		// PSR-0 autoload
		$filename = str_replace('\\', '/', $classname);
		$path = ROOT . '/include/lib/' . $filename . '.php';
	}

	if (file_exists($path)) {
		require_once $path;
	}
}, true);

if (!defined('Garradin\DATA_ROOT')) {
	// Migrate plugins, cache and SQLite to data/ subdirectory (version 1.1)
	if (!file_exists(ROOT . '/data/association.sqlite') && file_exists(ROOT . '/association.sqlite')) {
		Upgrade::moveDataRoot();
	}

	define('Garradin\DATA_ROOT', ROOT . '/data');
}

if (!defined('Garradin\WWW_URI'))
{
	try {
		$uri = \KD2\HTTP::getRootURI(ROOT);
	}
	catch (\UnexpectedValueException $e) {
		$uri = null;
	}

	if ($uri == '/www/') {
		$uri = '/';
	}
	elseif ($uri !== null) {
		readfile(ROOT . '/sous-domaine.html');
		exit;
	}

	define('Garradin\WWW_URI', $uri);
	unset($uri);
}

$host = null;

if (!defined('Garradin\WWW_URL')) {
	$host = \KD2\HTTP::getHost();
}

if (WWW_URI === null || (!empty($host) && $host == 'host.unknown')) {
	$title = 'Impossible de détecter automatiquement l\'URL du site web.';
	$info = 'Consulter l\'aide pour configurer manuellement l\'URL avec la directive WWW_URL et WWW_URI.';
	$url ='https://fossil.kd2.org/paheko/wiki?name=Installation';

	if (PHP_SAPI == 'cli') {
		printf("\n/!\\ %s\n%s\n-> %s\n\n", $title, $info, $url);
	}
	else {
		printf('<h2 style="color: red">%s</h2><p><a href="%s">%s</a></p>', $title, $url, $info);
	}

	exit(1);
}

if (!defined('Garradin\WWW_URL') && $host !== null) {
	define('Garradin\WWW_URL', \KD2\HTTP::getScheme() . '://' . $host . WWW_URI);
}

static $default_config = [
	'CACHE_ROOT'            => DATA_ROOT . '/cache',
	'SHARED_CACHE_ROOT'     => DATA_ROOT . '/cache/shared',

	'DB_FILE'               => DATA_ROOT . '/association.sqlite',
	'DB_SCHEMA'             => ROOT . '/include/data/schema.sql',
	'PLUGINS_ROOT'          => DATA_ROOT . '/plugins',
	'PREFER_HTTPS'          => false,
	'ALLOW_MODIFIED_IMPORT' => true,
	'SHOW_ERRORS'           => true,
	'MAIL_ERRORS'           => false,
	'ERRORS_REPORT_URL'     => null,
	'REPORT_USER_EXCEPTIONS' => 0,
	'ENABLE_TECH_DETAILS'   => true,

	'ENABLE_UPGRADES'       => true,
	'USE_CRON'              => false,
	'ENABLE_XSENDFILE'      => false,

	'SMTP_HOST'             => false,
	'SMTP_USER'             => null,
	'SMTP_PASSWORD'         => null,
	'SMTP_PORT'             => 587,
	'SMTP_SECURITY'         => 'STARTTLS',

	'MAIL_RETURN_PATH'      => null,
	'MAIL_BOUNCE_PASSWORD'  => null,

	'ADMIN_URL'             => WWW_URL . 'admin/',
	'NTP_SERVER'            => 'fr.pool.ntp.org',
	'ADMIN_COLOR1'          => '#20787a',
	'ADMIN_COLOR2'          => '#85b9ba',
	'ADMIN_BACKGROUND_IMAGE' => WWW_URL . 'admin/static/bg.png',
	'FORCE_CUSTOM_COLORS'   => false,
	'DISABLE_INSTALL_FORM'  => false,
	'FILE_STORAGE_BACKEND'  => 'SQLite',
	'FILE_STORAGE_CONFIG'   => null,
	'FILE_STORAGE_QUOTA'    => null,


	'API_USER'              => null,
	'API_PASSWORD'          => null,
	'PDF_COMMAND'           => 'auto',
	'PDF_USAGE_LOG'         => null,

	'CALC_CONVERT_COMMAND'  => null,

	'CONTRIBUTOR_LICENSE'   => null,
	'SQL_DEBUG'             => null,
	'SYSTEM_SIGNALS'        => [],



	'DISABLE_INSTALL_PING'  => false,

	'SQLITE_JOURNAL_MODE'   => 'TRUNCATE',
];

foreach ($default_config as $const => $value)
{
	$const = sprintf('Garradin\\%s', $const);

	if (!defined($const))
	{
		define($const, $value);
	}
}

// Check SMTP_SECURITY value
if (SMTP_SECURITY) {
	$const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);

	if (!defined($const)) {
		throw new \LogicException('Configuration: SMTP_SECURITY n\'a pas une valeur reconnue. Valeurs acceptées: STARTTLS, TLS, SSL, NONE.');
	}
}

// Used for private files, just in case WWW_URL is not the same domain as ADMIN_URL
define('Garradin\BASE_URL', str_replace('/admin/', '/', ADMIN_URL));

const HELP_URL = 'https://paheko.cloud/aide?from=%s';
const HELP_PATTERN_URL = 'https://paheko.cloud/%s';
const WEBSITE = 'https://fossil.kd2.org/paheko/';
const PING_URL = 'https://paheko.cloud/ping/';
const PLUGINS_URL = 'https://paheko.cloud/plugins/list.json';

const USER_TEMPLATES_CACHE_ROOT = CACHE_ROOT . '/utemplates';
const STATIC_CACHE_ROOT = CACHE_ROOT . '/static';
const SHARED_USER_TEMPLATES_CACHE_ROOT = SHARED_CACHE_ROOT . '/utemplates';
const SMARTYER_CACHE_ROOT = SHARED_CACHE_ROOT . '/compiled';









// PHP devrait être assez intelligent pour chopper la TZ système mais nan
// il sait pas faire (sauf sur Debian qui a le bon patch pour ça), donc pour
// éviter le message d'erreur à la con on définit une timezone par défaut
// Pour utiliser une autre timezone, il suffit de définir date.timezone dans
// un .htaccess ou dans config.local.php
if (!ini_get('date.timezone'))
{
	if (($tz = @date_default_timezone_get()) && $tz != 'UTC')
	{
		ini_set('date.timezone', $tz);
	}
	else
	{
		ini_set('date.timezone', 'Europe/Paris');
	}
}

class ValidationException extends UserException
{
}

class APIException extends \LogicException
{
}

// activer le gestionnaire d'erreurs/exceptions
ErrorManager::enable(SHOW_ERRORS ? ErrorManager::DEVELOPMENT : ErrorManager::PRODUCTION);
ErrorManager::setLogFile(DATA_ROOT . '/error.log');

// activer l'envoi de mails si besoin est
if (MAIL_ERRORS)
{
	ErrorManager::setEmail(MAIL_ERRORS);
}

ErrorManager::setContext([
	'root_directory'      => ROOT,
	'garradin_data_root' => DATA_ROOT,
	'garradin_version'   => garradin_version(),
]);

if (ERRORS_REPORT_URL)
{
	ErrorManager::setRemoteReporting(ERRORS_REPORT_URL, true);
}








ErrorManager::setProductionErrorTemplate(defined('Garradin\ERRORS_TEMPLATE') && ERRORS_TEMPLATE ? ERRORS_TEMPLATE : '<!DOCTYPE html><html><head><title>Erreur interne</title>
	<style type="text/css">
	body {font-family: sans-serif; }
	code, p, h1 { max-width: 400px; margin: 1em auto; display: block; }
	code { text-align: right; color: #666; }
	a { color: blue; }
	form { text-align: center; }
	</style></head><body><h1>Erreur interne</h1><p>Désolé mais le serveur a rencontré une erreur interne
	et ne peut répondre à votre requête. Merci de ré-essayer plus tard.</p>
	<p>Si vous suspectez un bug dans Paheko, vous pouvez suivre
	<a href="https://fossil.kd2.org/paheko/wiki?name=Rapporter+un+bug&p">ces instructions</a>
	pour le rapporter.</p>
	<if(sent)><p>Un-e responsable a été notifié-e et cette erreur sera corrigée dès que possible.</p></if>
	<if(logged)><code>L\'erreur a été enregistrée dans les journaux système (error.log) sous la référence : <b>{$ref}</b></code></if>
	<p><a href="' . WWW_URL . '">&larr; Retour à la page d\'accueil</a></p>
	</body></html>');

ErrorManager::setHtmlHeader('<!DOCTYPE html><meta charset="utf-8" /><style type="text/css">
	body { font-family: sans-serif; } * { margin: 0; padding: 0; }
	u, code b, i, h3 { font-style: normal; font-weight: normal; text-decoration: none; }
	#icn { color: #fff; font-size: 2em; float: right; margin: 1em; padding: 1em; background: #900; border-radius: 50%; }
	section header { background: #fdd; padding: 1em; }
	section article { margin: 1em; }
	section article h3, section article h4 { font-size: 1em; font-family: mono; }
	code { border: 1px dotted #ccc; display: block; }
	code b { margin-right: 1em; color: #999; }
	code u { background: #fcc; display: inline-block; width: 100%; }
	table { border-collapse: collapse; margin: 1em; } td, th { border: 1px solid #ccc; padding: .2em .5em; text-align: left; 
	vertical-align: top; }
	input { padding: .3em; margin: .5em; font-size: 1.2em; cursor: pointer; }
</style>
<pre id="icn"> \__/<br /> (xx)<br />//||\\\\</pre>
<section>
	<article>
	<h1>Une erreur s\'est produite</h1>
	<if(report)><form method="post" action="{$report_url}"><p><input type="hidden" name="report" value="{$report_json}" /><input type="submit" value="Rapporter l\'erreur aux développeur⋅euses de Paheko &rarr;" /></p></form></if>
	</article>
</section>
');

function user_error(UserException $e)
{




	if (PHP_SAPI == 'cli')
	{
		echo $e->getMessage();
	}
	else
	{


		$tpl = Template::getInstance();








		$tpl->assign('error', $e->getMessage());
		$tpl->assign('html_error', $e->getHTMLMessage());
		$tpl->assign('admin_url', ADMIN_URL);
		$tpl->display('error.tpl');
	}

	exit;
}

if (REPORT_USER_EXCEPTIONS < 2) {
	// Message d'erreur simple pour les erreurs de l'utilisateur
	ErrorManager::setCustomExceptionHandler('\Garradin\UserException', '\Garradin\user_error');
}

// Clé secrète utilisée pour chiffrer les tokens CSRF etc.
if (!defined('Garradin\SECRET_KEY'))
{
	$key = base64_encode(random_bytes(64));
	Install::setLocalConfig('SECRET_KEY', $key);
	define('Garradin\SECRET_KEY', $key);
}

// Intégration du secret pour les tokens CSRF
Form::tokenSetSecret(SECRET_KEY);

EntityManager::setGlobalDB(DB::getInstance());

Translate::setLocale('fr_FR');







/*
 * Vérifications pour enclencher le processus d'installation ou de mise à jour
 */

if (!defined('Garradin\INSTALL_PROCESS') && !defined('Garradin\UPGRADE_PROCESS'))
{
	if (!file_exists(DB_FILE)) {


		if (in_array('install.php', get_included_files())) {
			die('Erreur de redirection en boucle : problème de configuration ?');
		}

		Utils::redirect(ADMIN_URL . 'install.php');
	}

	$v = DB::getInstance()->version();

	if (version_compare($v, garradin_version(), '<'))




	{
		Utils::redirect(ADMIN_URL . 'upgrade.php');
	}
}







|
<
|


|

|

|


|



|

|



|
>
>
>
>
>
>
>













|
<
<
<
<
<
|


|
















|





|


















|
|





>



<






>



>





>


>










>
>




>

>



>
>
>

>





|

















|











>
>
>
>
>
>
>
>





|
|
<
|
<


|
<













|



|
<



<
<
<
<
<
<
|
<



>
>
>
>
>
>
>
|

|














|
|











|











>
>
>
>






>
>
|
>
>
>
>
>
>
>




|







|



|



|








>
>
>
>
>
>





|

|
>
>









|
>
>
>
>
|



90
91
92
93
94
95
96
97

98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139





140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195

196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292

293

294
295
296

297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314

315
316
317






318

319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
}

/*
 * Configuration globale
 */

// Configuration externalisée
if (file_exists(__DIR__ . '/../' . CONFIG_FILE)) {

	require __DIR__ . '/../' . CONFIG_FILE;
}

// Configuration par défaut, si les constantes ne sont pas définies dans CONFIG_FILE
// (fallback)
if (!defined('Paheko\ROOT'))
{
	define('Paheko\ROOT', dirname(__DIR__));
}

\spl_autoload_register(function (string $classname): void {
	$classname = ltrim($classname, '\\');

	// Plugins
	if (substr($classname, 0, 14) == 'Paheko\\Plugin\\')
	{
		$classname = substr($classname, 14);
		$plugin_name = substr($classname, 0, strpos($classname, '\\'));
		$filename = str_replace('\\', '/', substr($classname, strpos($classname, '\\')+1));

		$path = Plugins::getPath(strtolower($plugin_name));

		// Plugin does not exist, just abort
		if (!$path) {
			return;
		}

		$path = $path . '/lib/' . $filename . '.php';
	}
	else
	{
		// PSR-0 autoload
		$filename = str_replace('\\', '/', $classname);
		$path = ROOT . '/include/lib/' . $filename . '.php';
	}

	if (file_exists($path)) {
		require_once $path;
	}
}, true);

if (!defined('Paheko\DATA_ROOT')) {





	define('Paheko\DATA_ROOT', ROOT . '/data');
}

if (!defined('Paheko\WWW_URI'))
{
	try {
		$uri = \KD2\HTTP::getRootURI(ROOT);
	}
	catch (\UnexpectedValueException $e) {
		$uri = null;
	}

	if ($uri == '/www/') {
		$uri = '/';
	}
	elseif ($uri !== null) {
		readfile(ROOT . '/sous-domaine.html');
		exit;
	}

	define('Paheko\WWW_URI', $uri);
	unset($uri);
}

$host = null;

if (!defined('Paheko\WWW_URL')) {
	$host = \KD2\HTTP::getHost();
}

if (WWW_URI === null || (!empty($host) && $host == 'host.unknown')) {
	$title = 'Impossible de détecter automatiquement l\'URL du site web.';
	$info = 'Consulter l\'aide pour configurer manuellement l\'URL avec la directive WWW_URL et WWW_URI.';
	$url ='https://fossil.kd2.org/paheko/wiki?name=Installation';

	if (PHP_SAPI == 'cli') {
		printf("\n/!\\ %s\n%s\n-> %s\n\n", $title, $info, $url);
	}
	else {
		printf('<h2 style="color: red">%s</h2><p><a href="%s">%s</a></p>', $title, $url, $info);
	}

	exit(1);
}

if (!defined('Paheko\WWW_URL') && $host !== null) {
	define('Paheko\WWW_URL', \KD2\HTTP::getScheme() . '://' . $host . WWW_URI);
}

static $default_config = [
	'CACHE_ROOT'            => DATA_ROOT . '/cache',
	'SHARED_CACHE_ROOT'     => DATA_ROOT . '/cache/shared',
	'WEB_CACHE_ROOT'        => DATA_ROOT . '/cache/web/%host%',
	'DB_FILE'               => DATA_ROOT . '/association.sqlite',
	'DB_SCHEMA'             => ROOT . '/include/data/schema.sql',
	'PLUGINS_ROOT'          => DATA_ROOT . '/plugins',

	'ALLOW_MODIFIED_IMPORT' => true,
	'SHOW_ERRORS'           => true,
	'MAIL_ERRORS'           => false,
	'ERRORS_REPORT_URL'     => null,
	'REPORT_USER_EXCEPTIONS' => 0,
	'ENABLE_TECH_DETAILS'   => true,
	'HTTP_LOG_FILE'         => null,
	'ENABLE_UPGRADES'       => true,
	'USE_CRON'              => false,
	'ENABLE_XSENDFILE'      => false,
	'DISABLE_EMAIL'         => false,
	'SMTP_HOST'             => false,
	'SMTP_USER'             => null,
	'SMTP_PASSWORD'         => null,
	'SMTP_PORT'             => 587,
	'SMTP_SECURITY'         => 'STARTTLS',
	'SMTP_HELO_HOSTNAME'    => null,
	'MAIL_RETURN_PATH'      => null,
	'MAIL_BOUNCE_PASSWORD'  => null,
	'MAIL_SENDER'           => null,
	'ADMIN_URL'             => WWW_URL . 'admin/',
	'NTP_SERVER'            => 'fr.pool.ntp.org',
	'ADMIN_COLOR1'          => '#20787a',
	'ADMIN_COLOR2'          => '#85b9ba',
	'ADMIN_BACKGROUND_IMAGE' => WWW_URL . 'admin/static/bg.png',
	'FORCE_CUSTOM_COLORS'   => false,
	'DISABLE_INSTALL_FORM'  => false,
	'FILE_STORAGE_BACKEND'  => 'SQLite',
	'FILE_STORAGE_CONFIG'   => null,
	'FILE_STORAGE_QUOTA'    => null,
	'FILE_VERSIONING_POLICY'   => null,
	'FILE_VERSIONING_MAX_SIZE' => null,
	'API_USER'              => null,
	'API_PASSWORD'          => null,
	'PDF_COMMAND'           => 'auto',
	'PDF_USAGE_LOG'         => null,
	'PDFTOTEXT_COMMAND'     => null,
	'CALC_CONVERT_COMMAND'  => null,
	'DOCUMENT_THUMBNAIL_COMMANDS' => null,
	'CONTRIBUTOR_LICENSE'   => null,
	'SQL_DEBUG'             => null,
	'SYSTEM_SIGNALS'        => [],
	'LOCAL_LOGIN'           => null,
	'LEGAL_HOSTING_DETAILS' => null,
	'ALERT_MESSAGE'         => null,
	'DISABLE_INSTALL_PING'  => false,
	'WOPI_DISCOVERY_URL'    => null,
	'SQLITE_JOURNAL_MODE'   => 'TRUNCATE',
];

foreach ($default_config as $const => $value)
{
	$const = sprintf('Paheko\\%s', $const);

	if (!defined($const))
	{
		define($const, $value);
	}
}

// Check SMTP_SECURITY value
if (SMTP_SECURITY) {
	$const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);

	if (!defined($const)) {
		throw new \LogicException('Configuration: SMTP_SECURITY n\'a pas une valeur reconnue. Valeurs acceptées: STARTTLS, TLS, SSL, NONE.');
	}
}

// Used for private files, just in case WWW_URL is not the same domain as ADMIN_URL
define('Paheko\BASE_URL', str_replace('/admin/', '/', ADMIN_URL));

const HELP_URL = 'https://paheko.cloud/aide?from=%s';
const HELP_PATTERN_URL = 'https://paheko.cloud/%s';
const WEBSITE = 'https://fossil.kd2.org/paheko/';
const PING_URL = 'https://paheko.cloud/ping/';
const PLUGINS_URL = 'https://paheko.cloud/plugins/list.json';

const USER_TEMPLATES_CACHE_ROOT = CACHE_ROOT . '/utemplates';
const STATIC_CACHE_ROOT = CACHE_ROOT . '/static';
const SHARED_USER_TEMPLATES_CACHE_ROOT = SHARED_CACHE_ROOT . '/utemplates';
const SMARTYER_CACHE_ROOT = SHARED_CACHE_ROOT . '/compiled';

// Used to get around some providers misconfiguration issues
if (isset($_SERVER['HTTP_X_OVHREQUEST_ID'])) {
	define('Paheko\HOSTING_PROVIDER', 'OVH');
}
else {
	define('Paheko\HOSTING_PROVIDER', null);
}

// PHP devrait être assez intelligent pour chopper la TZ système mais nan
// il sait pas faire (sauf sur Debian qui a le bon patch pour ça), donc pour
// éviter le message d'erreur à la con on définit une timezone par défaut
// Pour utiliser une autre timezone, il suffit de définir date.timezone dans
// un .htaccess ou dans CONFIG_FILE
if (!ini_get('date.timezone') || ini_get('date.timezone') === 'UTC') {

	if (($tz = @date_default_timezone_get()) && $tz !== 'UTC') {

		ini_set('date.timezone', $tz);
	}
	else {

		ini_set('date.timezone', 'Europe/Paris');
	}
}

class ValidationException extends UserException
{
}

class APIException extends \LogicException
{
}

// activer le gestionnaire d'erreurs/exceptions
ErrorManager::setEnvironment(SHOW_ERRORS ? ErrorManager::DEVELOPMENT : ErrorManager::PRODUCTION | ErrorManager::CLI_DEVELOPMENT);
ErrorManager::setLogFile(DATA_ROOT . '/error.log');

// activer l'envoi de mails si besoin est
if (MAIL_ERRORS) {

	ErrorManager::setEmail(MAIL_ERRORS);
}







if (ERRORS_REPORT_URL) {

	ErrorManager::setRemoteReporting(ERRORS_REPORT_URL, true);
}

ErrorManager::setContext([
	'root_directory'   => ROOT,
	'paheko_data_root' => DATA_ROOT,
	'paheko_version'   => paheko_version(),
]);


ErrorManager::setProductionErrorTemplate(defined('Paheko\ERRORS_TEMPLATE') && ERRORS_TEMPLATE ? ERRORS_TEMPLATE : '<!DOCTYPE html><html><head><title>Erreur interne</title>
	<style type="text/css">
	body {font-family: sans-serif; background: #fff; }
	code, p, h1 { max-width: 400px; margin: 1em auto; display: block; }
	code { text-align: right; color: #666; }
	a { color: blue; }
	form { text-align: center; }
	</style></head><body><h1>Erreur interne</h1><p>Désolé mais le serveur a rencontré une erreur interne
	et ne peut répondre à votre requête. Merci de ré-essayer plus tard.</p>
	<p>Si vous suspectez un bug dans Paheko, vous pouvez suivre
	<a href="https://fossil.kd2.org/paheko/wiki?name=Rapporter+un+bug&p">ces instructions</a>
	pour le rapporter.</p>
	<if(sent)><p>Un-e responsable a été notifié-e et cette erreur sera corrigée dès que possible.</p></if>
	<if(logged)><code>L\'erreur a été enregistrée dans les journaux système (error.log) sous la référence : <b>{$ref}</b></code></if>
	<p><a href="' . WWW_URL . '">&larr; Retour à la page d\'accueil</a></p>
	</body></html>');

ErrorManager::setHtmlHeader('<!DOCTYPE html><html><head><meta charset="utf-8" /><style type="text/css">
	body { font-family: sans-serif; background: #fff; } * { margin: 0; padding: 0; }
	u, code b, i, h3 { font-style: normal; font-weight: normal; text-decoration: none; }
	#icn { color: #fff; font-size: 2em; float: right; margin: 1em; padding: 1em; background: #900; border-radius: 50%; }
	section header { background: #fdd; padding: 1em; }
	section article { margin: 1em; }
	section article h3, section article h4 { font-size: 1em; font-family: mono; }
	code { border: 1px dotted #ccc; display: block; }
	code b { margin-right: 1em; color: #999; }
	code u { background: #fcc; display: inline-block; width: 100%; }
	table { border-collapse: collapse; margin: 1em; } td, th { border: 1px solid #ccc; padding: .2em .5em; text-align: left; 
	vertical-align: top; }
	input { padding: .3em; margin: .5em; font-size: 1.2em; cursor: pointer; }
</style></head><body>
<pre id="icn"> \__/<br /> (xx)<br />//||\\\\</pre>
<section>
	<article>
	<h1>Une erreur s\'est produite</h1>
	<if(report)><form method="post" action="{$report_url}"><p><input type="hidden" name="report" value="{$report_json}" /><input type="submit" value="Rapporter l\'erreur aux développeur⋅euses de Paheko &rarr;" /></p></form></if>
	</article>
</section>
');

function user_error(UserException $e)
{
	if (REPORT_USER_EXCEPTIONS > 0) {
		\Paheko\Form::reportUserException($e);
	}

	if (PHP_SAPI == 'cli')
	{
		echo $e->getMessage();
	}
	else
	{
		// Flush any previous output, such as module HTML code etc.
		@ob_end_clean();

		if ($e->getCode() >= 400) {
			http_response_code($e->getCode());
		}

		// Don't use Template class as there might be an error there due do the context (eg. install/upgrade)
		$tpl = new \KD2\Smartyer(ROOT . '/templates/error.tpl');
		$tpl->setCompiledDir(SMARTYER_CACHE_ROOT);

		$tpl->assign('error', $e->getMessage());
		$tpl->assign('html_error', $e->getHTMLMessage());
		$tpl->assign('admin_url', ADMIN_URL);
		$tpl->display();
	}

	exit;
}

if (REPORT_USER_EXCEPTIONS < 2) {
	// Message d'erreur simple pour les erreurs de l'utilisateur
	ErrorManager::setCustomExceptionHandler('\Paheko\UserException', '\Paheko\user_error');
}

// Clé secrète utilisée pour chiffrer les tokens CSRF etc.
if (!defined('Paheko\SECRET_KEY'))
{
	$key = base64_encode(random_bytes(64));
	Install::setLocalConfig('SECRET_KEY', $key);
	define('Paheko\SECRET_KEY', $key);
}

// Intégration du secret pour les tokens CSRF
Form::tokenSetSecret(SECRET_KEY);

EntityManager::setGlobalDB(DB::getInstance());

Translate::setLocale('fr_FR');

// This is specific to OVH and other hosting providers who don't set up their servers properly
// see https://www.prestashop.com/forums/topic/393496-prestashop-16-webservice-authentification-on-ovh/
if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) && !empty($_SERVER['HTTP_AUTHORIZATION'])) {
	@list($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) = explode(':', base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6)));
}

/*
 * Vérifications pour enclencher le processus d'installation ou de mise à jour
 */

if (!defined('Paheko\INSTALL_PROCESS'))
{
	$exists = file_exists(DB_FILE);

	if (!$exists) {
		if (in_array('install.php', get_included_files())) {
			die('Erreur de redirection en boucle : problème de configuration ?');
		}

		Utils::redirect(ADMIN_URL . 'install.php');
	}

	$v = DB::getInstance()->version();

	if (version_compare($v, paheko_version(), '<')) {
		if (!empty($_POST)) {
			readfile(ROOT . '/templates/static/upgrade_post.html');
			exit;
		}

		Utils::redirect(ADMIN_URL . 'upgrade.php');
	}
}

Deleted src/include/lib/Garradin/API.php version [15a246b4fb].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
<?php

namespace Garradin;

use Garradin\Membres\Session;
use Garradin\Web\Web;
use Garradin\Accounting\Accounts;
use Garradin\Accounting\Charts;
use Garradin\Accounting\Reports;
use Garradin\Accounting\Transactions;
use Garradin\Accounting\Years;
use Garradin\Entities\Accounting\Transaction;

use KD2\ErrorManager;

class API
{
	protected $body;
	protected $params;
	protected $method;
	protected int $access;

	protected function requireAccess(int $level)
	{
		if ($this->access < $level) {
			throw new APIException('You do not have enough rights to make this request', 403);
		}
	}

	protected function body(): string
	{
		if (null == $this->body) {
			$this->body = trim(file_get_contents('php://input'));
		}

		return $this->body;
	}

	protected function hasParam(string $param): bool
	{
		return array_key_exists($param, $_GET);
	}

	protected function download()
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

		(new Sauvegarde)->dump();
		return null;
	}

	protected function sql()
	{
		if ($this->method != 'POST') {
			throw new APIException('Wrong request method', 400);
		}

		$body = $this->body();

		if ($body === '') {
			throw new APIException('Missing SQL statement', 400);
		}

		try {
			$r = Recherche::rawSQL($body);

			if (isset($_GET['format']) && in_array($_GET['format'], ['xlsx', 'ods', 'csv'])) {
				CSV::export($_GET['format'], 'sql', $r);
				return null;
			}
			else {
				return ['results' => $r];
			}
		}
		catch (\Exception $e) {
			http_response_code(400);
			return ['error' => 'Error in SQL statement', 'sql_error' => $e->getMessage()];
		}
	}

	protected function user(string $uri): ?array
	{
		$fn = strtok($uri, '/');

		// CSV import
		if ($fn == 'import') {
			if ($this->method != 'PUT') {
				throw new APIException('Wrong request method', 400);
			}

			$mode = $_GET['mode'] ?? 'auto';

			if (!in_array($mode, ['auto', 'create', 'update'])) {
				throw new APIException('Unknown mode. Only "auto", "create" and "update" are accepted.');
			}

			$this->requireAccess(Session::ACCESS_ADMIN);

			$admin_user_id = 1; // FIXME: should be NULL here

			$file = tempnam(CACHE_ROOT, 'tmp-import-api');

			try {
				$stdin = fopen('php://input', 'r');
				$fp = fopen($file, 'w');
				stream_copy_to_stream($stdin, $fp);
				fclose($fp);
				fclose($stdin);

				if (!filesize($file)) {
					throw new APIException('Empty CSV file', 400);
				}

				$import = new Membres\Import;
				$import->fromGarradinCSV($file, $admin_user_id, $mode);
			}
			finally {
				Utils::safe_unlink($file);
			}

			return null;
		}
		else {
			throw new APIException('Unknown user action', 404);
		}
	}

	protected function web(string $uri): ?array
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

		$fn = strtok($uri, '/');
		$param = strtok('');

		switch ($fn) {
			case 'list':
				return [
					'categories' => array_map(fn($p) => $p->asArray(true), Web::listCategories($param)),
					'pages' => array_map(fn($p) => $p->asArray(true), Web::listPages($param)),
				];
			case 'attachment':
				$attachment = Web::getAttachmentFromURI($param);

				if (!$attachment) {
					throw new APIException('Page not found', 404);
				}

				$attachment->serve();
				return null;
			case 'html':
			case 'page':
				$page = Web::getByURI($param);

				if (!$page) {
					throw new APIException('Page not found', 404);
				}

				if ($fn == 'page') {
					$out = $page->asArray(true);

					if ($this->hasParam('html')) {
						$out['html'] = $page->render();
					}

					return $out;
				}

				// HTML render
				echo $page->render();
				return null;
			default:
				throw new APIException('Unknown web action', 404);
		}
	}

	protected function accounting(string $uri): ?array
	{
		$fn = strtok($uri, '/');
		$p1 = strtok('/');
		$p2 = strtok(false);

		if ($fn == 'transaction') {
			if (!$p1) {
				if ($this->method != 'POST') {
					throw new APIException('Wrong request method', 400);
				}

				$this->requireAccess(Session::ACCESS_WRITE);
				$transaction = new Transaction;
				$transaction->importFromAPI();
				$transaction->save();
				return $transaction->asJournalArray();
			}
			// Return or edit linked users
			elseif (ctype_digit($p1) && $p2 == 'users') {
				$transaction = Transactions::get((int)$p1);

				if (!$transaction) {
					throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
				}

				if ($this->method === 'POST') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateLinkedUsers((array)($_POST['users'] ?? null));
					return ['success' => true];
				}
				elseif ($this->method === 'DELETE') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateLinkedUsers([]);
					return ['success' => true];
				}
				elseif ($this->method === 'GET') {
					return $transaction->listLinkedUsers();
				}
				else {
					throw new APIException('Wrong request method', 400);
				}
			}
			elseif (ctype_digit($p1) && !$p2) {
				$transaction = Transactions::get((int)$p1);

				if (!$transaction) {
					throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
				}

				if ($this->method == 'GET') {
					return $transaction->asJournalArray();
				}
				elseif ($this->method == 'POST') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->importFromNewForm();
					$transaction->save();
					return $transaction->asJournalArray();
				}
				else {
					throw new APIException('Wrong request method', 400);
				}
			}
			else {
				throw new APIException('Unknown transactions route', 404);
			}
		}
		elseif ($fn == 'years') {
			if ($this->method != 'GET') {
				throw new APIException('Wrong request method', 400);
			}

			if (($p1 === 'current' || ctype_digit($p1)) && ($p2 === 'journal' || $p2 === 'account/journal')) {
				if ($p1 === 'current') {
					$id_year = Years::getCurrentOpenYearId();

					if (!$id_year) {
						throw new APIException('There are no currently open years', 404);
					}
				}
				else {
					$id_year = (int)$match[1];
				}

				if ($p2 == 'journal') {
					try {
						return iterator_to_array(Reports::getJournal(['year' => $id_year]));
					}
					catch (\LogicException $e) {
						throw new APIException('Missing parameter for journal: ' . $e->getMessage(), 400, $e);
					}
				}
				else {
					$year = Years::get($id_year);

					if (!$year) {
						throw new APIException('Invalid year.', 400, $e);
					}

					$a = $year->chart()->accounts();

					if (!empty($_GET['code'])) {
						$account = $a->getWithCode($_GET['code']);
					}
					else {
						$account = $a->get((int)$_GET['code'] ?? null);
					}

					if (!$account) {
						throw new APIException('Unknown account id or code.', 400, $e);
					}

					$list = $account->listJournal($year->id, false);
					$list->setTitle(sprintf('Journal - %s - %s', $account->code, $account->label));
					$list->loadFromQueryString();
					$list->setPageSize(null);
					$list->orderBy('date', false);
					return iterator_to_array($list->iterate());
				}
			}
			elseif (!$p1 && !$p2) {
				return Years::list();
			}
			else {
				throw new APIException('Unknown years action', 404);
			}
		}
		elseif ($fn == 'charts') {
			if ($this->method != 'GET') {
				throw new APIException('Wrong request method', 400);
			}

			if (ctype_digit($p1) && $p2 === 'accounts') {
				$a = new Accounts((int)$p1);
				return array_map(fn($c) => $c->asArray(), $a->listAll());
			}
			elseif (!$p1 && !$p2) {
				return array_map(fn($c) => $c->asArray(), Charts::list());
			}
			else {
				throw new APIException('Unknown charts action', 404);
			}
		}
		else {
			throw new APIException('Unknown accounting action', 404);
		}
	}

	public function errors(string $uri)
	{
		$fn = strtok($uri, '/');

		if (!ini_get('error_log')) {
			throw new APIException('The error log is disabled', 404);
		}

		if (!ENABLE_TECH_DETAILS) {
			throw new APIException('Access to error log is disabled.', 403);
		}

		if ($uri == 'report') {
			if ($this->method != 'POST') {
				throw new APIException('Wrong request method', 400);
			}

			$this->requireAccess(Session::ACCESS_ADMIN);

			$body = $this->body();
			$report = json_decode($body);

			if (!isset($report->context->id)) {
				throw new APIException('Invalid JSON body', 400);
			}

			$log = sprintf('=========== Error ref. %s ===========', $report->context->id)
				. PHP_EOL . PHP_EOL . "Report from API" . PHP_EOL . PHP_EOL
				. '<errorReport>' . PHP_EOL . json_encode($report, \JSON_PRETTY_PRINT)
				. PHP_EOL . '</errorReport>' . PHP_EOL;

			error_log($log);

			return null;
		}
		elseif ($uri == 'log') {
			if ($this->method != 'GET') {
				throw new APIException('Wrong request method', 400);
			}

			return ErrorManager::getReportsFromLog(null, null);
		}
		else {
			throw new APIException('Unknown errors action', 404);
		}
	}

	public function checkAuth(): void
	{
		if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
			throw new APIException('No username or password supplied', 401);
		}

		if (API_USER && API_PASSWORD && $_SERVER['PHP_AUTH_USER'] === API_USER && $_SERVER['PHP_AUTH_PW'] === API_PASSWORD) {
			$this->access = Session::ACCESS_ADMIN;
		}
		elseif ($c = API_Credentials::login($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
			$this->access = $c->access_level;
		}
		else {
			throw new APIException('Invalid username or password', 403);
		}
	}

	public function dispatch(string $fn, string $uri)
	{
		$this->checkAuth();

		switch ($fn) {
			case 'sql':
				return $this->sql();
			case 'download':
				return $this->download();
			case 'web':
				return $this->web($uri);
			case 'user':
				return $this->user($uri);
			case 'errors':
				return $this->errors($uri);
			case 'accounting':
				return $this->accounting($uri);
			default:
				throw new APIException('Unknown path', 404);
		}
	}

	static public function dispatchURI(string $uri)
	{
		$fn = strtok($uri, '/');

		$api = new self;

		$api->method = $_SERVER['REQUEST_METHOD'] ?? null;
		$type = $_SERVER['CONTENT_TYPE'] ?? null;
		$type ??= $_SERVER['HTTP_CONTENT_TYPE'] ?? null;

		if ($api->method === 'POST' && false !== strpos($type, '/json')) {
			$_POST = (array) json_decode($api->body(), true);
		}

		http_response_code(200);

		try {
			$return = $api->dispatch($fn, strtok(''));

			if (null !== $return) {
				echo json_encode($return, JSON_PRETTY_PRINT);
			}
		}
		catch (\Exception $e) {
			if ($e instanceof APIException) {
				http_response_code($e->getCode());
				echo json_encode(['error' => $e->getMessage()]);
			}
			elseif ($e instanceof UserException || $e instanceof ValidationException) {
				http_response_code(400);
				echo json_encode(['error' => $e->getMessage()]);
			}
			else {
				throw $e;
			}
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/API_Credentials.php version [55a0e25e82].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php

namespace Garradin;

use Garradin\Entities\API_Credentials as Entity;

use KD2\DB\EntityManager as EM;

class API_Credentials
{
	static public function list(): array
	{
		return EM::getInstance(Entity::class)->all('SELECT * FROM @TABLE ORDER BY key;');
	}

	static public function create(): Entity
	{
		$e = new Entity;
		$e->importForm();
		$e->secret = password_hash($e->secret, \PASSWORD_DEFAULT);
		$e->created = new \DateTime;
		$e->save();
		return $e;
	}

	static public function generateSecret(): string
	{
		return preg_replace('/[^0-9a-z]/i', '', base64_encode(random_bytes(16)));
	}

	static public function generateKey(): string
	{
		return strtolower(substr(self::generateSecret(), 0, 10));
	}

	static public function delete(int $id): void
	{
		$e = EM::findOneById(Entity::class, $id);

		if (!$e) {
			return;
		}

		$e->delete();
	}

	static public function login(string $key, string $secret): ?Entity
	{
		$e = EM::findOne(Entity::class, 'SELECT * FROM @TABLE WHERE key = ?;', $key);

		if (!$e || !password_verify($secret, $e->secret)) {
			return null;
		}

		EM::getInstance(Entity::class)->DB()->exec(sprintf('UPDATE %s SET last_use = datetime() WHERE id = %d;', Entity::TABLE, $e->id()));

		return $e;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






















































































































Deleted src/include/lib/Garradin/Accounting/Accounts.php version [d233ba835a].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use KD2\DB\EntityManager;

class Accounts
{
	protected $chart_id;
	protected $em;

	public function __construct(int $chart_id)
	{
		$this->chart_id = $chart_id;
		$this->em = EntityManager::getInstance(Account::class);
	}

	static public function get(int $id)
	{
		return EntityManager::findOneById(Account::class, $id);
	}

	public function getWithCode(string $code): ?Account
	{
		return EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE code = ? AND id_chart = ?', $code, $this->chart_id);
	}

	static public function getSelector(?int $id): ?array
	{
		if (!$id) {
			return null;
		}

		return [$id => self::getCodeAndLabel($id)];
	}

	static public function getCodeAndLabel(int $id): string
	{
		return EntityManager::getInstance(Account::class)->col('SELECT code || \' — \' || label FROM @TABLE WHERE id = ?;', $id);
	}

	public function getIdFromCode(string $code): int
	{
		return $this->em->col('SELECT id FROM @TABLE WHERE code = ? AND id_chart = ?;', $code, $this->chart_id);
	}

	static public function getCodeFromId(string $id): string
	{
		return EntityManager::getInstance(Account::class)->col('SELECT code FROM @TABLE WHERE id = ?;', $id);
	}

	/**
	 * Return common accounting accounts from current chart
	 * (will not return analytical and volunteering accounts)
	 */
	public function listCommonTypes(): array
	{
		$sql = sprintf('SELECT * FROM @TABLE WHERE id_chart = %d AND %s ORDER BY code COLLATE NOCASE;',
			$this->chart_id,
			DB::getInstance()->where('type', Account::COMMON_TYPES)
		);
		return $this->em->all($sql);
	}

	public function list(?array $types = null): DynamicList
	{
		$columns = [
			'id' => [
			],
			'code' => [
				'label' => 'N°',
				'order' => 'code COLLATE NOCASE %s',
			],
			'label' => [
				'label' => 'Libellé',
			],
			'description' => [
				'label' => '',
				'order' => null,
			],
			'level' => [
				'select' => 'CASE WHEN LENGTH(code) >= 6 THEN 6 ELSE LENGTH(code) END',
			],
			'report' => [
				'label' => ' ',
				'select' => null,
			],
			'position' => [
				'label' => 'Position',
			],
			'user' => [
				'label' => 'Ajouté',
			],
			'bookmark' => [
				'label' => 'Favori',
			],
		];

		$tables = 'acc_accounts';
		$conditions = 'id_chart = ' . $this->chart_id;

		if (!empty($types)) {
			$types = array_map('intval', $types);
			$conditions .= ' AND ' . DB::getInstance()->where('type', $types);
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('code', false);
		$list->setPageSize(null);
		$list->setModifier(function (&$row) {
			$row->position_report = !$row->position ? '' : ($row->position <= Account::ASSET_OR_LIABILITY ? 'Bilan' : 'Résultat');
			$row->position_name = Account::POSITIONS_NAMES[$row->position];
		});

		return $list;
	}

	public function listAll(array $types = null): array
	{
		$condition = '';

		if (!empty($types)) {
			$types = array_map('intval', $types);
			$condition = ' AND ' . DB::getInstance()->where('type', $types);
		}

		$sql = sprintf('SELECT * FROM @TABLE WHERE id_chart = %d %s ORDER BY code COLLATE NOCASE;', $this->chart_id, $condition);
		return $this->em->all($sql);
	}

	public function listForCodes(array $codes): array
	{
		return DB::getInstance()->getGrouped('SELECT code, id, label FROM acc_accounts WHERE id_chart = ?;', $this->chart_id);
	}

	/**
	 * List common accounts, grouped by type
	 * @return array
	 */
	public function listCommonGrouped(array $types = null, bool $hide_empty = false): array
	{
		if (null === $types) {
			// If we want all types, then we will get used or bookmarked accounts in common types
			// and only bookmarked accounts for other types, grouped in "Others"
			$target = Account::COMMON_TYPES;
		}
		else {
			$target = $types;
		}

		$out = [];

		foreach ($target as $type) {
			$out[$type] = (object) [
				'label'    => Account::TYPES_NAMES[$type],
				'type'     => $type,
				'accounts' => [],
			];
		}

		if (null === $types) {
			$out[0] = (object) [
				'label'    => 'Autres',
				'type'     => 0,
				'accounts' => [],
			];
		}

		$db = $this->em->DB();

		$sql = sprintf('SELECT a.* FROM @TABLE a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			WHERE a.id_chart = %d AND ((a.%s AND (a.bookmark = 1 OR b.id IS NOT NULL)) %s)
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;',
			$this->chart_id,
			$db->where('type', $target),
			(null === $types) ? 'OR (a.bookmark = 1)' : ''
		);

		$query = $this->em->iterate($sql);

		foreach ($query as $row) {
			$t = in_array($row->type, $target) ? $row->type : 0;
			$out[$t]->accounts[] = $row;
		}

		if ($hide_empty) {
			foreach ($out as $key => $v) {
				if (!count($v->accounts)) {
					unset($out[$key]);
				}
			}
		}

		return $out;
	}

	/**
	 * List accounts from this type that are missing in current "usual" accounts list
	 */
	public function listMissing(int $type): array
	{
		if ($type != Account::TYPE_EXPENSE && $type != Account::TYPE_REVENUE && $type != Account::TYPE_THIRD_PARTY) {
			return [];
		}

		return $this->em->DB()->get($this->em->formatQuery('SELECT a.*, CASE WHEN LENGTH(a.code) >= 6 THEN 6 ELSE LENGTH(a.code) END AS level,
			(a.bookmark = 1 OR a.user = 1 OR b.id IS NOT NULL) AS already_listed
			FROM @TABLE a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			WHERE a.id_chart = ? AND a.type = ?
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;'), $this->chart_id, $type);
	}

	public function countByType(int $type): int
	{
		return DB::getInstance()->count(Account::TABLE, 'id_chart = ? AND type = ?', $this->chart_id, $type);
	}

	public function getSingleAccountForType(int $type): ?Account
	{
		return $this->em->one('SELECT * FROM @TABLE WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
	}

	public function getIdForType(int $type): ?int
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
	}

	public function getOpeningAccountId(): ?int
	{
		return $this->getIdForType(Account::TYPE_OPENING);
	}

	public function getClosingAccountId(): ?int
	{
		return $this->getIdForType(Account::TYPE_CLOSING);
	}

	public function listUserAccounts(int $year_id): DynamicList
	{
		$id_field = Config::getInstance()->champ_identite;

		$columns = [
			'id' => [
				'select' => 'u.id',
			],
			'user_number' => [
				'select' => 'u.numero',
				'label' => 'N° membre',
			],
			'user_identity' => [
				'select' => 'u.' . $id_field,
				'label' => 'Membre',
			],
			'balance' => [
				'select' => 'SUM(l.debit - l.credit)',
				'label'  => 'Solde',
				//'order'  => 'balance != 0 %s, balance < 0 %1$s',
			],
			'status' => [
				'select' => null,
				'label' => 'Statut',
			],
		];

		$tables = 'acc_transactions_users tu
			INNER JOIN membres u ON u.id = tu.id_user
			INNER JOIN acc_transactions t ON tu.id_transaction = t.id
			INNER JOIN acc_transactions_lines l ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_account';

		$conditions = 'a.type = ' . Account::TYPE_THIRD_PARTY . ' AND t.id_year = ' . $year_id;

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('balance', false);
		$list->groupBy('u.id');
		$list->setCount('COUNT(*)');
		$list->setPageSize(null);
		$list->setExportCallback(function (&$row) {
			$row->balance = Utils::money_format($row->balance, '.', '', false);
		});

		return $list;
	}

/* FIXME: implement closing of accounts

	public function closeRevenueExpenseAccounts(Year $year, int $user_id)
	{
		$closing_id = $this->getClosingAccountId();

		if (!$closing_id) {
			throw new UserException('Aucun compte n\'est indiqué comme compte de clôture dans le plan comptable');
		}

		$transaction = new Transaction;
		$transaction->id_creator = $user_id;
		$transaction->id_year = $year->id();
		$transaction->type = Transaction::TYPE_ADVANCED;
		$transaction->label = 'Clôture de l\'exercice';
		$transaction->date = new \KD2\DB\Date;
		$debit = 0;
		$credit = 0;

		$sql = 'SELECT a.id, SUM(l.credit - l.debit) AS sum, a.position, a.code
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_account
			WHERE t.id_year = ? AND a.position IN (?, ?)
			GROUP BY a.id
			ORDER BY a.code;';

		$res = DB::getInstance()->iterate($sql, $year->id(), Account::REVENUE, Account::EXPENSE);

		foreach ($res as $row) {
			$reversed = $row->position == Account::ASSET;

			$line = new Line;
			$line->id_account = $row->id;
			$line->credit = $reversed ? abs($row->sum) : 0;
			$line->debit = !$reversed ? abs($row->sum) : 0;
			$transaction->addLine($line);

			if ($reversed) {
				$debit += abs($row->sum);
			}
			else {
				$credit += abs($row->sum);
			}
		}

		if ($debit) {
			$line = new Line;
			$line->id_account = $closing_id;
			$line->credit = 0;
			$line->debit = $debit;
			$transaction->addLine($line);
		}

		if ($credit) {
			$line = new Line;
			$line->id_account = $closing_id;
			$line->credit = $credit;
			$line->debit = 0;
			$transaction->addLine($line);
		}

		$transaction->save();
	}
*/
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
























































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/AssistedReconciliation.php version [7eaa670dc7].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
<?php

namespace Garradin\Accounting;

use Garradin\CSV_Custom;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Membres\Session;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entity;

/**
 * Provides assisted reconciliation
 */
class AssistedReconciliation
{
	const COLUMNS = [
		'label'          => 'Libellé',
		'date'           => 'Date',
		//'notes'          => 'Remarques',
		//'reference'      => 'Numéro pièce comptable',
		//'p_reference'    => 'Référence paiement',
		'amount'         => 'Montant',
		'debit'          => 'Débit',
		'credit'         => 'Crédit',
		'balance'        => 'Solde',
	];

	protected $csv;
	protected Account $account;

	public function __construct(Account $account)
	{
		$this->account = $account;
		$this->csv = new CSV_Custom(Session::getInstance(), 'acc_reconcile_csv');
		$this->csv->setColumns(self::COLUMNS);
		$this->csv->setMandatoryColumns(['label', 'date']);
		$this->csv->setModifier(function (\stdClass $line) use ($account) {
			$date = Entity::filterUserDateValue($line->date);

			$line->date = $date;

			static $has_amount = null;

			if (null === $has_amount) {
				$has_amount = in_array('amount', $this->csv->getTranslationTable());
			}

			if (!$has_amount && isset($line->credit) && isset($line->debit)) {
				$line->amount = $line->credit ?: '-' . ltrim($line->debit, '- \t\r\n');
			}

			$line->amount = Utils::moneyToInteger($line->amount ?? 0);

			if (!empty($line->balance)) {
				$line->balance = (substr($line->balance, 0, 1) == '-' ? -1 : 1) * Utils::moneyToInteger($line->balance);
			}

			$line->new_params = http_build_query([
				'00' => abs($line->amount),
				'l' => $line->label,
				'dt' => $date ? $date->format('Y-m-d') : '',
				't' => $line->amount < 0 ? Transaction::TYPE_EXPENSE : Transaction::TYPE_REVENUE,
				'ab' => $account->code,
			]);

			return $line;
		});
	}

	public function csv(): CSV_Custom
	{
		return $this->csv;
	}

	public function setSettings(array $translation_table, int $skip): void
	{
		$this->csv->setTranslationTable($translation_table);

		if ((in_array('credit', $translation_table) && !in_array('debit', $translation_table))
			|| (!in_array('credit', $translation_table) && in_array('debit', $translation_table))) {
			$this->csv->clear();
			throw new UserException('Il est nécessaire de sélectionner les deux colonnes "débit" et "crédit", pas seulement "crédit" ou "débit".');
		}

		$this->csv->skip($skip);
	}

	public function getStartAndEndDates(): ?array
	{
		$start = $end = null;

		if (!$this->csv->ready()) {
			return compact('start', 'end');
		}

		foreach ($this->csv->iterate() as $line) {
			if (null === $start || $line->date < $start) {
				$start = $line->date;
			}

			if (null === $end || $line->date > $end) {
				$end = $line->date;
			}
		}

		return compact('start', 'end');
	}

	public function mergeJournal(\Generator $journal)
	{
		$lines = [];

		$csv = iterator_to_array($this->csv->iterate());
		$journal = iterator_to_array($journal);
		$i = 0;
		$sum = 0;

		foreach ($journal as $j) {
			$id = $j->date->format('Ymd') . '.' . $i++;

			$row = (object) ['csv' => null, 'journal' => $j];

			if (isset($j->debit)) {
				foreach ($csv as &$line) {
					if (!isset($line->date)) {
						 continue;
					}

					// Match date, amount and label
					if ($j->date->format('Ymd') == $line->date->format('Ymd')
						&& ($j->credit * -1 == $line->amount || $j->debit == $line->amount)
						&& strtolower($j->label) == strtolower($line->label)) {
						$row->csv = $line;
						$line = null;
						break;
					}
				}
			}

			$lines[$id] = $row;
		}

		unset($line, $row, $j);

		// Second round to match only amount and label
		foreach ($lines as $row) {
			if ($row->csv || !isset($row->journal->debit)) {
				continue;
			}

			$j = $row->journal;

			foreach ($csv as &$line) {
				if (!isset($line->date)) {
					 continue;
				}

				if ($j->date->format('Ymd') == $line->date->format('Ymd')
					&& ($j->credit * -1 == $line->amount || $j->debit == $line->amount)) {
					$row->csv = $line;
					$line = null;
					break;
				}
			}
		}

		unset($j, $line);

		// Then add CSV lines on the right
		foreach ($csv as $line) {
			if (null == $line || !isset($line->date)) {
				continue;
			}

			$id = $line->date->format('Ymd') . '.' . ($i++);
			$lines[$id] = (object) ['csv' => $line, 'journal' => null];
		}

		ksort($lines);
		$prev = null;

		foreach ($lines as &$line) {
			$line->add = false;

			if (isset($line->csv)) {
				$sum += $line->csv->amount;
				$line->csv->running_sum = $sum;

				if ($prev && ($prev->date->format('Ymd') != $line->csv->date->format('Ymd') || $prev->label != $line->csv->label)) {
					$prev = null;
				}
			}

			if (isset($line->csv) && isset($line->journal)) {
				$prev = null;
			}

			if (isset($line->csv) && !isset($line->journal) && !$prev) {
				$line->add = true;
				$prev = $line->csv;
			}
		}

		return $lines;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/Charts.php version [5ce026c347].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Chart;
use Garradin\Utils;
use Garradin\DB;
use Garradin\UserException;

use KD2\DB\EntityManager;

use const Garradin\ROOT;

class Charts
{
	const BUNDLED_CHARTS = [
		'fr_pca_1999' => 'Plan comptable associatif 1999',
		'fr_pca_2018' => 'Plan comptable associatif 2020 (Règlement ANC n°2018-06)',
		'fr_pcc_2020' => 'Plan comptable des copropriétés (2005 révisé en 2020)',
		'fr_cse_2015' => 'Plan comptable des CSE (Comité Social et Économique) (Règlement ANC n°2015-01)',
		'fr_pcg_2014' => 'Plan comptable général, pour entreprises (Règlement ANC n° 2014-03, consolidé 1er janvier 2019)',
		'fr_pcs_2018' => 'Plan comptable des syndicats (2018)',
		'be_pcmn_2019' => 'Plan comptable minimum normalisé des associations et fondations 2019',
		'ch_asso' => 'Plan comptable associatif',
	];

	static public function getFirstForCountry(string $country): ?Chart
	{
		$db = DB::getInstance();

		$chart = EntityManager::findOne(Chart::class, 'SELECT * FROM acc_charts WHERE archived = 0 AND country = ? AND code IS NOT NULL LIMIT 1;', $country);

		if (!$chart) {
			$chart = EntityManager::findOne(Chart::class, 'SELECT * FROM acc_charts LIMIT 1;',);
		}

		return $chart;
	}

	static public function updateInstalled(string $chart_code): ?Chart
	{
		$file = sprintf('%s/include/data/charts/%s.csv', ROOT, $chart_code);
		$country = strtoupper(substr($chart_code, 0, 2));
		$code = strtoupper(substr($chart_code, 3));

		$chart = EntityManager::findOne(Chart::class, 'SELECT * FROM @TABLE WHERE code = ? AND country = ?;', $code, $country);

		if (!$chart) {
			return null;
		}

		$chart->importCSV($file, true);
		return $chart;
	}

	static public function resetRules(array $country_list): void
	{
		foreach (self::list() as $c) {
			if (in_array($c->country, $country_list)) {
				$c->resetAccountsRules();
			}
		}
	}

	static public function installCountryDefault(string $country_code): Chart
	{
		if ($country_code == 'CH') {
			$chart_code = 'ch_asso';
		}
		elseif ($country_code == 'be') {
			$chart_code = 'be_pcmn_2019';
		}
		else {
			$chart_code = 'fr_pca_2018';
		}

		return self::install($chart_code);
	}

	static public function install(string $chart_code): Chart
	{
		if (!array_key_exists($chart_code, self::BUNDLED_CHARTS)) {
			throw new \InvalidArgumentException('Le plan comptable demandé n\'existe pas.');
		}

		$file = sprintf('%s/include/data/charts/%s.csv', ROOT, $chart_code);

		if (!file_exists($file)) {
			throw new \LogicException('Le plan comptable demandé n\'a pas de fichier CSV');
		}

		$country = strtoupper(substr($chart_code, 0, 2));
		$code = strtoupper(substr($chart_code, 3));

		if (DB::getInstance()->test(Chart::TABLE, 'country = ? AND code = ?', $country, $code)) {
			throw new \RuntimeException('Ce plan comptable est déjà installé');
		}

		$db = DB::getInstance();
		$db->begin();

		$chart = new Chart;
        $chart->label = self::BUNDLED_CHARTS[$chart_code];
        $chart->country = $country;
        $chart->code = $code;
        $chart->save();
        $chart->importCSV($file);

        $db->commit();
        return $chart;
	}

	static public function listInstallable(): array
	{
		$installed = DB::getInstance()->getAssoc('SELECT id, LOWER(country || \'_\' || code) FROM acc_charts;');
		$out = [];

		foreach (self::BUNDLED_CHARTS as $code => $label) {
			if (in_array($code, $installed)) {
				continue;
			}

			$out[$code] = sprintf('%s — %s', Utils::getCountryName(substr($code, 0, 2)), $label);
		}

		return $out;
	}

	static public function get(int $id)
	{
		return EntityManager::findOneById(Chart::class, $id);
	}

	static public function list()
	{
		$em = EntityManager::getInstance(Chart::class);
		return $em->all('SELECT * FROM @TABLE ORDER BY country, label;');
	}

	static public function listForCountry(string $country): array
	{
		$installed = DB::getInstance()->getAssoc(sprintf('SELECT id, label FROM %s WHERE country = ? AND code IS NULL ORDER BY label COLLATE U_NOCASE;', Chart::TABLE), $country);
		$country = strtolower($country);

		$list = [];

		foreach (self::BUNDLED_CHARTS as $code => $label) {
			if (substr($code, 0, 2) != $country) {
				continue;
			}

			$list[$code] = $label;
		}

		// Don't use array_merge here, or it will erase ID keys
		return $list + $installed;
	}

	static public function getOrInstall(string $id_or_code): int
	{
		if (ctype_digit($id_or_code)) {
			return (int) $id_or_code;
		}

		$country = strtoupper(substr($id_or_code, 0, 2));
		$code = strtoupper(substr($id_or_code, 3));
		$id = DB::getInstance()->firstColumn('SELECT id FROM acc_charts WHERE country = ? AND code = ?;', $country, $code);

		if ($id) {
			return $id;
		}

		$chart = self::install($id_or_code);
		return $chart->id;
	}

	static public function listByCountry(bool $filter_archived = false)
	{
		$where = $filter_archived ? ' AND archived = 0' : '';
		$sql = sprintf('SELECT id, country, label FROM %s WHERE 1 %s ORDER BY country, code DESC, label;', Chart::TABLE, $where);
		$list = DB::getInstance()->getGrouped($sql);
		$out = [];

		foreach ($list as $row) {
			$country = $row->country ? Utils::getCountryName($row->country) : 'Aucun';

			if (!array_key_exists($country, $out)) {
				$out[$country] = [];
			}

			$out[$country][$row->id] = $row->label;
		}

		return $out;
	}

	static public function copyFrom(int $from_id, ?string $label, ?string $country): void
	{
		$db = DB::getInstance();
		$db->begin();

		$chart = new Chart;
		$chart->importForm(compact('label', 'country'));
		$chart->save();

		$db->exec(sprintf('INSERT INTO %s (id_chart, code, label, description, position, type, user, bookmark)
			SELECT %d, code, label, description, position, type, user, bookmark FROM %1$s WHERE id_chart = %d;', Account::TABLE, $chart->id, $from_id));
		$db->commit();
	}

	static public function import(string $file_key, ?string $label, ?string $country): void
	{
		if (empty($_FILES[$file_key]) || empty($_FILES[$file_key]['size']) || empty($_FILES[$file_key]['tmp_name'])) {
			throw new UserException('Fichier invalide');
		}

		$db = DB::getInstance();
		$db->begin();

		$chart = new Chart;
		$chart->importForm(compact('label', 'country'));
		$chart->save();
		$chart->importCSV($_FILES[$file_key]['tmp_name']); // This will save everything

		$db->commit();
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<








































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/Export.php version [872ac881da].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use Garradin\Config;
use Garradin\CSV;
use Garradin\DB;
use Garradin\Utils;

class Export
{
	const FULL = 'full';
	const GROUPED = 'grouped';
	const SIMPLE = 'simple';
	const FEC = 'fec';

	const NAMES = [
		self::FULL => 'Complet',
		self::GROUPED => 'Groupé',
		self::SIMPLE => 'Simplifié',
		self::FEC => 'FEC',
	];

	const COLUMNS_FULL = [
		'Numéro d\'écriture'     => 'id',
		'Type'                   => 'type',
		'Statut'                 => 'status',
		'Libellé'                => 'label',
		'Date'                   => 'date',
		'Remarques'              => 'notes',
		'Numéro pièce comptable' => 'reference',

		// Lines
		'Numéro compte'     => 'account',
		'Libellé compte'    => 'account_label',
		'Débit'             => 'debit',
		'Crédit'            => 'credit',
		'Référence ligne'   => 'line_reference',
		'Libellé ligne'     => 'line_label',
		'Rapprochement'     => 'reconciled',
		'Projet analytique' => 'project',
		'Membres associés'  => 'linked_users',
	];

	const COLUMNS = [
		self::GROUPED => self::COLUMNS_FULL,
		self::FULL => self::COLUMNS_FULL,
		self::SIMPLE => [
			'Numéro d\'écriture'     => 'id',
			'Type'                   => 'type',
			'Statut'                 => 'status',
			'Libellé'                => 'label',
			'Date'                   => 'date',
			'Remarques'              => 'notes',
			'Numéro pièce comptable' => 'reference',
			'Référence paiement'     => 'p_reference',
			'Compte de débit'        => 'debit_account',
			'Compte de crédit'       => 'credit_account',
			'Montant'                => 'amount',
			'Projet analytique'      => 'project',
			'Membres associés'       => 'linked_users',
		],
		self::FEC => [
			'JournalCode'   => null,
			'JournalLib'    => null,
			'EcritureNum'   => 'id',
			'EcritureDate'  => 'date',
			'CompteNum'     => 'account',
			'CompteLib'     => 'account_label',
			'CompAuxNum'    => null,
			'CompAuxLib'    => null,
			'PieceRef'      => 'reference',
			'PieceDate'     => null,
			'EcritureLib'   => 'label',
			'Debit'         => 'debit',
			'Credit'        => 'credit',
			'EcritureLet'   => null,
			'DateLet'       => null,
			'ValidDate'     => null,
			'MontantDevise' => null,
			'Idevise'       => null,
		],
	];

	const MANDATORY_COLUMNS = [
		self::GROUPED => [
			'type',
			'label',
			'date',
			'account',
			'credit',
			'debit',
		],
		self::SIMPLE => [
			'label',
			'date',
			'credit_account',
			'debit_account',
			'amount'
		],
		self::FEC => [
			'label',
			'date',
			'account',
			'label',
			'debit',
			'credit',
		],
	];

	/**
	 * Return all transactions from year
	 */
	static public function export(Year $year, string $format, string $type): void
	{
		$header = null;

		if (!array_key_exists($type, self::COLUMNS)) {
			throw new \InvalidArgumentException('Unknown type: ' . $type);
		}

		CSV::export(
			$format,
			sprintf('%s - Export comptable - %s - %s', Config::getInstance()->get('nom_asso'), self::NAMES[$type], $year->label),
			self::iterateExport($year->id(), $type),
			array_keys(self::COLUMNS[$type])
		);
	}

	static public function getExamples(Year $year)
	{
		$out = [];

		foreach (self::NAMES as $type => $label) {
			$i = 0;
			$out[$type] = [array_keys(self::COLUMNS[$type])];

			foreach (self::iterateExport($year->id(), $type) as $row) {
				$out[$type][] = $row;

				if (++$i > 1) {
					break;
				}
			}
		}

		return $out;
	}

	static protected function iterateExport(int $year_id, string $type): \Generator
	{
		$id_field = Config::getInstance()->get('champ_identite');

		if (self::SIMPLE == $type) {
			$sql =  'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
				IFNULL(l1.reference, l2.reference) AS p_reference,
				a1.code AS debit_account,
				a2.code AS credit_account,
				l1.debit AS amount,
				IFNULL(p.code, p.label) AS project,
				GROUP_CONCAT(u.%s) AS linked_users
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit != 0
				INNER JOIN acc_transactions_lines l2 ON l2.id_transaction = t.id AND l2.credit != 0
				INNER JOIN acc_accounts a1 ON a1.id = l1.id_account
				INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
				LEFT JOIN acc_projects p ON p.id = l1.id_project
				LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
				LEFT JOIN membres u ON u.id = tu.id_user
				WHERE t.id_year = ?
					AND t.type != %d
				GROUP BY t.id
				ORDER BY t.date, t.id;';

			$sql = sprintf($sql, $id_field, Transaction::TYPE_ADVANCED);
		}
		elseif (self::FEC == $type) {
			// JournalCode|JournalLib|EcritureNum|EcritureDate|CompteNum|CompteLib
			// |CompAuxNum|CompAuxLib|PieceRef|PieceDate|EcritureLib|Debit|Credit
			// |EcritureLet|DateLet|ValidDate|MontantDevise|Idevise

			$sql = 'SELECT
				printf(\'%02d\', t.type) AS type_id, t.type,
				t.id, t.date,
				a.code AS account, a.label AS account_label,
				NULL AS CompAuxNum, NULL AS CompAuxLib,
				t.reference,
				strftime(\'%Y%m%d\', t.date) AS ref_date,
				t.label,
				l.debit, l.credit,
				NULL AS EcritureLet,
				NULL AS DateLet,
				NULL AS ValidDate,
				NULL AS MontantDevise,
				NULL AS Idevise
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
				INNER JOIN acc_accounts a ON a.id = l.id_account
				WHERE t.id_year = ?
				GROUP BY t.id, l.id
				ORDER BY t.date, t.id, l.id;';
		}
		elseif (self::FULL == $type || self::GROUPED == $type) {
			$sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
				a.code AS account, a.label AS account_label, l.debit AS debit, l.credit AS credit,
				l.reference AS line_reference, l.label AS line_label, l.reconciled,
				IFNULL(p.code, p.label) AS project,
				GROUP_CONCAT(u.%s) AS linked_users
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
				INNER JOIN acc_accounts a ON a.id = l.id_account
				LEFT JOIN acc_projects p ON p.id = l.id_project
				LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
				LEFT JOIN membres u ON u.id = tu.id_user
				WHERE t.id_year = ?
				GROUP BY t.id, l.id
				ORDER BY t.date, t.id, l.id;';

			$sql = sprintf($sql, $id_field);
		}
		else {
			throw new \LogicException('Unknown export type: ' . $type);
		}

		$res = DB::getInstance()->iterate($sql, $year_id);

		$previous_id = null;

		foreach ($res as $row) {
			if ($type == self::GROUPED && $previous_id === $row->id) {
				// Remove transaction data to differentiate lines and transactions
				$row->id = $row->type = $row->status = $row->label = $row->date = $row->notes = $row->reference = $row->linked_users = null;
			}
			else {
				$row->type = Transaction::TYPES_NAMES[$row->type];

				if (property_exists($row, 'status')) {
					$status = [];

					foreach (Transaction::STATUS_NAMES as $k => $v) {
						if ($row->status & $k) {
							$status[] = $v;
						}
					}

					$row->status = implode(', ', $status);
				}

				$row->date = \DateTime::createFromFormat('Y-m-d', $row->date);
				$row->date = $row->date->format($type == self::FEC ? 'Ymd' : 'd/m/Y');
				$previous_id = $row->id;
			}

			if ($type == self::SIMPLE) {
				$row->amount = Utils::money_format($row->amount, ',', '');
			}
			else {
				$row->credit = Utils::money_format($row->credit, ',', '');
				$row->debit = Utils::money_format($row->debit, ',', '');
			}

			yield $row;
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
























































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/Graph.php version [dad1eda3f9].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Utils;
use Garradin\Config;
use Garradin\DB;
use Garradin\UserTemplate\CommonModifiers;

use const Garradin\ADMIN_COLOR1;
use const Garradin\ADMIN_COLOR2;
use const Garradin\ADMIN_URL;

use KD2\DB\EntityManager;

use KD2\Graphics\SVG\Plot;
use KD2\Graphics\SVG\Plot_Data;

use KD2\Graphics\SVG\Pie;
use KD2\Graphics\SVG\Pie_Data;

use KD2\Graphics\SVG\Bar;
use KD2\Graphics\SVG\Bar_Data_Set;

class Graph
{
	const URL_LIST = [
		ADMIN_URL . 'acc/reports/graph_plot.php?type=assets&%s' => 'Évolution banques et caisses',
		ADMIN_URL . 'acc/reports/graph_plot.php?type=result&%s' => 'Évolution dépenses et recettes',
		ADMIN_URL . 'acc/reports/graph_plot.php?type=debts&%s' => 'Évolution créances (positif) et dettes (négatif)',
		ADMIN_URL . 'acc/reports/graph_pie.php?type=assets&%s' => 'Répartition actif',
		ADMIN_URL . 'acc/reports/graph_pie.php?type=revenue&%s' => 'Répartition recettes',
		ADMIN_URL . 'acc/reports/graph_pie.php?type=expense&%s' => 'Répartition dépenses',
	];

	const PLOT_TYPES = [
		'assets' => [
			'Total' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING], 'exclude_position' => [Account::LIABILITY]],
			'Banques' => ['type' => Account::TYPE_BANK],
			'Caisses' => ['type' => Account::TYPE_CASH],
			'En attente' => ['type' => Account::TYPE_OUTSTANDING, 'exclude_position' => [Account::LIABILITY]],
		],
		'result' => [
			'Recettes' => ['position' => Account::REVENUE],
			'Dépenses' => ['position' => Account::EXPENSE],
		],
		'debts' => [
			'Comptes de tiers' => ['type' => Account::TYPE_THIRD_PARTY],
		],
	];

	const PIE_TYPES = [
		'revenue' => ['position' => Account::REVENUE, 'exclude_type' => Account::TYPE_VOLUNTEERING_REVENUE],
		'expense' => ['position' => Account::EXPENSE, 'exclude_type' => Account::TYPE_VOLUNTEERING_EXPENSE],
		'assets' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]],
	];

	const WEEKLY_INTERVAL = 604800; // 7 days
	const MONTHLY_INTERVAL = 2635200; // 1 month

	static public function plot(string $type, array $criterias, int $interval = self::WEEKLY_INTERVAL, int $width = 700)
	{
		if (!array_key_exists($type, self::PLOT_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}

		$plot = new Plot($width, 300);

		$lines = self::PLOT_TYPES[$type];
		$data = [];

		foreach ($lines as $label => $line_criterias) {
			$line_criterias = array_merge($criterias, $line_criterias);
			$sums = Reports::getSumsByInterval($line_criterias, $interval);

			if (count($sums) <= 1) {
				continue;
			}

			// Invert sums for banks, cash, etc.
			if ('assets' === $type || 'debts' === $type || ('result' === $type && $line_criterias['position'] == Account::EXPENSE)) {
				$sums = array_map(function ($v) { return $v * -1; }, $sums);
			}

			$sums = array_map(function ($v) { return (int)$v/100; }, $sums);

			$graph = new Plot_Data($sums);
			$graph->title = $label;
			$data[] = $graph;
		}


		if (count($data))
		{
			$labels = [];

			foreach ($data[0]->get() as $k=>$v)
			{
				$date = new \DateTime('@' . ($k * $interval));
				$labels[] = Utils::date_fr($date, 'M y');
			}

			$plot->setLabels($labels);

			$i = 0;
			$colors = self::getColors();

			foreach ($data as $line)
			{
				$line->color = $colors[$i++];
				$line->width = 3;
				$plot->add($line);

				if ($i >= count($colors))
					$i = 0;
			}
		}

		$out = $plot->output();

		return $out;
	}

	static public function pie(string $type, array $criterias)
	{
		if (!array_key_exists($type, self::PIE_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}

		$pie = new Pie(700, 300);

		$pie_criterias = self::PIE_TYPES[$type];
		$data = Reports::getAccountsBalances(array_merge($criterias, $pie_criterias), 'balance DESC');

		$others = 0;
		$colors = self::getColors();
		$max = count($colors);
		$total = 0;
		$count = 0;
		$i = 0;

		$currency = Config::getInstance()->monnaie;

		foreach ($data as $row) {
			$total += $row->balance;
		}

		foreach ($data as $row)
		{
			if ($i++ >= $max || $count > $total*0.95)
			{
				$others += $row->balance;
			}
			else
			{
				$label = strlen($row->label) > 40 ? trim(substr($row->label, 0, 38)) . '…' : $row->label;
				$data = new Pie_Data(abs($row->balance) / 100, $label, $colors[$i-1]);
				$data->sublabel = Utils::money_format(intval($row->balance / 100) * 100, null, ' ', true) . ' ' . $currency;
				$pie->add($data);
			}

			$count += $row->balance;
		}

		if ($others != 0)
		{
			$data = new Pie_Data(abs($others) / 100, 'Autres', '#ccc');
			$data->sublabel = Utils::money_format(intval($others / 100) * 100, null, ' ', true) . ' ' . $currency;
			$pie->add($data);
		}

		$pie->togglePercentage(true);

		$out = $pie->output();

		return $out;
	}

	static public function bar(string $type, array $criterias)
	{
		if (!array_key_exists($type, self::PLOT_TYPES)) {
			throw new \InvalidArgumentException('Unknown type');
		}

		$bar = new Bar(600, 300);

		$lines = self::PLOT_TYPES[$type];
		$data = [];

		$colors = self::getColors();

		foreach ($lines as $label => $line_criterias) {
			$color = current($colors);
			next($colors);

			$line_criterias = array_merge($criterias, $line_criterias);
			$years = Reports::getSumsPerYear($line_criterias);

			if (count($years) < 1) {
				continue;
			}

			foreach ($years as $year) {
				$start = Utils::date_fr($year->start_date, 'Y');
				$end = Utils::date_fr($year->end_date, 'Y');
				$year_label = $start == $end ? $start : sprintf('%s-%s', $start, substr($end, -2));

				$year_id = $year_label . '-' . $year->id;

				if (!isset($data[$year_id])) {
					$data[$year_id] = new Bar_Data_Set($year_label);
				}

				$data[$year_id]->add((int) $year->balance / 100, $label, $color);
			}
		}

		ksort($data);

		foreach ($data as $group) {
			$bar->add($group);
		}

		$out = $bar->output();

		return $out;
	}

	static protected function getColors()
	{
		$config = Config::getInstance();
		$c1 = $config->get('couleur1') ?: ADMIN_COLOR1;
		$c2 = $config->get('couleur2') ?: ADMIN_COLOR2;
		list($h, $s, $v) = Utils::rgbToHsv($c1);
		list($h1, $s, $v) = Utils::rgbToHsv($c2);

		$colors = [];

		for ($i = 0; $i < 5; $i++) {
			if ($i % 2 == 0) {
				$s = $v = 50;
				$h =& $h1;
			}
			else {
				$s = $v = 70;
				$h =& $h2;
			}

			$colors[] = sprintf('hsl(%d, %d%%, %d%%)', $h, $s, $v);

			$h += 30;

			if ($h > 360) {
				$h -= 360;
			}
		}

		return $colors;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/Import.php version [9708fc44ff].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use Garradin\CSV_Custom;
use Garradin\Config;
use Garradin\DB;
use Garradin\UserException;

use KD2\SimpleDiff;

class Import
{
	static protected function saveImportedTransaction(Transaction $transaction, ?array $linked_users, bool $dry_run = false, array &$report = null): void
	{
		static $users = [];
		$found_users = null;

		// Associate users
		if (is_array($linked_users) && count($linked_users)) {
			$found_users = array_intersect_key($users, array_flip($linked_users));

			foreach ($linked_users as $name) {
				if (!array_key_exists($name, $users)) {
					continue;
				}

				$found_users[$name] = $users[$name];
			}

			if (count($found_users) != count($linked_users)) {
				$id_field = Config::getInstance()->champ_identite;
				$db = DB::getInstance();
				$sql = sprintf('SELECT %s AS name, id FROM membres WHERE %s;', $db->quoteIdentifier($id_field), $db->where($id_field, $linked_users));

				foreach ($db->iterate($sql) as $row) {
					$found_users[$row->name] = $row->id;
					$users[$row->name] = $row->id;
				}

				// Fill array with NULL for missing user names, so that we won't go fetch them again
				foreach ($linked_users as $name) {
					if (!array_key_exists($name, $users)) {
						$users[$name] = null;
					}
				}
			}

			$found_users = array_filter($found_users);
		}
		elseif (is_array($linked_users) && count($linked_users) == 0) {
			$found_users = [];
		}


		if ($transaction->countLines() > 2) {
			$transaction->type = Transaction::TYPE_ADVANCED;
		}
		// Try to magically find out what kind of transaction this is
		elseif (!isset($transaction->type)) {
			$transaction->type = $transaction->findTypeFromAccounts();
		}

		if (!$dry_run) {
			if ($transaction->isModified() || $transaction->diff()) {
				$transaction->save();
			}

			if (null !== $found_users) {
				$transaction->updateLinkedUsers($found_users);
			}
		}
		else {
			$transaction->selfCheck();
		}

		if (null !== $report) {
			$diff = null;

			if (!$transaction->exists()) {
				$target = 'created';
			}
			elseif (($diff = $transaction->diff())
				|| ($linked_users = $transaction->listLinkedUsersAssoc()) && (array_values($linked_users) != array_keys($found_users))) {
				if (!$diff) {
					$diff = [];
				}

				$target = 'modified';

				if (array_values($linked_users) != array_keys($found_users)) {
					$diff['linked_users'] = [
						implode(', ', $linked_users),
						implode(', ', array_keys($found_users))
					];
				}

				$linked_users = implode(', ', $linked_users);
				$diff = compact('diff', 'transaction', 'linked_users');
			}
			else {
				$target = 'unchanged';
			}

			$report[$target][] = $diff ?? array_merge($transaction->asJournalArray(), ['linked_users' => implode(', ', array_keys($found_users))]);
		}
	}

	/**
	 * Imports a CSV file of transactions in a year
	 * @param  string     $type    Type of CSV format
	 * @param  Year       $year    Target year where transactions should be updated or created
	 * @param  CSV_Custom $csv     CSV object
	 * @param  int        $user_id Current user ID, the one running the import
	 * @param  array      $options array of options
	 * @return ?array
	 */
	static public function import(string $type, Year $year, CSV_Custom $csv, int $user_id, array $options = []): ?array
	{
		$options_default = [
			'ignore_ids'      => false,
			'dry_run'         => false,
			'return_report'   => false,
		];

		$o = (object) array_merge($options_default, $options);

		$dry_run = $o->dry_run;

		if ($type != Export::GROUPED && $type != Export::SIMPLE && $type != Export::FEC) {
			throw new \InvalidArgumentException('Invalid type value');
		}

		if ($year->closed) {
			throw new \InvalidArgumentException('Closed year');
		}

		$db = DB::getInstance();
		$db->begin();

		$accounts = $year->accounts();
		$transaction = null;
		$linked_users = null;
		$types = array_flip(Transaction::TYPES_NAMES);

		if ($o->return_report) {
			$report = ['created' => [], 'modified' => [], 'unchanged' => []];
		}
		else {
			$report = null;
		}

		$l = 1;

		try {
			$current_id = null;

			foreach ($csv->iterate() as $l => $row) {
				$row = (object) $row;

				// Import grouped transactions
				if ($type == Export::GROUPED) {
					// If a line doesn't have any transaction info: this is a line following the previous transaction
					$has_transaction = !(empty($row->id)
						&& empty($row->type)
						&& empty($row->status)
						&& empty($row->label)
						&& empty($row->date)
						&& empty($row->notes)
						&& empty($row->reference)
					);

					// New transaction, save previous one
					if (null !== $transaction && $has_transaction) {
						self::saveImportedTransaction($transaction, $linked_users, $dry_run, $report);
						$transaction = null;
						$linked_users = null;
					}

					if (!$has_transaction && null === $transaction) {
						throw new UserException('cette ligne n\'est reliée à aucune écriture');
					}
				}
				else {
					if (!empty($row->id) && $row->id != $current_id) {
						if (null !== $transaction) {
							self::saveImportedTransaction($transaction, $linked_users, $dry_run, $report);
							$transaction = null;
							$linked_users = null;
						}

						$current_id = (int) $row->id;
					}
				}

				// Find or create transaction
				if (null === $transaction) {
					if (!empty($row->id) && !$o->ignore_ids) {
						$transaction = Transactions::get((int)$row->id);

						if (!$transaction) {
							throw new UserException(sprintf('l\'écriture #%d est introuvable', $row->id));
						}

						if ($transaction->id_year != $year->id()) {
							throw new UserException(sprintf('l\'écriture #%d appartient à un autre exercice', $row->id));
						}

						if ($transaction->validated) {
							throw new UserException(sprintf('l\'écriture #%d est validée et ne peut être modifiée', $row->id));
						}

						if ($type != Export::SIMPLE) {
							$transaction->resetLines();
						}
					}
					else {
						$transaction = new Transaction;
						$transaction->id_creator = $user_id;
						$transaction->id_year = $year->id();
					}

					if (isset($row->type) && !isset($types[$row->type])) {
						throw new UserException(sprintf('le type "%s" est inconnu. Les types reconnus sont : %s.', $row->type, implode(', ', array_keys($types))));
					}

					// FEC does not define type, so don't change it
					if (isset($row->type)) {
						$transaction->type = $types[$row->type];
					}

					$fields = array_intersect_key((array)$row, array_flip(['label', 'date', 'notes', 'reference']));

					// Remove empty values
					$fields = array_filter($fields);

					$transaction->importForm($fields);

					// Set status
					if (!empty($row->status)) {
						$status_list = array_map('trim', explode(',', $row->status));
						$status = 0;

						foreach (Transaction::STATUS_NAMES as $k => $v) {
							if (in_array($v, $status_list)) {
								$status |= $k;
							}
						}

						$transaction->status = $status;
					}

					if (isset($row->linked_users) && trim($row->linked_users) !== '') {
						$linked_users = array_map('trim', explode(',', $row->linked_users));
					}
					else {
						$linked_users = [];
					}
				}

				$data = [];

				if (!empty($row->project)) {
					$id_project = Projects::getIdFromCodeOrLabel($row->project);

					if (!$id_project) {
						throw new UserException(sprintf('le projet analytique "%s" n\'existe pas', $row->project));
					}

					$data['id_project'] = $id_project;
				}
				elseif (property_exists($row, 'project')) {
					$data['id_project'] = null;
				}

				// Add two transaction lines for each CSV line
				if ($type == Export::SIMPLE) {
					$credit_account = $accounts->getIdFromCode($row->credit_account);
					$debit_account = $accounts->getIdFromCode($row->debit_account);

					if (!$credit_account) {
						throw new UserException(sprintf('Compte de crédit "%s" inconnu dans le plan comptable', $row->credit_account));
					}

					if (!$debit_account) {
						throw new UserException(sprintf('Compte de débit "%s" inconnu dans le plan comptable', $row->debit_account));
					}

					$data['reference'] = isset($row->p_reference) ? $row->p_reference : null;

					$l1 = $transaction->getCreditLine() ?? new Line;
					$l2 = $transaction->getDebitLine() ?? new Line;

					$l1->importForm($data + [
						'credit'     => $row->amount,
						'debit'      => 0,
						'id_account' => $credit_account,
					]);

					$l2->importForm($data + [
						'credit'     => 0,
						'debit'      => $row->amount,
						'id_account' => $debit_account,
					]);

					if (!$l1->exists()) {
						$transaction->addLine($l1);
					}

					if (!$l2->exists()) {
						$transaction->addLine($l2);
					}

					self::saveImportedTransaction($transaction, $linked_users, $dry_run, $report);
					$transaction = null;
					$linked_users = null;
				}
				else {
					$id_account = $accounts->getIdFromCode($row->account);

					if (!$id_account) {
						throw new UserException(sprintf('le compte "%s" n\'existe pas dans le plan comptable', $row->account));
					}

					$data = $data + [
						'credit'     => $row->credit ?: 0,
						'debit'      => $row->debit ?: 0,
						'id_account' => $id_account,
						'reference'  => $row->line_reference ?? null,
						'label'      => $row->line_label ?? null,
						'reconciled' => $row->reconciled ?? false,
					];

					$line = new Line;
					$line->importForm($data);
					$transaction->addLine($line);
				}
			}

			if (null !== $transaction) {
				self::saveImportedTransaction($transaction, $linked_users, $dry_run, $report);
				$transaction = null;
				$linked_users = null;
			}
		}
		catch (UserException $e) {
			$db->rollback();
			$e->setMessage(sprintf('Erreur sur la ligne %d : %s', $l - 1, $e->getMessage()));

			if (null !== $transaction) {
				$e->setDetails($transaction->asDetailsArray());
			}

			throw $e;
		}

		$db->commit();

		if ($report) {
			foreach ($report as $type => $entries) {
				$report[$type . '_count'] = count($entries);
			}
		}


		return $report;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/Projects.php version [4318061708].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Project;
use Garradin\DB;

use KD2\DB\EntityManager;

class Projects
{
	static public function get(int $id): ?Project
	{
		return EntityManager::findOneById(Project::class, $id);
	}

	static public function getIdFromCodeOrLabel(string $str): ?int
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_projects WHERE code = ? OR label = ?;', $str, $str) ?: null;
	}

	static public function getName(?int $id): ?string
	{
		if (!$id) {
			return null;
		}

		static $projects = [];

		if (array_key_exists($id, $projects)) {
			return $projects[$id];
		}

		$p = DB::getInstance()->first('SELECT code, label FROM acc_projects WHERE id = ?;', $id);

		$projects[$id] = $p ? ($p->code ? sprintf('%s — %s', $p->code, $p->label) : $p->label) : null;
		return $projects[$id];
	}

	static public function count(): int
	{
		return DB::getInstance()->count(Project::TABLE);
	}

	static public function listAssoc(): array
	{
		$em = EntityManager::getInstance(Project::class);
		$sql = $em->formatQuery('SELECT id, CASE WHEN code IS NOT NULL THEN code || \' — \' || label ELSE label END FROM @TABLE WHERE archived = 0 ORDER BY code COLLATE NOCASE, label COLLATE U_NOCASE;');
		return $em->DB()->getAssoc($sql);
	}

	static public function listAssocWithEmpty(): array
	{
		return ['' => '-- Aucun'] + self::listAssoc();
	}

	/**
	 * Return account balances per year or per project
	 * @param  bool $by_year If true will return projects grouped by year, if false it will return years grouped by project
	 */
	static public function getBalances(bool $by_year = false): \Generator
	{
		$join = $by_year ? 'INNER' : 'LEFT';
		$sql = 'SELECT p.label AS project_label, p.description AS project_description, p.id AS id_project,
			p.code AS project_code, p.archived, p.id AS project_id,
			y.id AS id_year, y.label AS year_label, y.start_date, y.end_date,
			SUM(l.credit - l.debit) AS sum, SUM(l.credit) AS credit, SUM(l.debit) AS debit, 0 AS total,
			(SELECT SUM(l2.credit - l2.debit) FROM acc_transactions_lines l2
				INNER JOIN acc_transactions t2 ON t2.id = l2.id_transaction
				INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
				WHERE a2.position = %d AND l2.id_project = l.id_project AND t2.id_year = t.id_year) * -1 AS sum_expense,
			(SELECT SUM(l2.credit - l2.debit) FROM acc_transactions_lines l2
				INNER JOIN acc_transactions t2 ON t2.id = l2.id_transaction
				INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
				WHERE a2.position = %d AND l2.id_project = l.id_project AND t2.id_year = t.id_year) AS sum_revenue
			FROM acc_projects p
			%s JOIN acc_transactions_lines l ON p.id = l.id_project
			%3$s JOIN acc_transactions t ON t.id = l.id_transaction
			%3$s JOIN acc_years y ON y.id = t.id_year
			GROUP BY %s
			ORDER BY p.archived, %s;';

		$order = 'p.code COLLATE NOCASE, p.label COLLATE U_NOCASE';

		if ($by_year) {
			$group = 'y.id, p.id';
			$order = 'y.start_date DESC, ' . $order;
		}
		else {
			$group = 'p.id, y.id';
			$order = $order . ', y.id';
		}

		$sql = sprintf($sql, Account::EXPENSE, Account::REVENUE, $join, $group, $order);

		$current = null;

		static $sums = ['credit', 'debit', 'sum', 'sum_expense', 'sum_revenue'];

		$total = function (\stdClass $current, bool $by_year) use ($sums)
		{
			$out = (object) [
				'label' => 'Total',
				'id_project' => $by_year ? null : $current->id,
				'id_year' => $by_year ? $current->id : null,
				'total' => 1,
			];

			foreach ($sums as $s) {
				$out->{$s} = $current->{$s};
			}

			return $out;
		};

		foreach (DB::getInstance()->iterate($sql) as $row) {
			$id = $by_year ? $row->id_year : $row->project_id;

			if (null !== $current && $current->selector !== $id) {
				if (count($current->items)) {
					$current->items[] = $total($current, $by_year);
				}

				yield $current;
				$current = null;
			}

			if (null === $current) {
				$current = (object) [
					'selector' => $id,
					'id' => $by_year ? $row->id_year : $row->id_project,
					'label' => $by_year ? $row->year_label : ($row->project_code  ? $row->project_code . ' — ' : '') . $row->project_label,
					'id_year' => $by_year ? $row->id_year : null,
					'description' => !$by_year ? $row->project_description : null,
					'archived' => !$by_year ? $row->archived : 0,
					'items' => [],
				];

				foreach ($sums as $s) {
					$current->$s = 0;
				}
			}

			if (null === $row->sum) {
				continue;
			}

			$row->label = !$by_year ? $row->year_label : ($row->project_code  ? $row->project_code . ' — ' : '') . $row->project_label;
			$current->items[] = $row;

			foreach ($sums as $s) {
				$current->$s += $row->$s;
			}
		}

		if ($current === null) {
			return;
		}

		if (count($current->items)) {
			$current->items[] = $total($current, $by_year);
		}

		yield $current;
	}

}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
















































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/Reports.php version [9fc7c83c35].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Utils;
use Garradin\CSV;
use Garradin\DB;
use KD2\DB\EntityManager;

class Reports
{
	static public function getWhereClause(array $criterias, string $transactions_alias = '', string $lines_alias = '', string $accounts_alias = ''): string
	{
		$where = [];
		$db = DB::getInstance();

		$transactions_alias = $transactions_alias ? $transactions_alias . '.' : '';
		$lines_alias = $lines_alias ? $lines_alias . '.' : '';
		$accounts_alias = $accounts_alias ? $accounts_alias . '.' : '';

		if (!empty($criterias['year'])) {
			$where[] = sprintf($transactions_alias . 'id_year = %d', $criterias['year']);
		}

		if (!empty($criterias['position'])) {
			$criterias['position'] = array_map('intval', (array)$criterias['position']);
			$where[] = sprintf($accounts_alias . 'position IN (%s)', implode(',', $criterias['position']));
		}

		if (!empty($criterias['exclude_position'])) {
			$criterias['exclude_position'] = array_map('intval', (array)$criterias['exclude_position']);
			$where[] = sprintf($accounts_alias . 'position NOT IN (%s)', implode(',', $criterias['exclude_position']));
		}

		if (!empty($criterias['type'])) {
			$criterias['type'] = array_map('intval', (array)$criterias['type']);
			$where[] = sprintf($accounts_alias . 'type IN (%s)', implode(',', $criterias['type']));
		}

		if (!empty($criterias['type_or_bookmark'])) {
			$criterias['type'] = array_map('intval', (array)$criterias['type_or_bookmark']);
			$where[] = sprintf('(%stype IN (%s) OR %1$sbookmark = 1)', $accounts_alias, implode(',', $criterias['type']));
		}

		if (!empty($criterias['exclude_type'])) {
			$criterias['exclude_type'] = array_map('intval', (array)$criterias['exclude_type']);
			$where[] = sprintf($accounts_alias . 'type NOT IN (%s)', implode(',', $criterias['exclude_type']));
		}

		if (!empty($criterias['user'])) {
			$where[] = sprintf($transactions_alias . 'id IN (SELECT id_transaction FROM acc_transactions_users WHERE id_user = %d)', $criterias['user']);
		}

		if (!empty($criterias['creator'])) {
			$where[] = sprintf($transactions_alias . 'id_creator = %d', $criterias['creator']);
		}

		if (!empty($criterias['subscription'])) {
			$where[] = sprintf($transactions_alias . 'id IN (SELECT tu.id_transaction FROM acc_transactions_users tu WHERE id_service_user = %d)', $criterias['subscription']);
		}

		if (!empty($criterias['project'])) {
			$where[] = sprintf($lines_alias . 'id_project = %d', $criterias['project']);
		}

		if (!empty($criterias['account'])) {
			$where[] = sprintf($accounts_alias . 'id = %d', $criterias['account']);
		}

		if (!empty($criterias['projects_only'])) {
			$where[] = $lines_alias . 'id_project IS NOT NULL';
		}

		if (!empty($criterias['has_type'])) {
			$where[] = $accounts_alias . 'type != 0';
		}

		if (!empty($criterias['before']) && $criterias['before'] instanceof \DateTimeInterface) {
			$where[] = 'date <= ' . $db->quote($criterias['before']->format('Y-m-d'));
		}

		if (!empty($criterias['after']) && $criterias['after'] instanceof \DateTimeInterface) {
			$where[] = 'date >= ' . $db->quote($criterias['after']->format('Y-m-d'));
		}

		if (!count($where)) {
			throw new \LogicException('No criteria was provided.');
		}

		return implode(' AND ', $where);
	}

	static public function countTransactions(array $criterias): int
	{
		$where = self::getWhereClause($criterias);
		return DB::getInstance()->firstColumn('SELECT COUNT(DISTINCT t.id)
			FROM acc_transactions_lines l INNER JOIN acc_transactions t ON t.id = l.id_transaction WHERE ' .$where);
	}

	static public function getSumsPerYear(array $criterias): array
	{
		$where = self::getWhereClause($criterias);

		$sql = sprintf('SELECT y.id, y.start_date, y.end_date, y.label, SUM(b.balance) AS balance
			FROM acc_accounts_balances b
			INNER JOIN acc_years y ON y.id = b.id_year
			WHERE %s
			GROUP BY b.id_year ORDER BY y.end_date;', $where);

		return DB::getInstance()->getGrouped($sql);
	}

	static public function getSumsByInterval(array $criterias, int $interval)
	{
		$where = self::getWhereClause($criterias, 't', 'l', 'a');
		$where_interval = !empty($criterias['year']) ? sprintf(' WHERE id_year = %d', $criterias['year']) : '';

		$db = DB::getInstance();

		$sql = sprintf('SELECT
			strftime(\'%%s\', MIN(date)) / %d AS start_interval,
			strftime(\'%%s\', MAX(date)) / %1$d AS end_interval
			FROM acc_transactions %s;',
			$interval, $where_interval);

		$result = (array)$db->first($sql);
		extract($result);

		if (!isset($start_interval, $end_interval)) {
			return [];
		}

		$out = array_fill_keys(range($start_interval, $end_interval), 0);

		$sql = sprintf('SELECT strftime(\'%%s\', t.date) / %d AS interval, SUM(l.credit) - SUM(l.debit) AS sum, t.id_year
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
			INNER JOIN acc_accounts a ON a.id = l.id_account
			WHERE %s
			GROUP BY %s ORDER BY %3$s;', $interval, $where, isset($criterias['year']) ? 'interval' : 't.id_year, interval');

		$data = $db->getGrouped($sql);
		$sum = 0;
		$year = null;

		foreach ($out as $k => &$v) {
			if (array_key_exists($k, $data)) {
				$row = $data[$k];
				if ($row->id_year != $year) {
					$sum = 0;
					$year = $row->id_year;
				}

				$sum += $data[$k]->sum;
			}

			$v = $sum;
		}

		unset($v);

		return $out;
	}

	static public function getResult(array $criterias): int
	{
		if (!empty($criterias['project']) || !empty($criterias['projects_only'])
			|| !empty($criterias['before']) || !empty($criterias['after'])) {
			$where = self::getWhereClause($criterias, 't', 'l', 'a');
			$sql = self::getBalancesSQL(['inner_select' => 'l.id_project', 'inner_where' => $where]);
			$sql = sprintf('SELECT position, SUM(balance) FROM (%s) GROUP BY position;', $sql);
		}
		else {
			$where = self::getWhereClause($criterias);
			$sql = sprintf('SELECT position, SUM(balance) FROM acc_accounts_balances WHERE %s GROUP BY position;', $where);
		}

		$balances = DB::getInstance()->getAssoc($sql);

		return ($balances[Account::REVENUE] ?? 0) - ($balances[Account::EXPENSE] ?? 0);
	}

	static public function getBalancesSQL(array $parts = [])
	{
		return sprintf('SELECT %s id_year, id, label, code, type, debit, credit, position, %s, is_debt
			FROM (
				SELECT %s t.id_year, a.id, a.label, a.code, a.type,
					SUM(l.credit) AS credit,
					SUM(l.debit) AS debit,
					CASE -- 3 = dynamic asset or liability depending on balance
						WHEN position = 3 AND SUM(l.debit - l.credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
						WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
						ELSE position
					END AS position,
					CASE
						WHEN position IN (1, 4) -- 1 = asset, 4 = expense
							OR (position = 3 AND SUM(l.debit - l.credit) > 0)
						THEN
							SUM(l.debit - l.credit)
						ELSE
							SUM(l.credit - l.debit)
					END AS balance,
					CASE WHEN SUM(l.debit - l.credit) > 0 THEN 1 ELSE 0 END AS is_debt

				FROM acc_transactions_lines l
				INNER JOIN acc_transactions t ON t.id = l.id_transaction
				INNER JOIN acc_accounts a ON a.id = l.id_account
				%s
				%s
				GROUP BY %s
			)
			%s
			%s
			ORDER BY %s',
			isset($parts['select']) ? $parts['select'] . ',' : '',
			// SUM(balance) is important for grouping projects when id is different but code is the same
			isset($parts['group']) ? 'SUM(balance) AS balance' : 'balance',
			isset($parts['inner_select']) ? $parts['inner_select'] . ',' : '',
			$parts['inner_join'] ?? '',
			isset($parts['inner_where']) ? 'WHERE ' . $parts['inner_where'] : '',
			// Group by account code when multiple years are concerned
			$parts['inner_group'] ?? 'a.code, t.id_year',
			isset($parts['where']) ? 'WHERE ' . $parts['where'] : '',
			isset($parts['group']) ? 'GROUP BY ' . $parts['group'] : '',
			$order ?? 'code'
		);
	}

	/**
	 * Returns SQL query for accounts balances according to $criterias
	 * @param  array       $criterias   List of criterias, see self::getWhereClause
	 * @param  string|null $order       Order of rows (SQL clause), if NULL will order by CODE
	 * @param  bool        $remove_zero Remove accounts where the balance is zero from the list
	 */
	static protected function getAccountsBalancesInnerSQL(array $criterias, ?string $order = null, bool $remove_zero = true): string
	{
		$group = 'code';
		$having = '';

		if ($remove_zero) {
			$having = 'HAVING balance != 0';
		}

		$table = null;

		if (empty($criterias['project'])
			&& empty($criterias['projects_only'])
			&& empty($criterias['user'])
			&& empty($criterias['creator'])
			&& empty($criterias['subscription'])
			&& empty($criterias['before'])
			&& empty($criterias['after'])) {
			$table = 'acc_accounts_balances';
		}

		// Specific queries that can't rely on acc_accounts_balances
		if (!$table)
		{
			$where = null;

			// The position
			if (!empty($criterias['position'])) {
				$criterias['position'] = (array)$criterias['position'];

				if (in_array(Account::LIABILITY, $criterias['position'])
					|| in_array(Account::ASSET, $criterias['position'])) {
					$where = self::getWhereClause(['position' => $criterias['position']]);
					$criterias['position'][] = Account::ASSET_OR_LIABILITY;
				}
			}

			$inner_where = self::getWhereClause($criterias, 't', 'l', 'a');
			$remove_zero = $remove_zero ? ', ' . $remove_zero : '';
			$inner_group = empty($criterias['year']) ? 'a.code' : null;

			$sql = self::getBalancesSQL(['group' => 'code ' . $having] + compact('order', 'inner_where', 'where', 'inner_group'));
		}
		else {
			$where = self::getWhereClause($criterias);

			$query = 'SELECT id_year, id, label, code, type, SUM(debit) AS debit, SUM(credit) AS credit, position, SUM(balance) AS balance, is_debt FROM %s
				WHERE %s
				GROUP BY %s %s
				ORDER BY %s';

			$sql = sprintf($query, $table, $where, $group, $having, $order);
		}

		return $sql;
	}

	/**
	 * Returns accounts balances according to $criterias
	 * @param  array       $criterias   List of criterias, see self::getWhereClause
	 * @param  string|null $order       Order of rows (SQL clause), if NULL will order by CODE
	 * @param  bool        $remove_zero Remove accounts where the balance is zero from the list
	 */
	static public function getAccountsBalances(array $criterias, ?string $order = null, bool $remove_zero = true): array
	{
		$db = DB::getInstance();
		$order = $order ?: 'code COLLATE NOCASE';

		$sql = self::getAccountsBalancesInnerSQL($criterias, $order, $remove_zero);

		// SQLite does not support OUTER JOIN yet :(
		if (isset($criterias['compare_year'])) {
			$criterias2 = array_merge($criterias, ['year' => $criterias['compare_year']]);
			$sql2 = self::getAccountsBalancesInnerSQL($criterias2, $order, true);

			// Create temporary tables to store data, so that the request is not too complex
			// and doesn't require to do the same SELECTs twice or more
			$table_name = md5(random_bytes(10));
			$db->begin();
			$db->exec(sprintf('
				CREATE TEMP TABLE acc_compare_a_%1$s (id_year, id, label, code, type, debit, credit, position, balance, is_debt);
				CREATE TEMP TABLE acc_compare_b_%1$s (id_year, id, label, code, type, debit, credit, position, balance, is_debt);
				INSERT INTO acc_compare_a_%1$s %2$s;
				INSERT INTO acc_compare_b_%1$s %3$s;',
				$table_name, $sql, $sql2));
			$db->commit();

			// The magic!
			// Here we are selecting the balances of year A, joining with year B
			// BUT to show the accounts used in year B but NOT in year A, we need to do this
			// UNION ALL to select accounts from year B which are NOT in year A
			$sql_union = 'SELECT a.id, a.code AS code, a.label, a.position, a.type, a.debit, a.credit, a.balance, IFNULL(b.balance, 0) AS balance2, IFNULL(a.balance - b.balance, a.balance) AS change
				FROM acc_compare_a_%1$s AS a
				LEFT JOIN acc_compare_b_%1$s AS b ON b.code = a.code AND a.position = b.position AND b.id_year = %2$d
				UNION ALL
				-- Select balances of second year accounts that are =zero in first year
				SELECT
					NULL AS id, c.code AS code, c.label, c.position, c.type, c.debit, c.credit, 0 AS balance, c.balance AS balance2, c.balance * -1 AS change
				FROM acc_compare_b_%1$s AS c
				LEFT JOIN acc_compare_a_%1$s AS d ON d.code = c.code AND d.balance != 0 AND d.position = c.position AND d.id_year = %3$d
				WHERE d.id IS NULL
				ORDER BY code COLLATE NOCASE;';

			$sql = sprintf($sql_union, $table_name, $criterias['compare_year'], $criterias['year']);
		}

		$out = $db->get($sql);

		return $out;
	}

	static public function getTrialBalance(array $criterias, bool $simple = false): \Iterator
	{
		unset($criterias['compare_year']);
		$out = self::getAccountsBalances($criterias, null, false);

		$sums = [
			'debit'      => 0,
			'credit'     => 0,
			'balance'    => null,
			'label'      => 'Total',
		];

		foreach ($out as $row) {
			if (!$simple) {
				$row->balance = $row->debit - $row->credit;
			}

			$sums['debit'] += $row->debit;
			$sums['credit'] += $row->credit;
			yield $row;
		}

		yield (object) $sums;
	}

	/**
	 * Return a table line with the year result
	 */
	static public function getResultLine(array $criterias): \stdClass
	{
		$balance = self::getResult($criterias);
		$balance2 = null;
		$change = null;
		$label = $balance > 0 ? 'Résultat de l\'exercice courant (excédent)' : 'Résultat de l\'exercice courant (perte)';

		if (!empty($criterias['compare_year'])) {
			$balance2 = self::getResult(array_merge($criterias, ['year' => $criterias['compare_year']]));
			$change = $balance - $balance2;
		}

		if (!empty($criterias['compare_year']) || $balance == 0) {
			$label = 'Résultat de l\'exercice';
		}

		return (object) compact('balance', 'balance2', 'label', 'change');
	}

	/**
	 * Return a table line with totals
	 */
	static public function getTotalLine(array $rows, string $label = 'Total'): \stdClass
	{
		$balance = 0;
		$balance2 = 0;
		$change = 0;

		foreach ($rows as $row) {
			$balance += $row->balance;
			$balance2 += $row->balance2 ?? 0;
			$change += $row->change ?? 0;
		}

		return (object) compact('label', 'balance', 'balance2', 'change');
	}

	/**
	 * Statement / Compte de résultat
	 */
	static public function getStatement(array $criterias): \stdClass
	{
		$out = new \stdClass;

		$out->caption_left = 'Charges';
		$out->caption_right = 'Produits';
		$total_left = 'Total charges';
		$total_right = 'Total produits';

		$out->body_left = self::getAccountsBalances($criterias + ['position' => Account::EXPENSE]);
		$out->body_right = self::getAccountsBalances($criterias + ['position' => Account::REVENUE]);

		$out->foot_left = [self::getTotalLine($out->body_left, $total_left)];
		$out->foot_right = [self::getTotalLine($out->body_right, $total_right)];

		$r = self::getResultLine($criterias);

		if ($r->balance < 0) {
			// Deficit should go to expense column
			$out->foot_left[] = $r;
		}
		else {
			$out->foot_right[] = $r;
		}

		return $out;
	}

	static public function getVolunteeringStatement(array $criterias, \stdClass $general_statement): \stdClass
	{
		$out = new \stdClass;

		$criterias_all = $criterias + ['type' => [Account::TYPE_VOLUNTEERING_EXPENSE, Account::TYPE_VOLUNTEERING_REVENUE]];

		$out->caption_left = 'Emplois des contributions';
		$out->caption_right = 'Sources des contributions';

		$out->body_left = self::getAccountsBalances($criterias_all + ['position' => Account::EXPENSE]);
		$out->body_right = self::getAccountsBalances($criterias_all + ['position' => Account::REVENUE]);

		$out->foot_left = [
			self::getTotalLine($out->body_left, 'Total emplois'),
			self::getTotalLine(array_merge($out->body_left, $general_statement->body_left), 'Total charges et emplois'),
		];
		$out->foot_right = [
			self::getTotalLine($out->body_right, 'Total sources'),
			self::getTotalLine(array_merge($out->body_right, $general_statement->body_right), 'Total produits et sources'),
		];

		return $out;
	}

	/**
	 * Bilan / Balance sheet
	 */
	static public function getBalanceSheet(array $criterias): \stdClass
	{
		$out = new \stdClass;

		$out->caption_left = 'Actif';
		$out->caption_right = 'Passif';

		$out->body_left = self::getAccountsBalances($criterias + ['position' => Account::ASSET]);
		$out->body_right = self::getAccountsBalances($criterias + ['position' => Account::LIABILITY]);

		// Append result to liability
		$r = self::getResultLine($criterias);
		$out->body_right[] = $r;

		// Calculate the total sum for assets and liabilities
		$out->foot_left = [self::getTotalLine($out->body_left, 'Total actif')];
		$out->foot_right = [self::getTotalLine($out->body_right, 'Total passif')];

		return $out;
	}

	/**
	 * Return list of favorite accounts (accounts with a type), grouped by type, with their current sum
	 * @return array list of accounts grouped by type
	 */
	static public function getClosingSumsFavoriteAccounts(array $criterias): array
	{
		$types = Account::COMMON_TYPES;
		$accounts = self::getAccountsBalances($criterias + ['type_or_bookmark' => $types], 'type, code COLLATE NOCASE', false);

		$out = [];

		foreach ($types as $type) {
			$out[$type] = (object) [
				'label'    => Account::TYPES_NAMES[$type],
				'type'     => $type,
				'accounts' => [],
			];
		}

		$out[0] = (object) [
			'label'    => 'Autres',
			'type'     => 0,
			'accounts' => [],
		];

		foreach ($accounts as $row) {
			$t = in_array($row->type, $types, true) ? $row->type : 0;
			$out[$t]->accounts[] = $row;
		}

		foreach ($out as $t => $group) {
			if (!count($group->accounts)) {
				unset($out[$t]);
			}
		}

		return $out;
	}

	/**
	 * Grand livre
	 */
	static public function getGeneralLedger(array $criterias): \Generator
	{
		$where = self::getWhereClause($criterias);

		$db = DB::getInstance();

		if (!empty($criterias['projects_only'])) {
			$join = 'acc_projects a ON a.id = l.id_project';
		}
		else {
			$join = 'acc_accounts a ON a.id = l.id_account';
		}

		$sql = sprintf('SELECT
			t.id_year, a.id AS id_account, t.id, t.date, t.reference,
			l.debit, l.credit, l.reference AS line_reference, t.label, l.label AS line_label,
			a.label AS account_label, a.code AS account_code
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
			INNER JOIN %s
			WHERE %s
			ORDER BY a.code COLLATE U_NOCASE, t.date, t.id;', $join, $where);

		$account = null;
		$debit = $credit = 0;

		foreach ($db->iterate($sql) as $row) {
			if (null !== $account && $account->id != $row->id_account) {
				yield $account;
				$account = null;
			}

			if (null === $account) {
				$account = (object) [
					'code'  => $row->account_code,
					'label' => $row->account_label,
					'id'    => $row->id_account,
					'id_year' => $row->id_year,
					'sum'   => 0,
					'debit' => 0,
					'credit'=> 0,
					'lines' => [],
				];
			}

			$row->date = \DateTime::createFromFormat('Y-m-d', $row->date);

			$account->sum += ($row->credit - $row->debit);
			$account->debit += $row->debit;
			$account->credit += $row->credit;
			$debit += $row->debit;
			$credit += $row->credit;
			$row->running_sum = $account->sum;

			unset($row->account_code, $row->account_label, $row->id_account, $row->id_year);

			$account->lines[] = $row;
		}

		if (null === $account) {
			return;
		}

		$account->all_debit = $debit;
		$account->all_credit = $credit;

		yield $account;
	}

	static public function getJournal(array $criterias): \Generator
	{
		$where = self::getWhereClause($criterias, 't', 'l', 'a');

		$sql = sprintf('SELECT
			t.id_year, l.id_account, l.debit, l.credit, t.id, t.date, t.reference,
			l.reference AS line_reference, t.label, l.label AS line_label,
			a.label AS account_label, a.code AS account_code
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
			INNER JOIN acc_accounts a ON l.id_account = a.id
			WHERE %s ORDER BY t.date, t.id;', $where);

		$transaction = null;
		$db = DB::getInstance();

		foreach ($db->iterate($sql) as $row) {
			if (null !== $transaction && $transaction->id != $row->id) {
				yield $transaction;
				$transaction = null;
			}

			if (null === $transaction) {
				$transaction = (object) [
					'id'        => $row->id,
					'label'     => $row->label,
					'date'      => \DateTime::createFromFormat('Y-m-d', $row->date),
					'reference' => $row->reference,
					'lines'     => [],
				];
			}

			$transaction->lines[] = (object) [
				'account_label' => $row->account_label,
				'account_code'  => $row->account_code,
				'label'         => $row->line_label,
				'reference'     => $row->line_reference,
				'id_account'    => $row->id_account,
				'credit'        => $row->credit,
				'debit'         => $row->debit,
				'id_year'       => $row->id_year,
			];
		}

		if (null === $transaction) {
			return;
		}

		yield $transaction;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




























































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/Transactions.php version [fe8c92f672].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Project;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Utils;
use Garradin\UserException;

class Transactions
{
	static public function create(array $data): Transaction
	{
		$transaction = new Transaction;
		$transaction->importForm($data);
		return $transaction;
	}

	static public function get(int $id)
	{
		return EntityManager::findOneById(Transaction::class, $id);
	}

	static public function saveReconciled(\Generator $journal, ?array $checked)
	{
		if (null === $checked) {
			$checked = [];
		}

		$db = DB::getInstance();
		$db->begin();

		// Synchro des trucs cochés
		$st = $db->prepare('UPDATE acc_transactions_lines SET reconciled = :r WHERE id = :id;');

		foreach ($journal as $row)
		{
			if (!isset($row->id_line)) {
				continue;
			}

			$st->bindValue(':id', (int)$row->id_line, \SQLITE3_INTEGER);
			$st->bindValue(':r', !empty($checked[$row->id_line]) ? 1 : 0, \SQLITE3_INTEGER);
			$st->execute();
		}

		$db->commit();
	}

	static public function saveDeposit(Transaction $transaction, \Generator $journal, array $checked)
	{
		$db = DB::getInstance();
		$db->begin();

		try {
			$ids = [];
			foreach ($journal as $row) {
				if (!array_key_exists($row->id_line, $checked)) {
					continue;
				}

				$ids[] = (int)$row->id;

				$line = new Line;
				$line->importForm([
					'reference'  => $row->line_reference,
					'label'      => $row->line_label ?? $row->label,
					'id_account' => $row->id_account,
					'id_project' => $row->id_project,
				]);

				$line->credit = $row->debit;

				$transaction->addLine($line);
			}

			$transaction->save();
			$ids = implode(',', $ids);
			$db->exec(sprintf('UPDATE acc_transactions SET status = (status | %d) WHERE id IN (%s);', Transaction::STATUS_DEPOSIT, $ids));
			$db->commit();
		}
		catch (\Exception $e) {
			$db->rollback();
			throw $e;
		}
	}

	static public function countForUser(int $user_id): int
	{
		return DB::getInstance()->count('acc_transactions_users', 'id_user = ?', $user_id);
	}

	static public function countForCreator(int $user_id): int
	{
		return DB::getInstance()->count('acc_transactions', 'id_creator = ?', $user_id);
	}

	/**
	 * Returns a dynamic list of all waiting credit and debt transactions for closed years
	 */
	static public function listPendingCreditAndDebtForClosedYears(): DynamicList
	{
		$columns = Account::LIST_COLUMNS;

		unset($columns['line_label'], $columns['sum'], $columns['debit'], $columns['credit']);
		unset($columns['project_code'], $columns['id_project'], $columns['line_reference']);

		$columns['change']['select'] = 'SUM(l.credit)';
		$columns['change']['label'] = 'Montant';

		$columns = [
			'year_label' => [
				'select' => 'y.label',
				'label' => 'Exercice',
			],
			'type_label' => [
				'select' => 't.type',
				'label' => 'Type',
			]]
			+ $columns;

		$conditions = sprintf('y.closed = 1 AND t.status & %d AND t.type IN (%d, %d)',
			Transaction::STATUS_WAITING, Transaction::TYPE_CREDIT, Transaction::TYPE_DEBT);

		$tables = 'acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			INNER JOIN acc_years y ON y.id = t.id_year';

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		$list->setCount('COUNT(DISTINCT t.id)');
		$list->groupBy('t.id');
		$list->setModifier(function (&$row) {
			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);

			if (isset($row->type_label)) {
				$row->type_label = Transaction::TYPES_NAMES[(int)$row->type_label];
			}
		});

		$list->setExportCallback(function (&$row) {
			$row->change = Utils::money_format($row->change, '.', '', false);
		});

		$list->setTitle('Dettes et créances en attente');

		return $list;
	}

	static public function setProject(?int $id_project, ?array $transactions = null, ?array $lines = null)
	{
		$db = DB::getInstance();

		if (null !== $id_project && !$db->test(Project::TABLE, 'id = ?', $id_project)) {
			throw new \InvalidArgumentException('Invalid project ID');
		}

		if (isset($transactions, $lines) || ($transactions === null && $lines === null)) {
			throw new \BadMethodCallException('Only one of transactions or lines should be set');
		}

		$selection = array_map('intval', $transactions ?? $lines);
		$where = sprintf($transactions ? 'id_transaction IN (%s)' : 'id IN (%s)', implode(', ', $selection));

		return $db->exec(sprintf('UPDATE acc_transactions_lines SET id_project = %s WHERE %s;',
			(int)$id_project ?: 'NULL', $where));
	}

	static public function listByType(int $year_id, ?int $type): DynamicList
	{
		$reverse = 1;

		$columns = Account::LIST_COLUMNS;
		unset($columns['line_label'], $columns['sum'], $columns['debit'], $columns['credit']);
		$columns['line_reference']['label'] = 'Réf. paiement';
		$columns['change']['select'] = sprintf('SUM(l.credit) * %d', $reverse);
		$columns['change']['label'] = 'Montant';
		$columns['project_code']['select'] = 'GROUP_CONCAT(IFNULL(b.code, SUBSTR(b.label, 1, 10) || \'…\'), \',\')';
		$columns['id_project']['select'] = 'GROUP_CONCAT(l.id_project, \',\')';

		if ($type == Transaction::TYPE_CREDIT || $type == Transaction::TYPE_DEBT) {
			$db = DB::getInstance();

			$columns['status_label'] = [
				'label' => 'Statut',
				'select' => sprintf('CASE WHEN t.status & %d THEN %s WHEN t.status & %d THEN %s ELSE NULL END',
					Transaction::STATUS_WAITING, $db->quote('En attente'),
					Transaction::STATUS_PAID, $db->quote('Réglée')
				),
			];
		}

		if (!$type) {
			$columns = ['type_label' => [
					'select' => 't.type',
					'label' => 'Type d\'écriture',
				]]
				+ $columns;
		}

		$tables = 'acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_account
			LEFT JOIN acc_projects b ON b.id = l.id_project';
		$conditions = sprintf('t.id_year = %d', $year_id);

		if (null !== $type) {
			$conditions .= sprintf(' AND t.type = %s', $type);
		}

		$sum = 0;

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		$list->setCount('COUNT(DISTINCT t.id)');
		$list->groupBy('t.id');
		$list->setModifier(function (&$row) {
			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);

			$row->projects = [];

			if (isset($row->id_project, $row->project_code)) {
				$row->projects = array_combine(explode(',', $row->id_project), explode(',', $row->project_code));
			}

			if (isset($row->type_label)) {
				$row->type_label = Transaction::TYPES_NAMES[(int)$row->type_label];
			}
		});
		$list->setExportCallback(function (&$row) {
			$row->change = Utils::money_format($row->change, '.', '', false);
			$row->projects = implode(', ', $row->projects);
			unset($row->project_code, $row->id_project);
		});

		return $list;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<








































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Accounting/Years.php version [119db6c791].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use Garradin\Utils;
use Garradin\DB;
use KD2\DB\EntityManager;
use KD2\DB\Date;

class Years
{
	static public function get(int $year_id)
	{
		return EntityManager::findOneById(Year::class, $year_id);
	}

	static public function getCurrentOpenYear()
	{
		return EntityManager::findOne(Year::class, 'SELECT * FROM @TABLE WHERE closed = 0 ORDER BY start_date LIMIT 1;');
	}

	static public function getCurrentOpenYearId()
	{
		return EntityManager::getInstance(Year::class)->col('SELECT id FROM @TABLE WHERE closed = 0 ORDER BY start_date LIMIT 1;');
	}

	static public function listOpen($with_stats = false)
	{
		$db = EntityManager::getInstance(Year::class)->DB();
		$stats = $with_stats ? ', (SELECT COUNT(*) FROM acc_transactions WHERE id_year = acc_years.id) AS nb_transactions' : '';
		return $db->getGrouped(sprintf('SELECT id, * %s FROM acc_years WHERE closed = 0 ORDER BY end_date;', $stats));
	}

	static public function listOpenAssocExcept(int $id)
	{
		$db = EntityManager::getInstance(Year::class)->DB();
		return $db->getAssoc('SELECT id, label FROM acc_years WHERE closed = 0 AND id != ? ORDER BY end_date;', $id);
	}

	static public function listOpenAssoc()
	{
		$db = EntityManager::getInstance(Year::class)->DB();
		return $db->getAssoc('SELECT id, label FROM acc_years WHERE closed = 0 ORDER BY end_date DESC;');
	}

	static public function listAssoc()
	{
		return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years ORDER BY end_date;');
	}

	static public function listAssocExcept(int $id)
	{
		return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years WHERE id != ? ORDER BY end_date;', $id);
	}

	static public function listClosedAssoc()
	{
		return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years WHERE closed = 1 ORDER BY end_date;');
	}

	static public function listClosedAssocExcept(int $id)
	{
		return DB::getInstance()->getAssoc('SELECT id, label FROM acc_years WHERE closed = 1 AND id != ? ORDER BY end_date DESC;', $id);
	}

	static public function listClosed()
	{
		$em = EntityManager::getInstance(Year::class);
		return $em->all('SELECT * FROM @TABLE WHERE closed = 1 ORDER BY end_date;');
	}

	static public function countClosed()
	{
		return DB::getInstance()->count(Year::TABLE, 'closed = 1');
	}

	static public function count()
	{
		return DB::getInstance()->count(Year::TABLE);
	}

	static public function list(bool $reverse = false, ?int $except_id = null)
	{
		$desc = $reverse ? 'DESC' : '';
		$except = $except_id ? ' AND y.id != ' . (int)$except_id : '';
		$sql = sprintf('SELECT y.*,
			(SELECT COUNT(*) FROM acc_transactions WHERE id_year = y.id) AS nb_transactions,
			c.label AS chart_name
			FROM acc_years y
			INNER JOIN acc_charts c ON c.id = y.id_chart
			WHERE 1 %s
			ORDER BY end_date %s;', $except, $desc);
		return DB::getInstance()->get($sql);
	}

	static public function listLastTransactions(int $count, array $years): array
	{
		$out = [];

		foreach ($years as $year) {
			$out[$year->id] = Transactions::listByType($year->id, null);
			$out[$year->id]->setPageSize($count);
			$out[$year->id]->orderBy('id', true);
		}

		return $out;
	}

	static public function getNewYearDates(): array
	{
		$last_year = EntityManager::findOne(Year::class, 'SELECT * FROM @TABLE ORDER BY end_date DESC LIMIT 1;');

		if ($last_year) {
			$start_date = clone $last_year->start_date;
			$start_date->modify('+1 year');

			$end_date = clone $last_year->end_date;
			$end_date->modify('+1 year');
		}
		else {
			$start_date = new Date('January 1st');
			$end_date = new Date('December 31');
		}

		return [$start_date, $end_date];
	}

	/**
	 * Crée une écriture d'affectation automatique
	 * @param  Year   $year
	 * @return Transaction|null
	 */
	static public function makeAppropriation(Year $year): ?Transaction
	{
		$db = DB::getInstance();
		$balances = $db->getGrouped('SELECT a.type, a.id, SUM(l.credit) - SUM(l.debit) AS balance
			FROM acc_accounts a
			INNER JOIN acc_transactions_lines l ON l.id_account = a.id
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND (a.type = ? OR a.type = ?) GROUP BY a.type;',
			$year->id, Account::TYPE_NEGATIVE_RESULT, Account::TYPE_POSITIVE_RESULT
		);

		if (!count($balances)) {
			return null;
		}

		$appropriation_account = $db->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;',
			Account::TYPE_APPROPRIATION_RESULT, $year->id_chart);

		if (!$appropriation_account) {
			return null;
		}

		$t = new Transaction;
		$t->type = $t::TYPE_ADVANCED;
		$t->id_year = $year->id();
		$t->label = 'Affectation automatique du résultat';
		$t->notes = 'Le résultat a été affecté automatiquement lors de la balance d\'ouverture';
		$t->date = $year->start_date;
		$t->addStatus($t::STATUS_OPENING_BALANCE);

		$sum = 0;

		if (!empty($balances[Account::TYPE_NEGATIVE_RESULT])) {
			$account = $balances[Account::TYPE_NEGATIVE_RESULT];

			$line = Line::create($account->id, abs($account->balance), 0);
			$t->addLine($line);

			$sum += abs($account->balance);
		}

		if (!empty($balances[Account::TYPE_POSITIVE_RESULT])) {
			$account = $balances[Account::TYPE_POSITIVE_RESULT];

			$line = Line::create($account->id, 0, abs($account->balance));
			$t->addLine($line);

			$sum -= abs($account->balance);
		}

		if ($sum == 0) {
			return null;
		}

		if ($sum > 0) {
			$line = Line::create($appropriation_account, 0, $sum);
		}
		elseif ($sum < 0) {
			$line = Line::create($appropriation_account, abs($sum), 0);
		}

		$t->addLine($line);

		return $t;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




















































































































































































































































































































































































































Deleted src/include/lib/Garradin/CSV.php version [6e243dfec5].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
<?php

namespace Garradin;

use KD2\Office\Calc\Writer as ODSWriter;

use KD2\HTML\TableExport;

class CSV
{
	/**
	 * Convert a file to CSV if required (and if CALC_CONVERT_COMMAND is set)
	 */
	static public function convertUploadIfRequired(string $path, bool $delete_original = false): string
	{
		if (!CALC_CONVERT_COMMAND) {
			return $path;
		}

		$mime = @mime_content_type($path);

		// XLSX
		if ($mime == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
			$ext = 'xlsx';
		}
		elseif ($mime == 'application/vnd.ms-excel') {
			$ext = 'xls';
		}
		elseif ($mime == 'application/vnd.oasis.opendocument.spreadsheet') {
			$ext = 'ods';
		}
		// Assume raw CSV
		else {
			return $path;
		}

		$r = md5(random_bytes(10));
		$a = sprintf('%s/convert_%s.%s', CACHE_ROOT, $r, $ext);
		$b = sprintf('%s/convert_%s.csv', CACHE_ROOT, $r);
		$is_upload = is_uploaded_file($path);

		try {
			if ($is_upload) {
				move_uploaded_file($path, $a);
			}
			else {
				copy($path, $a);
			}

			self::convertXLSX($a, $b);

			return $b;
		}
		finally {
			if ($delete_original) {
				@unlink($a);
			}
		}
	}

	static public function convertXLSX(string $from, string $to): string
	{
		$tool = substr(CALC_CONVERT_COMMAND, 0, strpos(CALC_CONVERT_COMMAND, ' ') ?: strlen(CALC_CONVERT_COMMAND));

		if ($tool == 'unoconv') {
			$cmd = CALC_CONVERT_COMMAND . ' -i FilterOptions=44,34,76 -o %2$s %1$s';
		}
		elseif ($tool == 'ssconvert') {
			$cmd = CALC_CONVERT_COMMAND . ' %1$s %2$s';
		}
		elseif ($tool == 'unoconvert') {
			$cmd = CALC_CONVERT_COMMAND . ' %1$s %2$s';
		}
		else {
			throw new \LogicException(sprintf('Conversion tool "%s" is not supported', $tool));
		}

		$cmd = sprintf($cmd, escapeshellarg($from), escapeshellarg($to));
		$cmd .= ' 2>&1';
		$return = shell_exec($cmd);

		if (!file_exists($to)) {
			throw new UserException('Impossible de convertir le fichier. Vérifier que le fichier est un format supporté.');
		}

		return $to;
	}

	static public function supportsXLSExport(): bool
	{
		return CALC_CONVERT_COMMAND ? true : false;
	}

	static public function readAsArray(string $path)
	{
		if (!file_exists($path) || !is_readable($path))
		{
			throw new \RuntimeException('Fichier inconnu : '.$path);
		}

		$fp = self::open($path);

		if (!$fp)
		{
			return false;
		}

		$delim = self::findDelimiter($fp);
		self::skipBOM($fp);

		$line = 0;
		$out = [];
		$nb_columns = null;

		while (!feof($fp))
		{
			$row = fgetcsv($fp, 4096, $delim);
			$line++;

			if (empty($row))
			{
				continue;
			}

			if (null === $nb_columns)
			{
				$nb_columns = count($row);
			}

			if (count($row) != $nb_columns)
			{
				throw new UserException('Erreur sur la ligne ' . $line . ' : incohérence dans le nombre de colonnes avec la première ligne.');
			}

			// Make sure the data is UTF-8 encoded
			$row = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $row);

			$out[$line] = $row;
		}

		fclose($fp);

		return $out;
	}

	static public function open(string $file)
	{
		return fopen($file, 'r');
	}

	static public function findDelimiter(&$fp)
	{
		$line = '';

		while ($line === '' && !feof($fp))
		{
			$line = fgets($fp, 4096);
		}

		if (strlen($line) >= 4095) {
			throw new UserException('Fichier CSV illisible : la première ligne est trop longue.');
		}

		// Delete the columns content
		$line = preg_replace('/".*?"/', '', $line);

		$delims = [
			';' => substr_count($line, ';'),
			',' => substr_count($line, ','),
			"\t"=> substr_count($line, "\t"),
			'|' => substr_count($line, '|'),
		];

		arsort($delims);
		reset($delims);

		rewind($fp);

		return key($delims);
	}

	static public function skipBOM(&$fp)
	{
		// Skip BOM
		if (fgets($fp, 4) !== chr(0xEF) . chr(0xBB) . chr(0xBF))
		{
			fseek($fp, 0);
		}
	}

	static public function row($row): string
	{
		$row = (array) $row;

		array_walk($row, function (&$field) {
			$field = strtr((string) $field, ['"' => '""', "\r\n" => "\n"]);
		});

		return sprintf("\"%s\"\r\n", implode('","', $row));
	}

	static public function export(string $format, string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
	{
		if ('csv' == $format) {
			self::toCSV(... array_slice(func_get_args(), 1));
		}
		elseif ('xlsx' == $format && CALC_CONVERT_COMMAND) {
			self::toXLSX(... array_slice(func_get_args(), 1));
		}
		elseif ('ods' == $format) {
			self::toODS(... array_slice(func_get_args(), 1));
		}
		else {
			throw new \InvalidArgumentException('Unknown export format');
		}
	}

	static public function exportHTML(string $format, string $html, string $name = 'Export'): void
	{
		$css = file_get_contents(ROOT . '/www/admin/static/styles/06-tables-export.css');
		TableExport::download($format, $name, $html, $css);
		exit;
	}

	static protected function rowToArray($row, ?callable $row_map_callback)
	{
		if (null !== $row_map_callback) {
			call_user_func_array($row_map_callback, [&$row]);
		}

		if (is_object($row) && $row instanceof Entity) {
			$row = $row->asArray();
		}
		elseif (is_object($row)) {
			$row = (array) $row;
		}

		foreach ($row as $key => &$v) {
			if ((is_object($v) && !($v instanceof \DateTimeInterface)) || is_array($v)) {
				throw new \UnexpectedValueException(sprintf('Unexpected value for "%s": %s', $key, gettype($v)));
			}
		}

		return $row;
	}

	static public function toCSV(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null, string $output = null): void
	{
		if (null === $output) {
			header('Content-type: application/csv');
			header(sprintf('Content-Disposition: attachment; filename="%s.csv"', $name));

			$fp = fopen('php://output', 'w');
		}
		else {
			$fp = fopen($output, 'w');
		}

		if ($header) {
			fputs($fp, self::row($header));
		}

		if (!($iterator instanceof \Iterator) || $iterator->valid()) {
			foreach ($iterator as $row) {
				$row = self::rowToArray($row, $row_map_callback);

				foreach ($row as $key => &$v) {
					if (is_object($v) && $v instanceof \DateTimeInterface) {
						if ($v->format('His') == '000000') {
							$v = $v->format('d/m/Y');
						}
						else {
							$v = $v->format('d/m/Y H:i:s');
						}
					}
				}

				if (!$header)
				{
					fputs($fp, self::row(array_keys($row)));
					$header = true;
				}

				fputs($fp, self::row($row));
			}
		}

		fclose($fp);
	}

	static public function toODS(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null, string $output = null): void
	{
		if (null === $output) {
			header('Content-type: application/vnd.oasis.opendocument.spreadsheet');
			header(sprintf('Content-Disposition: attachment; filename="%s.ods"', $name));
		}

		$ods = new ODSWriter;
		$ods->table_name = $name;

		if ($header) {
			$ods->add((array) $header);
		}

		if (!($iterator instanceof \Iterator) || $iterator->valid()) {
			foreach ($iterator as $row) {
				$row = self::rowToArray($row, $row_map_callback);

				if (!$header)
				{
					$ods->add(array_keys($row));
					$header = true;
				}

				$ods->add((array) $row);
			}
		}

		$ods->output($output);
	}

	static public function toXLSX(string $name, iterable $iterator, ?array $header = null, ?callable $row_map_callback = null): void
	{
		if (!CALC_CONVERT_COMMAND) {
			throw new \LogicException('CALC_CONVERT_COMMAND is not set');
		}

		$tmpfile1 = sprintf('%s/export_%s.ods', STATIC_CACHE_ROOT, md5(random_bytes(10)));
		$tmpfile2 = substr($tmpfile1, 0, -3) . 'xlsx';

		try {
			self::toODS($name, $iterator, $header, $row_map_callback, $tmpfile1);

			self::convertXLSX($tmpfile1, $tmpfile2);

			header('Content-type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
			header(sprintf('Content-Disposition: attachment; filename="%s.xlsx"', $name));

			readfile($tmpfile2);
		}
		finally {
			@unlink($tmpfile1);
			@unlink($tmpfile2);
		}
	}

	static public function importUpload(array $file, array $expected_columns): \Generator
	{
		if (empty($file['size']) || empty($file['tmp_name'])) {
			throw new UserException('Fichier invalide');
		}

		return self::import($file['tmp_name'], $expected_columns);
	}

	static public function import(string $file, ?array $columns = null, array $required_columns = []): \Generator
	{
		$delete_after = is_uploaded_file($file);
		$file = self::convertUploadIfRequired($file, $delete_after);

		try {
			$fp = fopen($file, 'r');

			if (!$fp) {
				throw new UserException('Le fichier ne peut être ouvert');
			}

			// Find the delimiter
			$delim = self::findDelimiter($fp);
			self::skipBOM($fp);

			$line = 0;

			$header = fgetcsv($fp, 4096, $delim);

			// Make sure the data is UTF-8 encoded
			$header = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $header);

			$columns_map = [];

			if (null === $columns) {
				$columns_map = $header;
			}
			else {
				$columns_is_list = is_int(key($columns));

				// Check for columns
				foreach ($header as $key => $label) {
					// try to find with string key
					if (!$columns_is_list && array_key_exists($label, $columns)) {
						$columns_map[] = $label;
					}
					// Or with label
					elseif (in_array($label, $columns)) {
						$columns_map[] = $columns_is_list ? $label : array_search($label, $columns);
					}
					else {
						$columns_map[] = null;
					}
				}
			}

			foreach ($required_columns as $key) {
				if (!in_array($key, $columns_map)) {
					throw new UserException(sprintf('La colonne "%s" est absente du fichier importé', $columns[$key] ?? $key));
				}
			}

			while (!feof($fp))
			{
				$row = fgetcsv($fp, 4096, $delim);
				$line++;

				// Empty line, skip
				if (empty($row)) {
					continue;
				}

				if (count($row) != count($header))
				{
					throw new UserException('Erreur sur la ligne ' . $line . ' : le nombre de colonnes est incorrect.');
				}

				// Make sure the data is UTF-8 encoded
				$row = array_map(fn ($a) => Utils::utf8_encode(trim($a)), $row);

				$row = array_combine($columns_map, $row);

				yield $line => $row;
			}

			fclose($fp);
		}
		finally {
			if ($delete_after) {
				@unlink($file);
			}
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
















































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/CSV_Custom.php version [5243427abe].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
<?php

namespace Garradin;

use KD2\UserSession;

class CSV_Custom
{
	protected $session;
	protected $key;
	protected $csv;
	protected $translation;
	protected $columns;
	protected $columns_defaults;
	protected array $mandatory_columns = [];
	protected int $skip = 1;
	protected $modifier = null;
	protected array $_default;

	public function __construct(UserSession $session, string $key)
	{
		$this->session = $session;
		$this->key = $key;
		$this->csv = $this->session->get($this->key);
		$this->translation = $this->session->get($this->key . '_translation') ?: [];
		$this->skip = $this->session->get($this->key . '_skip') ?? 1;
	}

	public function load(array $file): void
	{
		if (empty($file['size']) || empty($file['tmp_name']) || empty($file['name'])) {
			throw new UserException('Fichier invalide');
		}

		$path = $file['tmp_name'];

		if (CALC_CONVERT_COMMAND && strtolower(substr($file['name'], -4)) != '.csv') {
			$path = CSV::convertUploadIfRequired($path, true);
		}

		$csv = CSV::readAsArray($path);

		if (!count($csv)) {
			throw new UserException('Ce fichier est vide (aucune ligne trouvée).');
		}

		$this->session->set($this->key, $csv);
		$this->session->save();

		@unlink($path);
	}

	public function iterate(): \Generator
	{
		if (empty($this->csv)) {
			throw new \LogicException('No file has been loaded');
		}

		if (!$this->columns || !$this->translation) {
			throw new \LogicException('Missing columns or translation table');
		}

		for ($i = 0; $i < count($this->csv); $i++) {
			if ($i < $this->skip) {
				continue;
			}

			yield $i+1 => $this->getLine($i + 1);
		}
	}

	public function getLine(int $i): ?\stdClass
	{
		if (!isset($this->csv[$i])) {
			return null;
		}

		if (!isset($this->_default)) {
			$this->_default = array_map(function ($a) { return null; }, array_flip($this->translation));
		}

		$row = $this->_default;

		foreach ($this->csv[$i] as $col => $value) {
			if (!isset($this->translation[$col])) {
				continue;
			}

			$row[$this->translation[$col]] = trim($value);
		}

		$row = (object) $row;

		if (null !== $this->modifier) {
			try {
				$row = call_user_func($this->modifier, $row);
			}
			catch (UserException $e) {
				throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()));
			}
		}

		return $row;
	}

	public function getFirstLine(): array
	{
		if (!$this->loaded()) {
			throw new \LogicException('No file has been loaded');
		}

		return current($this->csv);
	}

	public function setModifier(callable $callback): void
	{
		$this->modifier = $callback;
	}

	public function getSelectedTable(?array $source = null): array
	{
		if (null === $source && isset($_POST['translation_table'])) {
			$source = $_POST['translation_table'];
		}
		elseif (null === $source) {
			$source = [];
		}

		$selected = $this->getFirstLine();

		foreach ($selected as $k => &$v) {
			if (isset($source[$k])) {
				$v = $source[$k];
			}
			elseif (isset($this->translation[$k])) {
				$v = $this->translation[$k];
			}
			elseif (false !== ($pos = array_search($v, $this->columns, true))) {
				$v = $pos;
			}
			else {
				$v = null;
			}
		}

		return $selected;
	}

	public function getTranslationTable(): ?array
	{
		return $this->translation;
	}

	public function setTranslationTable(array $table): void
	{
		if (!count($table)) {
			throw new UserException('Aucune colonne n\'a été sélectionnée');
		}

		$translation = [];

		foreach ($table as $csv => $target) {
			if (empty($target)) {
				continue;
			}

			if (!array_key_exists($target, $this->columns)) {
				throw new UserException('Colonne inconnue: ' . $target);
			}

			$translation[(int)$csv] = $target;
		}

		foreach ($this->mandatory_columns as $key) {
			if (!in_array($key, $translation, true)) {
				throw new UserException(sprintf('La colonne "%s" est obligatoire mais n\'a pas été sélectionnée ou n\'existe pas.', $this->columns[$key]));
			}
		}

		if (!count($translation)) {
			throw new UserException('Aucune colonne n\'a été sélectionnée');
		}

		$this->translation = $translation;

		$this->session->set($this->key . '_translation', $this->translation);
		$this->session->save();
	}

	public function clear(): void
	{
		$this->session->set($this->key, null);
		$this->session->set($this->key . '_translation', null);
		$this->session->set($this->key . '_skip', null);
		$this->session->save();
		$this->csv = null;
		$this->translation = null;
	}

	public function loaded(): bool
	{
		return null !== $this->csv;
	}

	public function ready(): bool
	{
		return $this->loaded() && !empty($this->translation);
	}

	public function count(): ?int
	{
		return null !== $this->csv ? count($this->csv) : null;
	}

	public function skip(int $count): void
	{
		$this->skip = $count;
		$this->session->set($this->key . '_skip', $count);
	}

	public function setColumns(array $columns, array $defaults = []): void
	{
		$this->columns = array_filter($columns);
		$this->columns_defaults = array_filter($defaults);
	}

	public function setMandatoryColumns(array $columns): void
	{
		$this->mandatory_columns = $columns;
	}

	public function getColumnsString(): string
	{
		if (!empty($this->columns_defaults)) {
			$c = array_intersect_key($this->columns_defaults, $this->columns);
		}
		else {
			$c = $this->columns;
		}

		return implode(', ', $c);
	}

	public function getMandatoryColumnsString(): string
	{
	if (!empty($this->columns_defaults)) {
			$c = array_intersect_key($this->columns_defaults, $this->columns);
		}
		else {
			$c = $this->columns;
		}

		return implode(', ', array_intersect_key($c, array_flip($this->getMandatoryColumns())));
	}

	public function getColumns(): array
	{
		return $this->columns;
	}

	public function getColumnsWithDefaults(): array
	{
		$out = [];

		foreach ($this->columns as $key => $label) {
			$out[] = compact('key', 'label') + ['match' => $this->columns_defaults[$key] ?? $label];
		}

		return $out;
	}

	public function getMandatoryColumns(): array
	{
		return $this->mandatory_columns;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<








































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Config.php version [6f4c0c0f4e].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Membres\Champs;

use KD2\SMTP;
use KD2\Graphics\Image;

class Config extends Entity
{
	const FILES = [
		'admin_background' => File::CONTEXT_CONFIG . '/admin_bg.png',
		'admin_homepage'   => File::CONTEXT_CONFIG . '/admin_homepage.skriv',
		'admin_css'        => File::CONTEXT_CONFIG . '/admin.css',
		'logo'             => File::CONTEXT_CONFIG . '/logo.png',
		'icon'             => File::CONTEXT_CONFIG . '/icon.png',
		'favicon'          => File::CONTEXT_CONFIG . '/favicon.png',
	];

	const FILES_TYPES = [
		'admin_background' => 'image',
		'admin_css'        => 'code',
		'admin_homepage'   => 'web',
		'logo'             => 'image',
		'icon'             => 'image',
		'favicon'          => 'image',
	];

	protected $nom_asso;
	protected $adresse_asso;
	protected $email_asso;
	protected $telephone_asso;
	protected $site_asso;

	protected $monnaie;
	protected $pays;

	protected $champs_membres;
	protected $categorie_membres;

	protected $frequence_sauvegardes;
	protected $nombre_sauvegardes;

	protected $champ_identifiant;
	protected $champ_identite;

	/**
	 * This setting means that when creating a new transaction, if analytical_set_all
	 * is TRUE then all lines will be affected
	 */
	protected $analytical_set_all;

	protected $last_chart_change;
	protected $last_version_check;

	protected $couleur1;
	protected $couleur2;

	protected $files = [];

	protected $site_disabled;

	protected $_types = [
		'nom_asso'              => 'string',
		'adresse_asso'          => '?string',
		'email_asso'            => 'string',
		'telephone_asso'        => '?string',
		'site_asso'             => '?string',

		'monnaie'               => 'string',
		'pays'                  => 'string',

		'champs_membres'        => Champs::class,

		'categorie_membres'     => 'int',

		'frequence_sauvegardes' => '?int',
		'nombre_sauvegardes'    => '?int',

		'champ_identifiant'     => 'string',
		'champ_identite'        => 'string',

		'analytical_set_all'    => '?bool',

		'last_chart_change'     => '?int',
		'last_version_check'    => '?string',

		'couleur1'              => '?string',
		'couleur2'              => '?string',

		'files'                 => 'array',

		'site_disabled'         => 'bool',
	];

	static protected $_instance = null;

	static public function getInstance()
	{
		return self::$_instance ?: self::$_instance = new self;
	}

	static public function deleteInstance()
	{
		self::$_instance = null;
	}

	public function __clone()
	{
		throw new \LogicException('Cannot clone config');
	}

	protected function __construct()
	{
		parent::__construct();

		$db = DB::getInstance();

		$config = $db->getAssoc('SELECT key, value FROM config ORDER BY key;');

		if (empty($config)) {
			return;
		}

		$default = array_fill_keys(array_keys($this->_types), null);
		$config = array_merge($default, $config);

		$config['champs_membres'] = new Champs($config['champs_membres']);

		foreach ($this->_types as $key => $type) {
			$value = $config[$key];

			if ($type[0] == '?' && $value === null) {
				continue;
			}
		}

		$this->load($config);

		$this->champs_membres = new Membres\Champs((string)$this->champs_membres);
	}

	public function save(bool $selfcheck = true): bool
	{
		if (!count($this->_modified)) {
			return true;
		}

		if ($selfcheck) {
			$this->selfCheck();
		}

		$values = $this->modifiedProperties(true);

		$db = DB::getInstance();
		$db->begin();

		foreach ($values as $key => $value)
		{
			$db->preparedQuery('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?);', $key, $value);
		}

		if (!empty($values['champ_identifiant'])) {
			// Regenerate login index
			$db->exec('DROP INDEX IF EXISTS users_id_field;');
			$config = Config::getInstance();
			$champs = $config->get('champs_membres');
			$champs->createIndexes();
		}

		$db->commit();

		if (isset($values['couleur1']) || isset($values['couleur2'])) {
			// Reset graph cache
			Static_Cache::clean(0);
		}

		$this->_modified = [];

		return true;
	}

	public function delete(): bool
	{
		throw new \LogicException('Cannot delete config');
	}

	public function importForm($source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		// N'enregistrer les couleurs que si ce ne sont pas les couleurs par défaut
		if (isset($source['couleur1'], $source['couleur2'])
			&& ($source['couleur1'] == ADMIN_COLOR1 && $source['couleur2'] == ADMIN_COLOR2))
		{
			$source['couleur1'] = null;
			$source['couleur2'] = null;
		}

		parent::importForm($source);
	}

	protected function _filterType(string $key, $value)
	{
		switch ($this->_types[$key]) {
			case 'int':
				return (int) $value;
			case 'bool':
				return (bool) $value;
			case 'string':
				return (string) $value;
			case Champs::class:
				if (!is_object($value) || !($value instanceof $this->_types[$key])) {
					throw new \InvalidArgumentException(sprintf('"%s" is not of type "%s"', $key, $this->_types[$key]));
				}
				return $value;
			default:
				throw new \InvalidArgumentException(sprintf('"%s" has unknown type "%s"', $key, $this->_types[$key]));
		}
	}

	public function selfCheck(): void
	{
		$this->assert(trim($this->nom_asso) != '', 'Le nom de l\'association ne peut rester vide.');
		$this->assert(trim($this->monnaie) != '', 'La monnaie ne peut rester vide.');
		$this->assert(trim($this->pays) != '' && Utils::getCountryName($this->pays), 'Le pays ne peut rester vide.');
		$this->assert(null === $this->site_asso || filter_var($this->site_asso, FILTER_VALIDATE_URL), 'L\'adresse URL du site web est invalide.');
		$this->assert(trim($this->email_asso) != '' && SMTP::checkEmailIsValid($this->email_asso, false), 'L\'adresse e-mail de l\'association est  invalide.');
		$this->assert($this->champs_membres instanceof Champs, 'Objet champs membres invalide');

		// Files
		$this->assert(count($this->files) == count(self::FILES));

		foreach ($this->files as $key => $value) {
			$this->assert(array_key_exists($key, self::FILES));
			$this->assert(is_int($value) || is_null($value));
		}

		$champs = $this->champs_membres;

		$this->assert(!empty($champs->get($this->champ_identite)), sprintf('Le champ spécifié pour identité, "%s" n\'existe pas', $this->champ_identite));
		$this->assert(!empty($champs->get($this->champ_identifiant)), sprintf('Le champ spécifié pour identifiant, "%s" n\'existe pas', $this->champ_identifiant));

		$db = DB::getInstance();

		// Check that this field is actually unique
		if (isset($this->_modified['champ_identifiant'])) {
			$sql = sprintf('SELECT (COUNT(DISTINCT %s COLLATE U_NOCASE) = COUNT(*)) FROM membres WHERE %1$s IS NOT NULL AND %1$s != \'\';', $this->champ_identifiant);
			$is_unique = (bool) $db->firstColumn($sql);

			$this->assert($is_unique, sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->champ_identifiant));
		}

		$this->assert($db->test('users_categories', 'id = ?', $this->categorie_membres), 'Catégorie de membres inconnue');
	}

	public function file(string $key): ?File
	{
		if (!isset(self::FILES[$key])) {
			throw new \InvalidArgumentException('Invalid file key: ' . $key);
		}

		if (empty($this->files[$key])) {
			return null;
		}

		return Files::get(self::FILES[$key]);
	}

	public function fileURL(string $key, string $params = ''): ?string
	{
		if (empty($this->files[$key])) {

			if ($key == 'favicon') {
				return ADMIN_URL . 'static/favicon.png';
			}
			elseif ($key == 'icon') {
				return ADMIN_URL . 'static/icon.png';
			}

			return null;
		}

		$params = $params ? $params . '&' : '';

		return BASE_URL . self::FILES[$key] . '?' . $params . substr(md5($this->files[$key]), 0, 10);
	}


	public function hasFile(string $key): bool
	{
		return $this->files[$key] ? true : false;
	}

	public function updateFiles(): void
	{
		$files = $this->files;

		foreach (self::FILES as $key => $path) {
			if ($f = Files::get($path)) {
				$files[$key] = $f->modified->getTimestamp();
			}
			else {
				$files[$key] = null;
			}
		}

		$this->set('files', $files);
	}

	public function setFile(string $key, ?string $value, bool $upload = false): ?File
	{
		$f = Files::get(self::FILES[$key]);
		$files = $this->files;
		$type = self::FILES_TYPES[$key];
		$path = self::FILES[$key];

		// NULL = delete file
		if (null === $value) {
			if ($f) {
				$f->delete();
			}

			$f = null;
		}
		elseif ($upload) {
			$f = File::upload(Utils::dirname($path), $value, Utils::basename($path));

			if ($type == 'image' && !$f->image) {
				$this->setFile($key, null);
				throw new UserException('Le fichier n\'est pas une image.');
			}

			// Force favicon format
			if ($key == 'favicon') {
				$format = 'png';
				$i = new Image($f->fullpath());
				$i->cropResize(32, 32);
				$f->setContent($i->output($format, true));
			}
			// Force icon format
			else if ($key == 'favicon') {
				$format = 'png';
				$i = new Image($f->fullpath());
				$i->cropResize(512, 512);
				$f->setContent($i->output($format, true));
			}
		}
		elseif ($f) {
			$f->setContent($value);
		}
		else {
			$f = File::createAndStore(Utils::dirname($path), Utils::basename($path), null, $value);
		}

		$files[$key] = $f ? $f->modified->getTimestamp() : null;
		$this->set('files', $files);

		return $f;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




























































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/DB.php version [b153f0e4eb].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
<?php

namespace Garradin;

use KD2\DB\SQLite3;
use KD2\DB\DB_Exception;
use KD2\ErrorManager;

class DB extends SQLite3
{
    /**
     * Application ID pour SQLite
     * @link https://www.sqlite.org/pragma.html#pragma_application_id
     */
    const APPID = 0x5da2d811;

    static protected $_instance = null;

    protected $_version = -1;

    static protected $unicode_patterns_cache = [];

    protected $_log_last = null;
    protected $_log_start = null;
    protected $_log_store = [];

    static public function getInstance()
    {
        if (null === self::$_instance) {
            self::$_instance = new DB('sqlite', ['file' => DB_FILE]);
        }

        return self::$_instance;
    }

    static public function deleteInstance()
    {
        self::$_instance = null;
    }

    private function __clone()
    {
        // Désactiver le clonage, car on ne veut qu'une seule instance
    }

    public function __construct(string $driver, array $params)
    {
        if (self::$_instance !== null) {
            throw new \LogicException('Cannot start instance');
        }

        parent::__construct($driver, $params);

        // Enable SQL debug log if configured
        if (SQL_DEBUG) {
            $this->callback = [$this, 'log'];
            $this->_log_start = microtime(true);
        }
    }

    public function __destruct()
    {
        parent::__destruct();

        if (null !== $this->callback) {
            $this->saveLog();
        }
    }

    /**
     * Disable logging if enabled
     * useful to disable logging when reloading log page
     */
    public function disableLog(): void {
        $this->callback = null;
        $this->_log_store = [];
    }

    /**
     * Saves the log in a different database at the end of the script
     */
    protected function saveLog(): void
    {
        if (!count($this->_log_store)) {
            return;
        }

        $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
        $db->exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, script TEXT, user TEXT);
            CREATE TABLE IF NOT EXISTS log (session INTEGER NOT NULL REFERENCES sessions (id), time INTEGER, duration INTEGER, sql TEXT, trace TEXT);');

        $user = $_SESSION['userSession']->id ?? null;

        $db->insert('sessions', ['script' => str_replace(ROOT, '', $_SERVER['SCRIPT_NAME']), 'user' => $user]);
        $id = $db->lastInsertId();

        $db->begin();

        foreach ($this->_log_store as $row) {
            $db->insert('log', array_merge($row, ['session' => $id]));
        }

        $db->commit();
        $db->close();
    }

    /**
     * Log current SQL query
     */
    protected function log(string $method, ?string $timing, $object, ...$params): void
    {
        if ($method != 'execute' && $method != 'exec') {
            return;
        }

        if ($timing == 'before') {
            $this->_log_last = microtime(true);
            return;
        }

        $now = microtime(true);
        $duration = round(($now - $this->_log_last) * 1000 * 1000);
        $time = round(($now - $this->_log_start) * 1000 * 1000);

        if ($method == 'execute') {
            $sql = $params[0]->getSQL(true);
        }
        else {
            $sql = $params[0];
        }

        $sql = preg_replace('/^\s+/m', '  ', $sql);

        $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
        $trace = '';

        foreach ($backtrace as $line) {
            if (!isset($line['file']) || in_array(basename($line['file']), ['DB.php', 'SQLite3.php']) || strstr($line['file'], 'lib/KD2')) {
                continue;
            }

            $file = isset($line['file']) ? str_replace(ROOT . '/', '', $line['file']) : '';

            $trace .= sprintf("%s:%d\n", $file, $line['line']);
        }

        $this->_log_store[] = compact('duration', 'time', 'sql', 'trace');
    }

    /**
     * Return a debug log session using its ID
     */
    static public function getDebugSession(int $id): ?\stdClass
    {
        $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
        $s = $db->first('SELECT * FROM sessions WHERE id = ?;', $id);

        if ($s) {
            $s->list = $db->get('SELECT * FROM log WHERE session = ? ORDER BY time;', $id);

            foreach ($s->list as &$row) {
                try {
                    $explain = DB::getInstance()->get('EXPLAIN QUERY PLAN ' . $row->sql);
                    $row->explain = '';

                    foreach ($explain as $e) {
                        $row->explain .= $e->detail . "\n";
                    }
                }
                catch (DB_Exception $e) {
                    $row->explain = 'Error: ' . $e->getMessage();
                }
            }
        }

        $db->close();

        return $s;
    }

    /**
     * Return the list of all debug sessions
     */
    static public function getDebugSessionsList(): array
    {
        $db = new SQLite3('sqlite', ['file' => SQL_DEBUG]);
        $s = $db->get('SELECT s.*, SUM(l.duration) AS duration, COUNT(l.rowid) AS count
            FROM sessions s
            INNER JOIN log l ON l.session = s.id
            GROUP BY s.id
            ORDER BY s.date DESC;');

        $db->close();

        return $s;
    }

    public function connect(): void
    {
        if (null !== $this->db) {
            return;
        }

        parent::connect();

        // Activer les contraintes des foreign keys
        $this->db->exec('PRAGMA foreign_keys = ON;');

        // 10 secondes
        $this->db->busyTimeout(10 * 1000);

        // Default to DELETE
        $mode = strtoupper(SQLITE_JOURNAL_MODE);
        $set_mode = $this->db->querySingle('PRAGMA journal_mode;');
        $set_mode = strtoupper($set_mode);

        if ($set_mode !== $mode) {
            // WAL = performance enhancement
            // see https://www.cs.utexas.edu/~jaya/slides/apsys17-sqlite-slides.pdf
            // https://ericdraken.com/sqlite-performance-testing/
            $this->exec(sprintf(
                'PRAGMA journal_mode = %s; PRAGMA synchronous = NORMAL; PRAGMA journal_size_limit = %d;',
                $mode,
                32 * 1024 * 1024
            ));
        }

        self::registerCustomFunctions($this->db);
    }

    static public function registerCustomFunctions($db)
    {
        $db->createFunction('dirname', [Utils::class, 'dirname']);
        $db->createFunction('basename', [Utils::class, 'basename']);
        $db->createFunction('like', [self::class, 'unicodeLike']);
        $db->createFunction('email_hash', [Entities\Users\Email::class, 'getHash']);
        $db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']);
    }

    public function version(): ?string
    {
        if (-1 === $this->_version) {
            $this->connect();
            $this->_version = self::getVersion($this->db);
        }

        return $this->_version;
    }

    static public function getVersion($db)
    {
        $v = (int) $db->querySingle('PRAGMA user_version;');
        $v = self::parseVersion($v);

        if (null === $v) {
            try {
                // For legacy version before 1.1.0
                $v = $db->querySingle('SELECT valeur FROM config WHERE cle = \'version\';');
            }
            catch (\Exception $e) {
                throw new \RuntimeException('Cannot find application version', 0, $e);
            }
        }

        return $v ?: null;
    }

    static public function parseVersion(int $v): ?string
    {
        if ($v > 0) {
            $major = intval($v / 1000000);
            $v -= $major * 1000000;
            $minor = intval($v / 10000);
            $v -= $minor * 10000;
            $release = intval($v / 100);
            $v -= $release * 100;
            $type = $v;

            if ($type == 0) {
                $type = '';
            }
            // Corrective release: 1.2.3.1
            elseif ($type > 75) {
                $type = '.' . ($type - 75);
            }
            // RC release
            elseif ($type > 50) {
                $type = '-rc' . ($type - 50);
            }
            // Beta
            elseif ($type > 25) {
                $type = '-beta' . ($type - 25);
            }
            // Alpha
            else {
                $type = '-alpha' . $type;
            }

            $v = sprintf('%d.%d.%d%s', $major, $minor, $release, $type);
        }

        return $v ?: null;
    }

    /**
     * Save version to database
     * rc, alpha, beta and corrective release (4th number) are limited to 24 versions each
     * @param string $version Version string, eg. 1.2.3-rc2
     */
    public function setVersion(string $version): void
    {
        if (!preg_match('/^(\d+)\.(\d+)\.(\d+)(?:(?:-(alpha|beta|rc)|\.)(\d+)|)?$/', $version, $match)) {
            throw new \InvalidArgumentException('Invalid version number: ' . $version);
        }

        $version = ($match[1] * 100 * 100 * 100) + ($match[2] * 100 * 100) + ($match[3] * 100);

        if (isset($match[5])) {
            if ($match[5] > 24) {
                throw new \InvalidArgumentException('Invalid version number: cannot have a 4th component larger than 24: ' . $version);
            }

            if ($match[4] == 'rc') {
                $version += $match[5] + 50;
            }
            elseif ($match[4] == 'beta') {
                $version += $match[5] + 25;
            }
            elseif ($match[4] == 'alpha') {
                $version += $match[5];
            }
            else {
                $version += $match[5] + 75;
            }
        }

        $this->db->exec(sprintf('PRAGMA user_version = %d;', $version));
    }

    public function beginSchemaUpdate()
    {
        $this->toggleForeignKeys(false);
        $this->begin();
    }

    public function commitSchemaUpdate()
    {
        $this->commit();
        $this->toggleForeignKeys(true);
    }

    public function lastErrorMsg()
    {
        return $this->db->lastErrorMsg();
    }

    /**
     * @see https://www.sqlite.org/lang_altertable.html
     */
    public function toggleForeignKeys($enable)
    {
        assert(is_bool($enable));

        $this->connect();

        if (!$enable) {
            $this->db->exec('PRAGMA legacy_alter_table = ON;');
            $this->db->exec('PRAGMA foreign_keys = OFF;');
        }
        else {
            $this->db->exec('PRAGMA legacy_alter_table = OFF;');
            $this->db->exec('PRAGMA foreign_keys = ON;');
        }
    }

    /**
     * This is a rewrite of SQLite LIKE function that is transforming
     * the pattern and the value to lowercase ascii, so that we can match
     * "émilie" with "emilie".
     *
     * This is probably not the best way to do that, but we have to resort to that
     * as ICU extension is rarely available.
     *
     * @see https://www.sqlite.org/c3ref/strlike.html
     * @see https://sqlite.org/src/file?name=ext/icu/icu.c&ci=trunk
     */
    static public function unicodeLike($pattern, $value, $escape = null) {
        if (null === $pattern || null === $value) {
            return false;
        }

        $escape ??= '\\';
        $pattern = str_replace('’', '\'', $pattern); // Normalize French apostrophe
        $value = str_replace('’', '\'', $value);

        $id = md5($pattern . $escape);

        // Build regexp
        if (!array_key_exists($id, self::$unicode_patterns_cache)) {
            // Match escaped special chars | special chars | unicode characters | other
            $regexp = '/!([%_!])|([%_!])|(\pL+)|(.+?)/iu';
            $regexp = str_replace('!', preg_quote($escape, '/'), $regexp);

            preg_match_all($regexp, $pattern, $parts, PREG_SET_ORDER);
            $pattern = '';

            foreach ($parts as $part) {
                // Append other characters
                if (isset($part[4])) {
                    $pattern .= preg_quote(strtolower($part[0]), '/');
                }
                // Append unicode
                elseif (isset($part[3])) {
                    $pattern .= preg_quote(Utils::unicodeCaseFold($part[3]), '/');
                }
                // Append .*
                elseif (isset($part[2]) && $part[2] == '%') {
                    $pattern .= '.*';
                }
                // Append .
                elseif (isset($part[2]) && $part[2] == '_') {
                    $pattern .= '.';
                }
                // Append escaped special character
                else {
                    $pattern .= preg_quote($part[1], '/');
                }
            }

            // Store pattern in cache
            $pattern = '/^' . $pattern . '$/im';
            self::$unicode_patterns_cache[$id] = $pattern;
        }

        $value = Utils::unicodeCaseFold($value);

        return (bool) preg_match(self::$unicode_patterns_cache[$id], $value);
    }
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/DynamicList.php version [6e9f9ec3ec].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
<?php

namespace Garradin;

class DynamicList implements \Countable
{
	protected $columns;
	protected $tables;
	protected $conditions;
	protected $group;
	protected $order;
	protected $modifier;
	protected $export_callback;
	protected $title = 'Liste';
	protected $count = 'COUNT(*)';
	protected $desc = true;
	protected $per_page = 100;
	protected $page = 1;

	private $count_result;

	public function __construct(array $columns, string $tables, string $conditions = '1')
	{
		$this->columns = $columns;
		$this->tables = $tables;
		$this->conditions = $conditions;
		$this->order = key($columns);
	}

	public function __isset($key)
	{
		return property_exists($this, $key);
	}

	public function __get($key)
	{
		return $this->$key;
	}

	public function setTitle(string $title) {
		$this->title = $title;
	}

	public function setModifier(callable $fn) {
		$this->modifier = $fn;
	}

	public function setExportCallback(callable $fn) {
		$this->export_callback = $fn;
	}

	public function setPageSize(?int $size) {
		$this->per_page = $size;
	}

	public function setConditions(string $conditions)
	{
		$this->conditions = $conditions;
	}

	public function orderBy(string $key, bool $desc)
	{
		if (!array_key_exists($key, $this->columns)) {
			throw new UserException('Invalid order: ' . $key);
		}

		$this->order = $key;
		$this->desc = $desc;
	}

	public function groupBy(string $value)
	{
		$this->group = $value;
	}

	public function count(): int
	{
		if (null === $this->count_result) {
			$sql = sprintf('SELECT %s FROM %s WHERE %s;', $this->count, $this->tables, $this->conditions);
			$this->count_result = DB::getInstance()->firstColumn($sql);
		}

		return (int) $this->count_result;
	}

	public function export(string $name, string $format = 'csv')
	{
		$this->setPageSize(null);
		$columns = [];

		foreach ($this->columns as $key => $column) {
			if (empty($column['label'])) {
				$columns[] = $key;
				continue;
			}

			$columns[] = $column['label'];
		}

		CSV::export($format, $name, $this->iterate(false), $this->getExportHeaderColumns(), $this->export_callback);
	}

	public function asArray(): array
	{
		$out = [];

		foreach ($this->iterate(true) as $row) {
			$out[] = $row;
		}

		return $out;
	}

	public function paginationURL()
	{
		return Utils::getModifiedURL('?p=[ID]');
	}

	public function orderURL(string $order, bool $desc)
	{
		$query = array_merge($_GET, ['o' => $order, 'd' => (int) $desc]);
		$url = Utils::getSelfURI($query);
		return $url;
	}

	public function setCount(string $count)
	{
		$this->count = $count;
	}

	public function getHeaderColumns(bool $export = false)
	{
		$columns = [];

		foreach ($this->columns as $alias => $properties) {
			if (isset($properties['only_with_order']) && !($properties['only_with_order'] == $this->order)) {
				continue;
			}

			// Skip columns that require a certain order AND paginated result
			if (isset($properties['only_with_order']) && $this->page > 1) {
				continue;
			}

			if (!isset($properties['label'])) {
				continue;
			}

			if (!$export && !empty($properties['export_only'])) {
				continue;
			}

			$columns[$alias] = $export ? $properties['label'] : $properties;
		}

		return $columns;
	}

	public function getExportHeaderColumns(): array
	{
		return $this->getHeaderColumns(true);
	}

	public function iterate(bool $include_hidden = true)
	{
		foreach (DB::getInstance()->iterate($this->getSQL()) as $row) {
			if ($this->modifier) {
				call_user_func_array($this->modifier, [&$row]);
			}

			foreach ($this->columns as $key => $config) {
				if (empty($config['label']) && !$include_hidden) {
					unset($row->$key);
				}
			}

			yield $row;
		}
	}

	public function getSQL(): string
	{
		$start = ($this->page - 1) * $this->per_page;
		$columns = [];
		$db = DB::getInstance();

		foreach ($this->columns as $alias => $properties) {
			// Skip columns that require a certain order (eg. calculating a running sum)
			if (isset($properties['only_with_order']) && !($properties['only_with_order'] == $this->order)) {
				continue;
			}

			// Skip columns that require a certain order AND paginated result
			if (isset($properties['only_with_order']) && $this->page > 1) {
				continue;
			}

			if (array_key_exists('select', $properties)) {
				$select = $properties['select'] ?? 'NULL';
				$columns[] = sprintf('%s AS %s', $select, $db->quoteIdentifier($alias));
			}
			else {
				$columns[] = $db->quoteIdentifier($alias);
			}
		}

		$columns = implode(', ', $columns);

		if (isset($this->columns[$this->order]['order'])) {
			$order = sprintf($this->columns[$this->order]['order'], $this->desc ? 'DESC' : 'ASC');
		}
		else {
			$order = $db->quoteIdentifiers($this->order);

			if (true === $this->desc) {
				$order .= ' DESC';
			}
		}

		$group = $this->group ? 'GROUP BY ' . $this->group : '';

		$sql = sprintf('SELECT %s FROM %s WHERE %s %s ORDER BY %s',
			$columns, $this->tables, $this->conditions, $group, $order);

		if (null !== $this->per_page) {
			$sql .= sprintf(' LIMIT %d,%d', $start, $this->per_page);
		}

		return $sql;
	}

	public function loadFromQueryString()
	{
		if (!empty($_GET['export'])) {
			$this->export($this->title, $_GET['export']);
			exit;
		}

		if (!empty($_GET['o'])) {
			$this->orderBy($_GET['o'], !empty($_GET['d']));
		}

		if (!empty($_GET['p'])) {
			$this->page = (int)$_GET['p'];
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/API_Credentials.php version [f8f4575a16].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

namespace Garradin\Entities;

use Garradin\Membres\Session;
use Garradin\Entity;

class API_Credentials extends Entity
{
	const TABLE = 'api_credentials';

	protected ?int $id;
	protected string $label;
	protected string $key;
	protected string $secret;
	protected \DateTime $created;
	protected ?\DateTime $last_use;
	protected int $access_level;

	const ACCESS_LEVELS = [
		Session::ACCESS_READ => 'Peut lire les données',
		Session::ACCESS_WRITE => 'Peut lire et modifier les données',
		Session::ACCESS_ADMIN => 'Peut tout faire, y compris supprimer les données',
	];

	public function selfCheck(): void
	{
		parent::selfCheck();

		$this->assert(trim($this->label) !== '', 'La description ne peut être laissée vide.');
		$this->assert(trim($this->key) !== '', 'La clé ne peut être laissée vide.');
		$this->assert(trim($this->secret) !== '', 'Le secret ne peut être laissé vide.');
		$this->assert(array_key_exists($this->access_level, self::ACCESS_LEVELS));

		$this->assert(preg_match('/^[a-z0-9_]+$/', $this->key), 'L\'identifiant ne peut contenir que des lettres, des chiffres et des tirets bas.');
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<










































































Deleted src/include/lib/Garradin/Entities/Accounting/Account.php version [9713ee2215].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
<?php

namespace Garradin\Entities\Accounting;

use DateTimeInterface;
use Garradin\Config;
use Garradin\CSV_Custom;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Accounting\Charts;

class Account extends Entity
{
	const TABLE = 'acc_accounts';

	const NONE = 0;

	// Actif
	const ASSET = 1;

	// Passif
	const LIABILITY = 2;

	// Passif ou actif
	const ASSET_OR_LIABILITY = 3;

	// Charge
	const EXPENSE = 4;

	// Produit
	const REVENUE = 5;

	const POSITIONS_NAMES = [
		'',
		'Actif',
		'Passif',
		'Actif ou passif',
		'Charge',
		'Produit',
	];

	/**
	 * TYPEs are special kinds of accounts, to help force the account position in the chart
	 */
	const TYPE_NONE = 0;
	const TYPE_BANK = 1;
	const TYPE_CASH = 2;

	/**
	 * Outstanding transaction accounts (like cheque or card payments)
	 */
	const TYPE_OUTSTANDING = 3;
	const TYPE_THIRD_PARTY = 4;

	const TYPE_EXPENSE = 5;
	const TYPE_REVENUE = 6;

	const TYPE_VOLUNTEERING_EXPENSE = 7;
	const TYPE_VOLUNTEERING_REVENUE = 8;

	const TYPE_OPENING = 9;
	const TYPE_CLOSING = 10;

	const TYPE_POSITIVE_RESULT = 11;
	const TYPE_NEGATIVE_RESULT = 12;

	const TYPE_APPROPRIATION_RESULT = 13;

	const TYPE_CREDIT_REPORT = 14;
	const TYPE_DEBIT_REPORT = 15;

	const TYPES_NAMES = [
		'',
		'Banque',
		'Caisse',
		'Attente d\'encaissement',
		'Tiers',
		'Dépenses',
		'Recettes',
		'Bénévolat — Emploi', // Used to be Analytique
		'Bénévolat — Contribution',
		'Ouverture',
		'Clôture',
		'Résultat excédentaire',
		'Résultat déficitaire',
		'Affectation du résultat',
		'Report à nouveau créditeur',
		'Report à nouveau débiteur',
	];

	/**
	 * Show only these types of accounts in the quick account view
	 */
	const COMMON_TYPES = [
		self::TYPE_BANK,
		self::TYPE_CASH,
		self::TYPE_OUTSTANDING,
		self::TYPE_THIRD_PARTY,
		self::TYPE_EXPENSE,
		self::TYPE_REVENUE,
		self::TYPE_VOLUNTEERING_EXPENSE,
		self::TYPE_VOLUNTEERING_REVENUE,
	];

	/**
	 * Positions that should be enforced according to account code
	 */
	const LOCAL_POSITIONS = [
		'FR' => [
			'^1' => self::LIABILITY,
			'^2' => self::ASSET,
			'^3' => self::ASSET,
			'^4' => self::ASSET_OR_LIABILITY,
			'^5' => self::ASSET_OR_LIABILITY,
			'^6' => self::EXPENSE,
			'^7' => self::REVENUE,
			'^86' => self::EXPENSE,
			'^87' => self::REVENUE,
		],
		'BE' => [
			'^69' => self::ASSET_OR_LIABILITY,
			'^6' => self::EXPENSE,
			'^79' => self::ASSET_OR_LIABILITY,
			'^7' => self::REVENUE,
			'^5' => self::ASSET_OR_LIABILITY,
			'^4' => self::ASSET_OR_LIABILITY,
			'^3' => self::ASSET,
			'^2' => self::ASSET,
			'^1' => self::LIABILITY,
		],
		'CH' => [
			'^1' => self::ASSET,
			'^2' => self::LIABILITY,
			'^3(?!910)|^4910' => self::EXPENSE,
			'^4(?!910)|^3910' => self::REVENUE,
			'^5' => self::ASSET_OR_LIABILITY,
		],
	];

	/**
	 * Codes that should be enforced according to type (and vice-versa)
	 */
	const LOCAL_TYPES = [
		'FR' => [
			self::TYPE_BANK => '512',
			self::TYPE_CASH => '53',
			self::TYPE_OUTSTANDING => '511',
			self::TYPE_THIRD_PARTY => '4',
			self::TYPE_EXPENSE => '6',
			self::TYPE_REVENUE => '7',
			self::TYPE_VOLUNTEERING_EXPENSE => '86',
			self::TYPE_VOLUNTEERING_REVENUE => '87',
			self::TYPE_OPENING => '890',
			self::TYPE_CLOSING => '891',
			self::TYPE_POSITIVE_RESULT => '120',
			self::TYPE_NEGATIVE_RESULT => '129',
			self::TYPE_APPROPRIATION_RESULT => '1068',
			self::TYPE_CREDIT_REPORT => '110',
			self::TYPE_DEBIT_REPORT => '119',
		],
		'BE' => [
			self::TYPE_APPROPRIATION_RESULT => '139',
			self::TYPE_CREDIT_REPORT => '4931',
			self::TYPE_DEBIT_REPORT => '4932',
			self::TYPE_BANK => '56',
			self::TYPE_CASH => '570',
			self::TYPE_OUTSTANDING => '499',
			self::TYPE_EXPENSE => '6',
			self::TYPE_REVENUE => '7',
			self::TYPE_POSITIVE_RESULT => '692',
			self::TYPE_NEGATIVE_RESULT => '690',
			self::TYPE_THIRD_PARTY => '4',
			self::TYPE_OPENING => '890',
			self::TYPE_CLOSING => '891',
		],
		'CH' => [
			self::TYPE_BANK => '102',
			self::TYPE_CASH => '100',
			self::TYPE_OUTSTANDING => '109',
			self::TYPE_THIRD_PARTY => '5',
			self::TYPE_EXPENSE => '3',
			self::TYPE_REVENUE => '4',
			self::TYPE_OPENING => '9100',
			self::TYPE_CLOSING => '9101',
			self::TYPE_POSITIVE_RESULT => '29991',
			self::TYPE_NEGATIVE_RESULT => '29999',
			self::TYPE_APPROPRIATION_RESULT => '2910',
			self::TYPE_CREDIT_REPORT => '2990',
			self::TYPE_DEBIT_REPORT => '2990',
		],
	];

	const LIST_COLUMNS = [
		'id' => [
			'select' => 't.id',
			'label' => 'N°',
		],
		'id_line' => [
			'select' => 'l.id',
		],
		'date' => [
			'label' => 'Date',
			'select' => 't.date',
			'order' => 'date %s, id %1$s',
		],
		'debit' => [
			'select' => 'l.debit',
			'label' => 'Débit',
		],
		'credit' => [
			'select' => 'l.credit',
			'label' => 'Crédit',
		],
		'change' => [
			'select' => '(l.debit - l.credit) * %d',
			'label' => 'Mouvement',
		],
		'sum' => [
			'select' => NULL,
			'label' => 'Solde cumulé',
			'only_with_order' => 'date',
		],
		'reference' => [
			'label' => 'Pièce comptable',
			'select' => 't.reference',
		],
		'type' => [
			'select' => 't.type',
		],
		'label' => [
			'select' => 't.label',
			'label' => 'Libellé',
		],
		'line_label' => [
			'select' => 'l.label',
			'label' => 'Libellé ligne'
		],
		'line_reference' => [
			'label' => 'Réf. ligne',
			'select' => 'l.reference',
		],
		'id_project' => [
			'select' => 'l.id_project',
		],
		'project_code' => [
			'select' => 'IFNULL(p.code, SUBSTR(p.label, 1, 10) || \'…\')',
			'label' => 'Projet',
		],
		'status' => [
			'select' => 't.status',
		],
	];

	protected ?int $id;
	protected int $id_chart;
	protected string $code;
	protected string $label;
	protected ?string $description;
	protected int $position = 0;
	protected int $type;
	protected bool $user = false;
	protected bool $bookmark = false;

	protected $_position = [];
	protected ?Chart $_chart = null;

	static protected ?array $_charts;

	public function selfCheck(): void
	{
		$db = DB::getInstance();

		$this->assert(trim($this->code) !== '', 'Le numéro de compte ne peut rester vide.');
		$this->assert(trim($this->label) !== '', 'L\'intitulé de compte ne peut rester vide.');

		// Only enforce code limits if the account is new, or if the code is changed
		if (!$this->exists() || $this->isModified('code')) {
			$this->assert(strlen($this->code) <= 20, 'Le numéro de compte ne peut faire plus de 20 caractères.');
			$this->assert(preg_match('/^[a-z0-9_]+$/i', $this->code), 'Le numéro de compte ne peut comporter que des lettres et des chiffres.');
		}

		$this->assert(strlen($this->label) <= 200, 'L\'intitulé de compte ne peut faire plus de 200 caractères.');
		$this->assert(!isset($this->description) || strlen($this->description) <= 2000, 'La description de compte ne peut faire plus de 2000 caractères.');

		$this->assert(!empty($this->id_chart), 'Aucun plan comptable lié');

		$where = 'code = ? AND id_chart = ?';
		$where .= $this->exists() ? sprintf(' AND id != %d', $this->id()) : '';

		if ($db->test(self::TABLE, $where, $this->code, $this->id_chart)) {
			throw new ValidationException(sprintf('Le numéro "%s" est déjà utilisé par un autre compte.', $this->code));
		}

		$this->assert(isset($this->type));

		$this->checkLocalRules();

		$this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type invalide: ' . $this->type);
		$this->assert(array_key_exists($this->position, self::POSITIONS_NAMES), 'Position invalide');

		parent::selfCheck();
	}

	protected function getCountry(): ?string
	{
		if (!isset(self::$_charts)) {
			self::$_charts = DB::getInstance()->getGrouped('SELECT id, country, code FROM acc_charts;');
		}

		return self::$_charts[$this->id_chart]->country ?? null;
	}

	protected function isChartOfficial(): bool
	{
		$country = $this->getCountry();
		return !empty(self::$_charts[$this->id_chart]->code);
	}

	/**
	 * This sets the account position according to local rules
	 * if the chart is linked to a country, but only
	 * if the account is user-created, or if the chart is non-official
	 */
	protected function getLocalPosition(string $country = null): ?int
	{
		if (!func_num_args()) {
			$country = $this->getCountry();
		}

		$is_official = $this->isChartOfficial();

		if (!$country) {
			return null;
		}

		// Do not change position of official chart accounts
		if (!$this->user && $is_official) {
			return null;
		}

		foreach (self::LOCAL_POSITIONS[$country] as $pattern => $position) {
			if (preg_match('/' . $pattern . '/', $this->code)) {
				return $position;
			}
		}

		return null;
	}

	protected function getLocalType(string $country = null): int
	{
		if (!func_num_args()) {
			$country = $this->getCountry();
		}

		if (!$country) {
			return self::TYPE_NONE;
		}

		foreach (self::LOCAL_TYPES[$country] as $type => $number) {
			if ($this->matchType($type, $country)) {
				return $type;
			}
		}

		return self::TYPE_NONE;
	}

	protected function matchType(int $type, string $country = null): bool
	{
		if (func_num_args() < 2) {
			$country = $this->getCountry();
		}

		$pattern = self::LOCAL_TYPES[$country][$type] ?? null;

		if (!$pattern) {
			return false;
		}

		if (in_array($type, self::COMMON_TYPES)) {
			$pattern = sprintf('/^%s.+/', $pattern);
		}
		else {
			$pattern = sprintf('/^%s$/', $pattern);
		}

		return (bool) preg_match($pattern, $this->code);
	}

	public function setLocalRules(string $country = null): void
	{
		if (!func_num_args()) {
			$country = $this->getCountry();
		}

		if (!$country) {
			$this->set('type', 0);
			return;
		}

		$this->set('type', $this->getLocalType($country));

		if (null !== ($p = $this->getLocalPosition($country))) {
			// If the allowed local position is asset OR liability, we allow either one of those 3 choices
			if ($p != self::ASSET_OR_LIABILITY
				|| !in_array($this->position, [self::ASSET_OR_LIABILITY, self::ASSET, self::LIABILITY])) {
				$this->set('position', $p);
			}
		}

		if (!isset($this->type)) {
			$this->set('type', 0);
		}
	}

	public function checkLocalRules(): void
	{
		$country = $this->getCountry();

		if (!$this->type) {
			return;
		}

		if (!isset(self::LOCAL_TYPES[$country][$this->type])) {
			return;
		}

		$this->assert($this->matchType($this->type), sprintf('Compte "%s - %s" : le numéro des comptes de type "%s" doit commencer par "%s" (%s).', $this->code, $this->label, self::TYPES_NAMES[$this->type], self::LOCAL_TYPES[$country][$this->type], $this->code));
	}

	public function getNewNumberAvailable(?string $base = null): ?string
	{
		$base ??= $this->getNumberBase();

		if (!$base) {
			return $base;
		}

		$pattern = $base . '_%';

		$db = DB::getInstance();
		$used_codes = $db->getAssoc(sprintf('SELECT code, code FROM %s WHERE code LIKE ? AND id_chart = ?;', Account::TABLE), $pattern, $this->id_chart);
		$used_codes = array_values($used_codes);
		$used_codes = array_map(fn($a) => substr($a, strlen($base)), $used_codes);

		$count = $db->count(Account::TABLE, 'id_chart = ? AND code LIKE ?', $this->id_chart, $pattern);
		$letter = null;

		// Make sure we don't reuse an existing code
		while (!$letter || in_array($letter, $used_codes)) {
			// Get new account code, eg. 512A, 99AA, 99BZ etc.
			$letter = Utils::num2alpha($count++);
		}

		return $letter;
	}

	public function getNumberUserPart(): ?string
	{
		$base = $this->getNumberBase();

		if (!$base) {
			return $base;
		}

		return substr($this->code, strlen($base));
	}

	public function getNumberBase(): ?string
	{
		if (!$this->type) {
			return null;
		}

		$country = $this->getCountry();

		if (!isset(self::LOCAL_TYPES[$country][$this->type])) {
			return null;
		}


		return self::LOCAL_TYPES[$country][$this->type];
	}

	public function listJournal(int $year_id, bool $simple = false, ?DateTimeInterface $start = null, ?DateTimeInterface $end = null)
	{
		$db = DB::getInstance();
		$columns = self::LIST_COLUMNS;

		$tables = 'acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			LEFT JOIN acc_projects p ON p.id = l.id_project';
		$conditions = sprintf('l.id_account = %d AND t.id_year = %d', $this->id(), $year_id);

		$sum = null;
		$reverse = $this->isReversed($simple, $year_id) ? -1 : 1;

		if ($start) {
			$conditions .= sprintf(' AND t.date >= %s', $db->quote($start->format('Y-m-d')));
		}

		if ($end) {
			$conditions .= sprintf(' AND t.date <= %s', $db->quote($end->format('Y-m-d')));
		}

		$columns['change']['select'] = sprintf($columns['change']['select'], $reverse);

		if ($simple) {
			unset($columns['debit']['label'], $columns['credit']['label'], $columns['line_label']);
			$columns['line_reference']['label'] = 'Réf. paiement';
		}
		else {
			unset($columns['change']['label']);
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		$list->setCount('COUNT(*)');
		$list->setPageSize(null); // Because with paging we can't calculate the running sum
		$list->setModifier(function (&$row) use (&$sum, &$list, $reverse, $year_id, $start, $end) {
			if (property_exists($row, 'sum')) {
				// Reverse running sum needs the last sum, first
				if ($list->desc && null === $sum) {
					$sum = $this->getSumAtDate($year_id, ($end ?? new \DateTime($row->date))->modify('+1 day')) * -1 * $reverse;
				}
				elseif (!$list->desc) {
					if (null === $sum && $start) {
						$sum = $this->getSumAtDate($year_id, $start) * -1 * $reverse;
					}

					$sum += $row->change;
				}

				$row->sum = $sum;

				if ($list->desc) {
					$sum -= $row->change;
				}
			}

			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
		});

		$list->setExportCallback(function (&$row) {
			static $columns = ['change', 'sum', 'credit', 'debit'];
			foreach ($columns as $key) {
				if (isset($row->$key)) {
					$row->$key = Utils::money_format($row->$key, '.', '', false);
				}
			}
		});

		return $list;
	}

	/**
	 * Renvoie TRUE si le solde du compte est inversé en vue simplifiée (= crédit - débit, au lieu de débit - crédit)
	 * @return boolean
	 */
	public function isReversed(bool $simple, int $id_year): bool
	{
		if ($simple && in_array($this->type, [self::TYPE_BANK, self::TYPE_CASH, self::TYPE_OUTSTANDING, self::TYPE_EXPENSE, self::TYPE_THIRD_PARTY])) {
			return false;
		}

		$position = $this->getPosition($id_year);

		if ($position == self::ASSET || $position == self::EXPENSE) {
			return false;
		}

		return true;
	}

	public function getPosition(int $id_year): int
	{
		$position = $this->_position[$id_year] ?? $this->position;

		if ($position == self::ASSET_OR_LIABILITY) {
			$balance = DB::getInstance()->firstColumn('SELECT debit - credit FROM acc_accounts_balances WHERE id = ? AND id_year = ?;', $this->id, $id_year);
			$position = $balance > 0 ? self::ASSET : self::LIABILITY;
		}

		$this->_position[$id_year] = $position;

		return $position;
	}

	public function getReconcileJournal(int $year_id, DateTimeInterface $start_date, DateTimeInterface $end_date, bool $only_non_reconciled = false)
	{
		if ($end_date < $start_date) {
			throw new ValidationException('La date de début ne peut être avant la date de fin.');
		}

		$condition = $only_non_reconciled ? ' AND l.reconciled = 0' : '';

		$db = DB::getInstance();
		$sql = 'SELECT l.debit, l.credit, t.id, t.date, t.reference, l.reference AS line_reference, t.label, l.label AS line_label, l.reconciled, l.id AS id_line
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE l.id_account = ? AND t.id_year = ? AND t.date >= ? AND t.date <= ? %s
			ORDER BY t.date, t.id;';
		$rows = $db->iterate(sprintf($sql, $condition), $this->id(), $year_id, $start_date->format('Y-m-d'), $end_date->format('Y-m-d'));

		$sum = $this->getSumAtDate($year_id, $start_date);
		$reconciled_sum = $this->getSumAtDate($year_id, $start_date, true);

		$start_sum = false;

		foreach ($rows as $row) {
			if (!$start_sum) {
				yield (object) ['sum' => $sum, 'date' => $start_date];
				$start_sum = true;
			}

			$row->date = \DateTime::createFromFormat('Y-m-d', $row->date);
			$sum += ($row->credit - $row->debit);
			$row->running_sum = $sum;

			if ($row->reconciled) {
				$reconciled_sum += ($row->credit - $row->debit);
			}

			$row->reconciled_sum = $reconciled_sum;

			yield $row;
		}

		if (!$only_non_reconciled) {
			yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date];
		}
	}

	public function getDepositJournal(int $year_id, array $checked = []): DynamicList
	{
		$columns = [
			'id' => [
				'label' => 'Num.',
				'select' => 't.id',
			],
			'date' => [
				'select' => 't.date',
				'label' => 'Date',
				'order' => 't.date %s, t.id %1$s',
			],
			'reference' => [
				'select' => 't.reference',
				'label' => 'Réf. écriture',
			],
			'line_reference' => [
				'select' => 'l.reference',
				'label' => 'Réf. paiement',
			],
			'label' => [
				'label' => 'Libellé',
				'select' => 't.label',
			],
			'amount' => [
				'label' => 'Montant',
				'select' => 'l.debit',
			],
			'running_sum' => [
				'label' => 'Solde cumulé',
				'only_with_order' => 'date',
				'select' => null,
			],
			'credit' => [
				'select' => 'l.credit',
			],
			'debit' => [
				'select' => 'l.debit',
			],
			'id_account' => [
				'select' => 'l.id_account',
			],
			'id_line' => [
				'select' => 'l.id',
			],
			'id_project' => [
				'select' => 'l.id_project',
			],
		];

		$tables = 'acc_transactions_lines l INNER JOIN acc_transactions t ON t.id = l.id_transaction';
		$conditions = sprintf('t.id_year = %d AND l.id_account = %d AND l.credit = 0 AND NOT (t.status & %d)',
			$year_id, $this->id(), Transaction::STATUS_DEPOSIT);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->setPageSize(null);
		$list->orderBy('date', true);
		$list->setModifier(function (&$row) use (&$sum, $checked) {
			$sum += ($row->credit - $row->debit);
			$row->running_sum = $sum;
			$row->checked = array_key_exists($row->id, $checked);
		});

		return $list;
	}

	public function getDepositMissingBalance(int $year_id): int
	{
		$deposit_balance = DB::getInstance()->firstColumn('SELECT SUM(l.debit)
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
			ORDER BY t.date, t.id;',
			$year_id, $this->id(), Transaction::STATUS_DEPOSIT);
		$account_balance = $this->getSum($year_id)->balance;

		return $account_balance - $deposit_balance;
	}

	public function getSum(int $year_id, bool $simple = false): ?\stdClass
	{
		$sum = DB::getInstance()->first('SELECT balance, credit, debit
			FROM acc_accounts_balances
			WHERE id = ? AND id_year = ?;', $this->id(), $year_id);

		return $sum ?: null;
	}


	public function getSumAtDate(int $year_id, DateTimeInterface $date, bool $reconciled_only = false): int
	{
		$sql = sprintf('SELECT SUM(l.credit) - SUM(l.debit)
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE l.id_account = ? AND t.id_year = ? AND t.date < ? %s;',
			$reconciled_only ? 'AND l.reconciled = 1' : '');
		return (int) DB::getInstance()->firstColumn($sql, $this->id(), $year_id, $date->format('Y-m-d'));
	}

	public function importLimitedForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$data = array_intersect_key($source, array_flip(['type', 'description']));
		parent::import($data);
	}

	public function canDelete(): bool
	{
		if ($this->chart()->code && !$this->user) {
			return false;
		}

		return !DB::getInstance()->firstColumn(sprintf('SELECT 1 FROM %s WHERE id_account = ? LIMIT 1;', Line::TABLE), $this->id());
	}

	/**
	 * An account properties (position, label and code) can only be changed if:
	 * * it's either a user-created account or an account part of a user-created chart
	 * * has no transactions in a closed year
	 * @return bool
	 */
	public function canEdit(): bool
	{
		if (!$this->exists()) {
			return true;
		}

		$db = DB::getInstance();
		$is_user = $this->user ?: $db->test(Chart::TABLE, 'id = ? AND code IS NULL', $this->id_chart);

		if (!$is_user) {
			return false;
		}

		$sql = sprintf('SELECT 1 FROM %s l
			INNER JOIN %s t ON t.id = l.id_transaction
			INNER JOIN %s y ON y.id = t.id_year
			WHERE l.id_account = ? AND y.closed = 1
			LIMIT 1;', Line::TABLE, Transaction::TABLE, Year::TABLE);
		$has_transactions_in_closed_year = $db->firstColumn($sql, $this->id());

		if ($has_transactions_in_closed_year) {
			return false;
		}

		return true;
	}

	/**
	 * We can set account position if:
	 * - account is not in a supported chart country
	 * - account is not part of an official chart
	 * - account is not affected by local position rules
	 */
	public function canSetPosition(): bool
	{
		if (!$this->getCountry()) {
			return true;
		}

		if ($this->isChartOfficial() && !$this->user) {
			return false;
		}

		if ($this->type || $this->getLocalType()) {
			return false;
		}

		if (null !== $this->getLocalPosition()) {
			return false;
		}

		return true;
	}

	/**
	 * We can set account asset or liability if:
	 * - local position rules allow for asset or liability
	 */
	public function canSetAssetOrLiabilityPosition(): bool
	{
		if (!$this->getCountry()) {
			return true;
		}

		if ($this->isChartOfficial() && !$this->user) {
			return false;
		}

		$type = $this->type ?: $this->getLocalType();

		if ($type == self::TYPE_THIRD_PARTY) {
			return true;
		}
		elseif ($type) {
			return false;
		}

		$position = $this->getLocalPosition();

		if ($position == self::ASSET_OR_LIABILITY) {
			return true;
		}

		return false;
	}

	public function chart(): Chart
	{
		$this->_chart ??= Charts::get($this->id_chart);
		return $this->_chart;
	}

	public function save(bool $selfcheck = true): bool
	{
		$this->setLocalRules();

		DB::getInstance()->exec(sprintf('REPLACE INTO config (key, value) VALUES (\'last_chart_change\', %d);', time()));

		return parent::save($selfcheck);
	}

	public function position_name(): string
	{
		return self::POSITIONS_NAMES[$this->position];
	}

	public function type_name(): string
	{
		return self::TYPES_NAMES[$this->type];
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['code_value'], $source['code_base'])) {
			$source['code'] = trim($source['code_base']) . trim($source['code_value']);
		}

		parent::importForm($source);
	}

	public function level(): int
	{
		$level = strlen($this->code);

		if ($level > 6) {
			$level = 6;
		}

		return $level;
	}

	public function isListedAsFavourite(): bool
	{
		if ($this->bookmark) {
			return true;
		}

		if ($this->user) {
			return true;
		}

		return DB::getInstance()->test('acc_transactions_lines', 'id_account = ?', $this->id);
	}

	public function createOpeningBalance(Year $year, int $amount, ?string $label = null): Transaction
	{
		$t = new Transaction;
		$t->label = $label ?? 'Solde d\'ouverture du compte';
		$t->date = clone $year->start_date;
		$t->type = $t::TYPE_ADVANCED;
		$t->notes = 'Créé automatiquement à l\'ajout du compte';
		$t->id_year = $year->id;

		$accounts = $year->accounts();

		$opening_account = $accounts->getOpeningAccountId();
		$credit = $amount > 0 ? 0 : abs($amount);
		$debit = $amount < 0 ? 0 : abs($amount);
		$t->addLine(Line::create($this->id(), $credit, $debit));
		$t->addLine(Line::create($opening_account, $debit, $credit));
		return $t;
	}

}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Accounting/Chart.php version [fe9131dd28].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
<?php

namespace Garradin\Entities\Accounting;

use Garradin\CSV;
use Garradin\DB;
use Garradin\Entity;
use Garradin\Utils;
use Garradin\ValidationException;
use Garradin\UserException;
use Garradin\Accounting\Accounts;

use KD2\DB\EntityManager;

class Chart extends Entity
{
	const TABLE = 'acc_charts';

	protected ?int $id;
	protected string $label;
	protected ?string $country = null;
	protected ?string $code;
	protected bool $archived = false;

	const COUNTRY_LIST = [
		'BE' => 'Belgique',
		'FR' => 'France',
		'CH' => 'Suisse',
	];

	const REQUIRED_COLUMNS = ['code', 'label', 'description', 'position', 'bookmark'];

	const COLUMNS = [
		'code' => 'Numéro',
		'label' => 'Libellé',
		'description' => 'Description',
		'position' => 'Position',
		'added' => 'Ajouté',
		'bookmark' => 'Favori',
	];

	public function selfCheck(): void
	{
		$this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide.');
		$this->assert(strlen($this->label) <= 200, 'Le libellé ne peut faire plus de 200 caractères.');
		$this->assert(null === $this->country || array_key_exists($this->country, self::COUNTRY_LIST), 'Pays inconnu');
		parent::selfCheck();
	}

	public function accounts()
	{
		return new Accounts($this->id());
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		// Don't allow to change country
		if (isset($this->code)) {
			unset($source['country']);
		}

		unset($source['code']);

		return Entity::importForm($source);
	}

	public function canDelete()
	{
		return !DB::getInstance()->firstColumn(sprintf('SELECT 1 FROM %s WHERE id_chart = ? LIMIT 1;', Year::TABLE), $this->id());
	}

	public function importCSV(string $file, bool $update = false): void
	{
		$db = DB::getInstance();
		$positions = array_flip(Account::POSITIONS_NAMES);
		$types = array_flip(Account::TYPES_NAMES);

		$db->begin();

		try {
			foreach (CSV::import($file, self::COLUMNS, self::REQUIRED_COLUMNS) as $line => $row) {
				$account = null;

				if ($update) {
					$account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE code = ? AND id_chart = ?;', $row['code'], $this->id());
				}

				if (!$account) {
					$account = new Account;
					$account->id_chart = $this->id();
				}

				try {
					if (!isset($positions[$row['position']])) {
						throw new ValidationException('Position inconnue : ' . $row['position']);
					}
					// Don't update user-set values
					if ($account->exists()) {
						unset($row['bookmark'], $row['description']);
					}
					else {
						$row['user'] = !empty($row['added']);
						$row['bookmark'] = !empty($row['bookmark']);
					}

					$row['position'] = $positions[$row['position']];

					$account->importForm($row);
					$account->save();
				}
				catch (ValidationException $e) {
					throw new UserException(sprintf('Ligne %d : %s', $line, $e->getMessage()));
				}
			}

			$db->commit();
		}
		catch (\Exception $e) {
			$db->rollback();
			throw $e;
		}
	}


	/**
	 * Return all accounts from current chart
	 */
	public function export(): \Generator
	{
		$res = DB::getInstance()->iterate('SELECT
			code, label, description, position, user, bookmark
			FROM acc_accounts WHERE id_chart = ? ORDER BY code COLLATE NOCASE;',
			$this->id);

		foreach ($res as $row) {
			$row->position = Account::POSITIONS_NAMES[$row->position];
			$row->user = $row->user ? 'Ajouté' : '';
			$row->bookmark = $row->bookmark ? 'Favori' : '';
			yield $row;
		}
	}

	public function resetAccountsRules(): void
	{
		$db = DB::getInstance();
		$db->begin();

		try {
			foreach ($this->accounts()->listAll() as $account) {
				$account->setLocalRules($this->country);
				$account->save();
			}
		}
		catch (UserException $e) {
			$db->rollback();
			throw $e;
		}

		$db->commit();
	}

	public function save(bool $selfcheck = true): bool
	{
		$country_modified = $this->isModified('country');
		$exists = $this->exists();

		$ok = parent::save($selfcheck);

		// Change account types
		if ($ok && $exists && $country_modified) {
			$this->resetAccountsRules();
		}

		return $ok;
	}

	public function country_code(): ?string
	{
		if (!$this->code) {
			return null;
		}

		return strtolower($this->country . '_' . $this->code);
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


























































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Accounting/Line.php version [d840a29496].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<?php

namespace Garradin\Entities\Accounting;

use Garradin\DB;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Utils;
use Garradin\Accounting\Accounts;

class Line extends Entity
{
	const TABLE = 'acc_transactions_lines';

	protected ?int $id;
	protected int $id_transaction;
	protected int $id_account;
	protected int $credit = 0;
	protected int $debit = 0;
	protected ?string $reference = null;
	protected ?string $label = null;
	protected bool $reconciled = false;
	protected ?int $id_project = null;

	static public function create(int $id_account, int $credit, int $debit, ?string $label = null, ?string $reference = null): Line
	{
		$line = new self;
		$line->id_account = $id_account;
		$line->credit = $credit;
		$line->debit = $debit;
		$line->label = $label;
		$line->reference = $reference;

		return $line;
	}

	public function filterUserValue(string $type, $value, string $key)
	{
		if ($key == 'credit' || $key == 'debit') {
			$value = Utils::moneyToInteger($value);
		}
		elseif ($key == 'id_project' && $value == 0) {
			$value = null;
		}

		$value = parent::filterUserValue($type, $value, $key);

		return $value;
	}

	public function selfCheck(): void
	{
		// We don't check that the account exists here
		// The fact that the account is in the right chart is checked in Transaction::selfCheck

		$this->assert($this->reference === null || strlen($this->reference) < 200, 'La référence doit faire moins de 200 caractères.');
		$this->assert($this->label === null || strlen($this->label) < 200, 'La référence doit faire moins de 200 caractères.');
		$this->assert($this->id_account !== null, 'Aucun compte n\'a été indiqué.');
		$this->assert($this->credit || $this->debit, 'Aucun montant au débit ou au crédit');
		$this->assert($this->credit >= 0 && $this->debit >= 0, 'Le montant ne peut être négatif');
		$this->assert($this->credit + $this->debit < 100000000000, 'Le montant ne peut être supérieur à un milliard');
		$this->assert(($this->credit * $this->debit) === 0 && ($this->credit + $this->debit) > 0, 'Ligne non équilibrée : crédit ou débit doit valoir zéro.');

		$this->assert(null === $this->id_project || DB::getInstance()->test(Project::TABLE, 'id = ?', $this->id_project), 'Le projet analytique indiqué n\'existe pas.');
		$this->assert(!empty($this->id_transaction), 'Aucune écriture n\'a été indiquée pour cette ligne.');
		parent::selfCheck();
	}

	public function asDetailsArray(): array
	{
		return [
			'Compte'    => $this->id_account ? Accounts::getCodeAndLabel($this->id_account) : null,
			'Libellé'   => $this->label,
			'Référence' => $this->reference,
			'Crédit'    => Utils::money_format($this->credit),
			'Débit'     => Utils::money_format($this->debit),
		];
	}

}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































































































































































Deleted src/include/lib/Garradin/Entities/Accounting/Project.php version [58624a9c0e].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php

namespace Garradin\Entities\Accounting;

use Garradin\DB;
use Garradin\Entity;

/**
 * Analytical projects
 */
class Project extends Entity
{
	const TABLE = 'acc_projects';

	protected ?int $id;
	protected ?string $code;
	protected string $label;
	protected ?string $description;
	protected bool $archived = false;
	protected $_position = [];

	public function selfCheck(): void
	{
		if (null !== $this->code) {
			$this->assert(trim($this->code) !== '', 'Le numéro de projet est invalide.');
			$this->assert(strlen($this->code) <= 100, 'Le numéro de projet est trop long.');
			$this->assert(preg_match('/^[A-Z0-9_]+$/', $this->code), 'Le numéro de projet ne peut comporter que des lettres majuscules et des chiffres.');

			$db = DB::getInstance();

			if ($this->exists()) {
				$this->assert(!$db->test(self::TABLE, 'code = ? AND id != ?', $this->code, $this->id()), 'Ce code est déjà utilisé par un autre projet.');
			}
			else {
				$this->assert(!$db->test(self::TABLE, 'code = ?', $this->code), 'Ce code est déjà utilisé par un autre projet.');
			}
		}

		$this->assert(trim($this->label) !== '', 'L\'intitulé de projet ne peut rester vide.');
		$this->assert(strlen($this->label) <= 200, 'L\'intitulé de compte ne peut faire plus de 200 caractères.');

		if (null !== $this->description) {
			$this->assert(trim($this->description) !== '', 'L\'intitulé de projet est invalide.');
			$this->assert(strlen($this->description) <= 2000, 'L\'intitulé de compte ne peut faire plus de 2000 caractères.');
		}


		parent::selfCheck();
	}

	public function importForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (!empty($source['code'])) {
			$source['code'] = strtoupper($source['code']);
		}

		$source['archived'] = !empty($source['archived']);

		parent::importForm($source);
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


































































































































Deleted src/include/lib/Garradin/Entities/Accounting/Transaction.php version [33c4de1c1b].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
<?php

namespace Garradin\Entities\Accounting;

use KD2\DB\EntityManager;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Config;
use Garradin\Utils;
use Garradin\UserException;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use Garradin\Accounting\Accounts;
use Garradin\Accounting\Projects;
use Garradin\Accounting\Years;
use Garradin\ValidationException;

class Transaction extends Entity
{
	const TABLE = 'acc_transactions';

	const TYPE_ADVANCED = 0;
	const TYPE_REVENUE = 1;
	const TYPE_EXPENSE = 2;
	const TYPE_TRANSFER = 3;
	const TYPE_DEBT = 4;
	const TYPE_CREDIT = 5;

	const STATUS_WAITING = 1;
	const STATUS_PAID = 2;
	const STATUS_DEPOSIT = 4;
	const STATUS_ERROR = 8;
	const STATUS_OPENING_BALANCE = 16;

	const STATUS_NAMES = [
		1 => 'En attente de règlement',
		2 => 'Réglé',
		4 => 'Déposé en banque',
	];

	const TYPES_NAMES = [
		'Avancé',
		'Recette',
		'Dépense',
		'Virement',
		'Dette',
		'Créance',
	];

	protected ?int $id;
	protected ?int $type = null;
	protected int $status = 0;
	protected string $label;
	protected ?string $notes = null;
	protected ?string $reference = null;

	protected \KD2\DB\Date $date;

	protected bool $validated = false;

	protected ?string $hash = null;
	protected ?string $prev_hash = null;

	protected int $id_year;
	protected ?int $id_creator = null;
	protected ?int $id_related = null;

	protected $_lines;
	protected $_old_lines = [];

	protected $_accounts = [];
	protected $_default_selector = [];

	/**
	 * @var Transaction
	 */
	protected $_related;

	static public function getTypeFromAccountType(int $account_type)
	{
		switch ($account_type) {
			case Account::TYPE_REVENUE:
				return self::TYPE_REVENUE;
			case Account::TYPE_EXPENSE:
				return self::TYPE_EXPENSE;
			case Account::TYPE_THIRD_PARTY:
				return self::TYPE_DEBT;
			case Account::TYPE_BANK:
			case Account::TYPE_CASH:
			case Account::TYPE_OUTSTANDING:
				return self::TYPE_TRANSFER;
			default:
				return self::TYPE_ADVANCED;
		}
	}

	public function findTypeFromAccounts(): int
	{
		if (count($this->getLines()) != 2) {
			return self::TYPE_ADVANCED;
		}

		$types = [];

		foreach ($this->getLinesWithAccounts() as $line) {
			if ($line->account_position == Account::REVENUE && $line->credit) {
				$types[] = self::TYPE_REVENUE;
			}
			elseif ($line->account_position == Account::EXPENSE && $line->debit) {
				$types[] = self::TYPE_EXPENSE;
			}
		}

		// Did not find a expense/revenue account: fall back to advanced
		// (or if one line is expense and the other is revenue)
		if (count($types) != 1) {
			return self::TYPE_ADVANCED;
		}

		return current($types);
	}

	public function getLinesWithAccounts(): array
	{
		$db = EntityManager::getInstance(Line::class)->DB();

		// Merge data from accounts with lines
		$accounts = [];
		$projects = [];
		$lines_with_accounts = [];

		foreach ($this->getLines() as $line) {
			if (!array_key_exists($line->id_account, $this->_accounts)) {
				$accounts[] = $line->id_account;
			}

			if ($line->id_project) {
				$projects[] = $line->id_project;
			}
		}

		// Remove NULL accounts
		$accounts = array_filter($accounts);

		if (count($accounts)) {
			$sql = sprintf('SELECT id, label, code, position FROM acc_accounts WHERE %s;', $db->where('id', 'IN', $accounts));
			// Don't use array_merge here or keys will be lost
			$this->_accounts = $this->_accounts + $db->getGrouped($sql);
		}

		if (count($projects)) {
			$projects = $db->getAssoc(sprintf('SELECT id, label FROM acc_projects WHERE %s;', $db->where('id', $projects)));
		}

		foreach ($this->getLines() as &$line) {
			$l = (object) $line->asArray();
			$l->account_code = $this->_accounts[$line->id_account]->code ?? null;
			$l->account_label = $this->_accounts[$line->id_account]->label ?? null;
			$l->account_position = $this->_accounts[$line->id_account]->position ?? null;
			$l->project_name = $projects[$line->id_project] ?? null;
			$l->account_selector = [$line->id_account => sprintf('%s — %s', $l->account_code, $l->account_label)];
			$l->line =& $line;

			$lines_with_accounts[] = $l;
		}

		unset($line);

		return $lines_with_accounts;
	}

	public function getLines(): array
	{
		if (null === $this->_lines && $this->exists()) {
			$em = EntityManager::getInstance(Line::class);
			$this->_lines = $em->all('SELECT * FROM @TABLE WHERE id_transaction = ? ORDER BY id;', $this->id);
		}
		elseif (null === $this->_lines) {
			$this->_lines = [];
		}

		return $this->_lines;
	}

	public function countLines(): int
	{
		return count($this->getLines());
	}

	public function removeLine(Line $remove)
	{
		$new = [];

		foreach ($this->getLines() as $line) {
			if ($line->id === $remove->id) {
				$this->_old_lines[] = $remove;
			}
			else {
				$new[] = $line;
			}
		}

		$this->_lines = $new;
	}

	public function resetLines()
	{
		$this->_old_lines = $this->getLines();
		$this->_lines = [];
	}

	public function getLine(int $id)
	{
		foreach ($this->getLines() as $line) {
			if ($line->id === $id) {
				return $line;
			}
		}

		return null;
	}

	public function getCreditLine(): ?Line
	{
		if ($this->type == self::TYPE_ADVANCED) {
			return null;
		}

		foreach ($this->getLines() as $line) {
			if ($line->credit) {
				return $line;
			}
		}

		return null;
	}

	public function getDebitLine(): ?Line
	{
		if ($this->type == self::TYPE_ADVANCED) {
			return null;
		}

		foreach ($this->getLines() as $line) {
			if ($line->debit) {
				return $line;
			}
		}

		return null;
	}

	public function getFirstLine()
	{
		$lines = $this->getLines();

		if (!count($lines)) {
			return null;
		}

		return reset($lines);
	}

	public function getLinesCreditSum()
	{
		$sum = 0;

		foreach ($this->getLines() as $line) {
			$sum += $line->credit;
		}

		return $sum;
	}

	public function getLinesDebitSum()
	{
		$sum = 0;

		foreach ($this->getLines() as $line) {
			$sum += $line->debit;
		}

		return $sum;
	}

	static public function getFormLines(?array $source = null): array
	{
		if (null === $source) {
			$source = $_POST['lines'] ?? [];
		}

		if (empty($source) || !is_array($source)) {
			return [];
		}

		$lines = Utils::array_transpose($source);

		foreach ($lines as &$line) {
			if (isset($line['credit'])) {
				$line['credit'] = Utils::moneyToInteger($line['credit']);
			}
			if (isset($line['debit'])) {
				$line['debit'] = Utils::moneyToInteger($line['debit']);
			}
		}

		unset($line);

		return $lines;
	}

	public function hasReconciledLines(): bool
	{
		foreach ($this->getLines() as $line) {
			if (!empty($line->reconciled)) {
				return true;
			}
		}

		return false;
	}

	public function getProjectId(): ?int
	{
		$lines = $this->getLines();

		if (!count($lines)) {
			return null;
		}

		$id_project = null;

		foreach ($lines as $line) {
			if ($line->id_project != $id_project) {
				$id_project = $line->id_project;
				break;
			}
		}

		return $id_project;
	}

	public function related(): ?Transaction
	{
		return $this->_related;
	}

	/**
	 * Creates a new Transaction entity (not saved) from an existing one,
	 * trying to adapt to a different chart if possible
	 * @param  int    $id
	 * @param  Year   $year Target year
	 * @return Transaction
	 */
	public function duplicate(Year $year): Transaction
	{
		$new = new Transaction;

		$copy = ['type', 'status', 'label', 'notes', 'reference'];

		foreach ($copy as $field) {
			$new->$field = $this->$field;
		}

		$copy = ['credit', 'debit', 'id_account', 'label', 'reference', 'id_project'];
		$lines = DB::getInstance()->get('SELECT
				l.credit, l.debit, l.label, l.reference, b.id AS id_account, c.id AS id_project
			FROM acc_transactions_lines l
			INNER JOIN acc_accounts a ON a.id = l.id_account
			LEFT JOIN acc_accounts b ON b.code = a.code AND b.id_chart = ?
			LEFT JOIN acc_projects c ON c.id = l.id_project
			WHERE l.id_transaction = ?;',
			$year->chart()->id,
			$this->id()
		);

		foreach ($lines as $l) {
			$line = new Line;

			foreach ($copy as $field) {
				// Do not copy id_account when it is null, as it will trigger an error (invalid entity)
				if ($field == 'id_account' && !isset($l->$field)) {
					continue;
				}

				$line->$field = $l->$field;
			}

			$new->addLine($line);
		}

		// Only set date if valid
		if ($this->date >= $year->start_date && $this->date <= $year->end_date) {
			$new->date = clone $this->date;
		}

		$new->status = 0;

		return $new;
	}

	public function payment_reference(): ?string
	{
		$line = current($this->getLines());

		if (!$line) {
			return null;
		}

		return $line->reference;
	}


/*
	public function getHash()
	{
		if (!$this->id_year) {
			throw new \LogicException('Il n\'est pas possible de hasher un mouvement qui n\'est pas associé à un exercice');
		}

		static $keep_keys = [
			'label',
			'notes',
			'reference',
			'date',
			'validated',
			'prev_hash',
		];

		$hash = hash_init('sha256');
		$values = $this->asArray();
		$values = array_intersect_key($values, $keep_keys);

		hash_update($hash, implode(',', array_keys($values)));
		hash_update($hash, implode(',', $values));

		foreach ($this->getLines() as $line) {
			hash_update($hash, implode(',', [$line->compte, $line->debit, $line->credit]));
		}

		return hash_final($hash, false);
	}

	public function checkHash()
	{
		return hash_equals($this->getHash(), $this->hash);
	}
*/

	public function addLine(Line $line)
	{
		$this->_lines[] = $line;
	}

	public function sum(): int
	{
		$sum = 0;

		foreach ($this->getLines() as $line) {
			$sum += $line->credit;
			// Because credit == debit, we only use credit
		}

		return $sum;
	}

	public function save(bool $selfcheck = true): bool
	{
		if ($this->type == self::TYPE_DEBT || $this->type == self::TYPE_CREDIT) {
			// Debts and credits add a waiting status
			if (!$this->exists()) {
				$this->addStatus(self::STATUS_WAITING);
			}
		}

		$db = DB::getInstance();

		// Allow only status to be modified
		if (!(count($this->_modified) === 1 && array_key_exists('status', $this->_modified))) {
			if (!empty($this->validated) && !(isset($this->_modified['validated']) && $this->_modified['validated'] === 0)) {
				throw new ValidationException('Il n\'est pas possible de modifier une écriture qui a été validée');
			}


			if (isset($this->id_year) && $db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) {
				throw new ValidationException('Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé');
			}
		}

		$this->selfCheck();

		$lines = $this->getLinesWithAccounts();

		// Self check lines before saving Transaction
		foreach ($lines as $i => $l) {
			$line = $l->line;
			$line->id_transaction = -1; // Get around validation of id_transaction being not null

			if (empty($l->account_code)) {
				throw new ValidationException('Le compte spécifié n\'existe pas.');
			}

			if ($this->type == self::TYPE_EXPENSE && $l->account_position == Account::REVENUE) {
				throw new ValidationException(sprintf('Line %d : il n\'est pas possible d\'attribuer un compte de produit (%s) à une dépense', $i+1, $l->account_code));
			}

			if ($this->type == self::TYPE_REVENUE && $l->account_position == Account::EXPENSE) {
				throw new ValidationException(sprintf('Line %d : il n\'est pas possible d\'attribuer un compte de charge (%s) à une recette', $i+1, $l->account_code));
			}

			try {
				$line->selfCheck();
			}
			catch (ValidationException $e) {
				// Add line number to message
				throw new ValidationException(sprintf('Ligne %d : %s', $i+1, $e->getMessage()), 0, $e);
			}
		}

		if ($this->exists() && $this->status & self::STATUS_ERROR) {
			// Remove error status when changed
			$this->removeStatus(self::STATUS_ERROR);
		}

		$db->begin();

		if (!parent::save()) {
			return false;
		}

		foreach ($lines as $line) {
			$line = $line->line; // Fetch real object
			$line->id_transaction = $this->id();
			$line->save(false);
		}

		foreach ($this->_old_lines as $line) {
			if ($line->exists()) {
				$line->delete();
			}
		}

		$db->commit();

		return true;
	}

	public function removeStatus(int $property) {
		$this->set('status', $this->status & ~$property);
	}

	public function addStatus(int $property) {
		$this->set('status', $this->status | $property);
	}

	public function markPaid() {
		$this->removeStatus(self::STATUS_WAITING);
		$this->addStatus(self::STATUS_PAID);
	}

	public function delete(): bool
	{
		if ($this->validated) {
			throw new ValidationException('Il n\'est pas possible de supprimer une écriture qui a été validée');
		}

		$db = DB::getInstance();

		if ($db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) {
			throw new ValidationException('Il n\'est pas possible de supprimer une écriture qui fait partie d\'un exercice clôturé');
		}

		// FIXME when lettering is properly implemented: mark parent transaction non-deposited when deleting a deposit transaction

		Files::delete($this->getAttachementsDirectory());

		return parent::delete();
	}

	public function selfCheck(): void
	{
		$db = DB::getInstance();

		$this->assert(!empty($this->id_year), 'L\'ID de l\'exercice doit être renseigné.');

		$this->assert(!empty($this->label) && trim((string)$this->label) !== '', 'Le champ libellé ne peut rester vide.');
		$this->assert(strlen($this->label) <= 200, 'Le champ libellé ne peut faire plus de 200 caractères.');
		$this->assert(!isset($this->reference) || strlen($this->reference) <= 200, 'Le champ numéro de pièce comptable ne peut faire plus de 200 caractères.');
		$this->assert(!isset($this->notes) || strlen($this->notes) <= 2000, 'Le champ remarques ne peut faire plus de 2000 caractères.');
		$this->assert(!empty($this->date), 'Le champ date ne peut rester vide.');

		$this->assert(null !== $this->id_year, 'Aucun exercice spécifié.');
		$this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type d\'écriture inconnu : ' . $this->type);
		$this->assert(null === $this->id_creator || $db->test('membres', 'id = ?', $this->id_creator), 'Le membre créateur de l\'écriture n\'existe pas ou plus');

		$is_in_year = $db->test(Year::TABLE, 'id = ? AND start_date <= ? AND end_date >= ?', $this->id_year, $this->date->format('Y-m-d'), $this->date->format('Y-m-d'));

		if (!$is_in_year) {
			$year = Years::get($this->id_year);
			throw new ValidationException(sprintf('La date (%s) de l\'écriture ne correspond pas à l\'exercice "%s" : la date doit être entre le %s et le %s.',
				Utils::shortDate($this->date),
				$year->label ?? '',
				Utils::shortDate($year->start_date),
				Utils::shortDate($year->end_date)
			));
		}

		$total = 0;

		$lines = $this->getLines();
		$count = count($lines);

		$this->assert($count > 0, 'Cette écriture ne comporte aucune ligne.');
		$this->assert($count >= 2, 'Cette écriture comporte moins de deux lignes.');
		$this->assert($count == 2 ||  $this->type == self::TYPE_ADVANCED, sprintf('Une écriture de type "%s" ne peut comporter que deux lignes au maximum.', self::TYPES_NAMES[$this->type]));

		$accounts_ids = [];
		$chart_id = $db->firstColumn('SELECT id_chart FROM acc_years WHERE id = ?;', $this->id_year);

		foreach ($lines as $k => $line) {
			$k = $k+1;
			$this->assert(!empty($line->id_account), sprintf('Ligne %d: aucun compte n\'est défini', $k));
			$this->assert($line->credit || $line->debit, sprintf('Ligne %d: Aucun montant au débit ou au crédit', $k));
			$this->assert($line->credit >= 0 && $line->debit >= 0, sprintf('Ligne %d: Le montant ne peut être négatif', $k));
			$this->assert(($line->credit * $line->debit) === 0 && ($line->credit + $line->debit) > 0, sprintf('Ligne %d: non équilibrée, crédit ou débit doit valoir zéro.', $k));
			$this->assert($db->test(Account::TABLE, 'id = ? AND id_chart = ?', $line->id_account, $chart_id), sprintf('Ligne %d: le compte spécifié n\'est pas lié au bon plan comptable', $k));

			$total += $line->credit;
			$total -= $line->debit;
		}

		// check that transaction type is respected, or fall back to advanced
		if ($this->type != self::TYPE_ADVANCED) {
			$details = $this->getDetails();

			foreach ($details as $detail) {
				$line = $detail->direction == 'credit' ? $this->getCreditLine() : $this->getDebitLine();
				$ok = $db->test(Account::TABLE, 'id = ? AND ' . $db->where('type', $detail->targets), $line->id_account);

				if (!$ok) {
					$this->set('type', self::TYPE_ADVANCED);
					break;
				}
			}
		}

		$this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total)));

		$this->assert($db->test('acc_years', 'id = ?', $this->id_year), 'L\'exercice sélectionné n\'existe pas');
		$this->assert($this->id_creator === null || $db->test('membres', 'id = ?', $this->id_creator), 'Le compte membre créateur de l\'écriture n\'existe pas');

		$this->assert(!$this->id_related || $db->test('acc_transactions', 'id = ?', $this->id_related), 'L\'écriture liée indiquée n\'existe pas');
		$this->assert(!$this->id_related || !$this->exists() || $this->id_related != $this->id, 'Il n\'est pas possible de lier une écriture à elle-même');

		parent::selfCheck();
	}

	public function importFromDepositForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (empty($source['amount'])) {
			throw new UserException('Montant non précisé');
		}

		$this->type = self::TYPE_ADVANCED;
		$amount = $source['amount'];

		$key = 'account_transfer';

		if (empty($source[$key]) || !count($source[$key])) {
			throw new ValidationException('Aucun compte de dépôt n\'a été sélectionné');
		}

		$account = key($source[$key]);

		$line = new Line;
		$line->importForm([
			'debit'      => $amount,
			'credit'     => 0,
			'id_account' => $account,
		]);

		$this->addLine($line);

		$this->importForm($source);
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['id_related']) && empty($source['id_related'])) {
			$source['id_related'] = null;
		}

		// Transpose lines (HTML transaction forms)
		if (!empty($source['lines']) && is_array($source['lines']) && is_string(key($source['lines']))) {
			try {
				$source['lines'] = Utils::array_transpose($source['lines']);
			}
			catch (\InvalidArgumentException $e) {
				throw new ValidationException('Aucun compte sélectionné pour certaines lignes.');
			}

			unset($source['_lines']);
		}

		if (isset($source['type'])) {
			$this->set('type', (int)$source['type']);
		}

		// Simple two-lines transaction
		if (isset($source['amount']) && $this->type != self::TYPE_ADVANCED && isset($this->type)) {
			if (empty($source['amount'])) {
				throw new ValidationException('Montant non précisé');
			}

			$accounts = $this->getTypesDetails($source)[$this->type]->accounts;

			// either supply debit/credit keys or simple accounts
			if (!isset($source['debit'], $source['credit'])) {
				foreach ($accounts as $account) {
					if (empty($account->selector_value)) {
						throw new ValidationException(sprintf('%s : aucun compte n\'a été sélectionné', $account->label));
					}
				}
			}

			$line = [
				'reference' => $source['payment_reference'] ?? null,
			];

			$source['lines'] = [
				$line + [
					$accounts[0]->direction => $source['amount'],
					'account_selector' => $accounts[0]->selector_value,
					'account' => $source[$accounts[0]->direction] ?? null,
				],
				$line + [
					$accounts[1]->direction => $source['amount'],
					'account_selector' => $accounts[1]->selector_value,
					'account' => $source[$accounts[1]->direction] ?? null,
				],
			];

			if ($this->type != self::TYPE_TRANSFER || Config::getInstance()->analytical_set_all) {
				$source['lines'][0]['id_project'] = $source['id_project'] ?? null;
			}

			if (Config::getInstance()->analytical_set_all) {
				$source['lines'][1]['id_project'] = $source['lines'][0]['id_project'];
			}

			unset($line, $accounts, $account, $source['simple']);
		}

		// Add lines
		if (isset($source['lines']) && is_array($source['lines'])) {
			$this->resetLines();
			$db = DB::getInstance();

			foreach ($source['lines'] as $i => $line) {
				if (empty($line['account'])
					&& empty($line['id_account'])
					&& (empty($line['account_selector'])
						|| !is_array($line['account_selector']) || empty(key($line['account_selector'])))) {
					throw new ValidationException(sprintf('Ligne %d : aucun compte n\'a été sélectionné', $i + 1));
				}

				if (isset($line['account_selector'])) {
					$line['id_account'] = (int)key($line['account_selector']);
				}
				elseif (isset($line['account'])) {
					if (empty($this->id_year) && empty($source['id_year'])) {
						throw new ValidationException('L\'identifiant de l\'exercice comptable n\'est pas précisé.');
					}

					$id_chart = $id_chart ?? $db->firstColumn('SELECT id_chart FROM acc_years WHERE id = ?;', $source['id_year'] ?? $this->id_year);
					$line['id_account'] = $db->firstColumn('SELECT id FROM acc_accounts WHERE code = ? AND id_chart = ?;', $line['account'], $id_chart);

					if (empty($line['id_account'])) {
						throw new ValidationException(sprintf('Le compte avec le code "%s" sur la ligne %d n\'existe pas.', $line['account'], $i+1));
					}
				}

				$l = new Line;
				$l->importForm($line);
				$this->addLine($l);
			}
		}

		return parent::importForm($source);
	}

	public function importFromNewForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (!isset($source['id_related'])) {
			unset($source['id_related']);
		}

		$type = $source['type'] ?? ($this->type ?? self::TYPE_ADVANCED);

		if (self::TYPE_ADVANCED != $type) {
			if (!isset($source['amount'])) {
				throw new UserException('Montant non précisé');
			}
		}

		$this->importForm($source);
	}

	public function importFromAPI(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['type']) && ctype_alpha($source['type']) && defined(self::class . '::TYPE_' . strtoupper($source['type']))) {
			$source['type'] = constant(self::class . '::TYPE_' . strtoupper($source['type']));
		}

		$this->importFromNewForm($source);
	}

	public function importFromPayoffForm(?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (empty($this->_related)) {
			throw new \LogicException('Cannot import pay-off if no related transaction is set');
		}

		// Just make sure we can't trigger importFromNewForm
		unset($source['type'], $source['lines']);

		if (empty($source['amount'])) {
			throw new ValidationException('Montant non précisé');
		}

		if (empty($source['account']) || !is_array($source['account'])) {
			throw new ValidationException('Aucun compte de règlement sélectionné.');
		}

		$id_account = null;
		// Reverse direction (compared with debt/credit transaction)
		$d1 = ($this->_related->type == self::TYPE_CREDIT) ? 'credit' : 'debit';
		$d2 = ($d1 == 'credit') ? 'debit' : 'credit';

		foreach ($this->_related->getLines() as $line) {
			if (($this->_related->type == self::TYPE_DEBT && $line->debit)
				|| ($this->_related->type == self::TYPE_CREDIT && $line->credit)) {
				// Skip the type of debt/credit, just keep the thirdparty account
				continue;
			}

			$id_account = $line->id_account;
			break;
		}

		if (!$id_account) {
			throw new \LogicException('Cannot find account ID of related transaction');
		}

		$line = [
			'reference' => $source['payment_reference'] ?? null,
		];

		$source['lines'] = [
			// First line is third-party account
			$line + compact('id_account') + [$d1 => $source['amount']],
			// Second line is payment account
			$line + ['account_selector' => $source['account'], $d2 => $source['amount']],
		];

		$source['lines'][0]['id_project'] = $source['id_project'] ?? null;

		if (Config::getInstance()->analytical_set_all) {
			$source['lines'][1]['id_project'] = $source['lines'][0]['id_project'];
		}

		$this->importFromNewForm($source);
	}

	public function importFromBalanceForm(Year $year, ?array $source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		$this->label = 'Balance d\'ouverture';
		$this->date = $year->start_date;
		$this->id_year = $year->id();
		$this->type = self::TYPE_ADVANCED;
		$this->addStatus(self::STATUS_OPENING_BALANCE);

		$this->importFromNewForm($source);

		$diff = $this->getLinesCreditSum() - $this->getLinesDebitSum();

		if (!$diff) {
			return;
		}

		// Add final balance line
		$line = new Line;

		if ($diff > 0) {
			$line->debit = $diff;
		}
		else {
			$line->credit = abs($diff);
		}

		$open_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $year->id_chart, Account::TYPE_OPENING);

		if (!$open_account) {
			throw new ValidationException('Aucun compte de bilan d\'ouverture n\'existe dans le plan comptable');
		}

		$line->id_account = $open_account->id();

		$this->addLine($line);
	}

	public function year()
	{
		return EntityManager::findOneById(Year::class, $this->id_year);
	}

	public function listFiles()
	{
		return Files::list($this->getAttachementsDirectory());
	}

	public function getAttachementsDirectory(): string
	{
		return File::CONTEXT_TRANSACTION . '/' . $this->id();
	}

	public function linkToUser(int $user_id, ?int $service_id = null)
	{
		$db = EntityManager::getInstance(self::class)->DB();

		return $db->preparedQuery('REPLACE INTO acc_transactions_users (id_transaction, id_user, id_service_user) VALUES (?, ?, ?);',
			$this->id(), $user_id, $service_id);
	}

	public function updateLinkedUsers(array $users): void
	{
		$users = array_values($users);

		foreach ($users as $i => $user) {
			if (!(is_int($user) || (is_string($user) && ctype_digit($user)))) {
				throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid user ID', $i, $user));
			}
		}

		$db = EntityManager::getInstance(self::class)->DB();

		if (!$this->checkLinkedUsersChange($users)) {
			return;
		}

		$db->begin();

		$sql = sprintf('DELETE FROM acc_transactions_users WHERE id_transaction = ? AND %s AND id_service_user IS NULL;', $db->where('id_user', 'NOT IN', $users));
		$db->preparedQuery($sql, $this->id());

		foreach ($users as $id) {
			$db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user) VALUES (?, ?);', $this->id(), (int)$id);
		}

		$db->commit();
	}

	public function checkLinkedUsersChange(array $users): bool
	{
		$existing = $this->listLinkedUsersAssoc();
		ksort($users);
		ksort($existing);

		if ($users === $existing) {
			return false;
		}

		return true;
	}

	public function listLinkedUsers(): array
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = Config::getInstance()->get('champ_identite');
		$sql = sprintf('SELECT m.id, m.%s AS identity, l.id_service_user FROM membres m INNER JOIN acc_transactions_users l ON l.id_user = m.id WHERE l.id_transaction = ? ORDER BY id;', $identity_column);
		return $db->get($sql, $this->id());
	}

	public function listLinkedUsersAssoc(): array
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = Config::getInstance()->get('champ_identite');
		$sql = sprintf('SELECT m.id, m.%s AS identity, l.id_service_user
			FROM membres m
			INNER JOIN acc_transactions_users l ON l.id_user = m.id
			WHERE l.id_transaction = ? AND l.id_service_user IS NULL;', $identity_column);
		return $db->getAssoc($sql, $this->id());
	}

	public function unlinkServiceUser(int $id): void
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user = ?', $this->id(), $id);
	}

	public function listRelatedTransactions()
	{
		return EntityManager::getInstance(self::class)->all('SELECT * FROM @TABLE WHERE id_related = ?;', $this->id);
	}

	public function setDefaultAccount(int $type, string $direction, int $id): void
	{
		$this->_default_selector[$type][$direction] = Accounts::getSelector($id);
	}

	/**
	 * Return tuples of accounts selectors according to each "simplified" type
	 */
	public function getTypesDetails(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$details = [
			self::TYPE_REVENUE => [
				'accounts' => [
					[
						'label' => 'Type de recette',
						'targets' => [Account::TYPE_REVENUE],
						'direction' => 'credit',
						'defaults' => [
							self::TYPE_CREDIT => 'credit',
						],
					],
					[
						'label' => 'Compte d\'encaissement',
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
						'direction' => 'debit',
						'defaults' => [
							self::TYPE_EXPENSE => 'credit',
							self::TYPE_TRANSFER => 'credit',
						],
					],
				],
				'label' => self::TYPES_NAMES[self::TYPE_REVENUE],
			],
			self::TYPE_EXPENSE => [
				'accounts' => [
					[
						'label' => 'Type de dépense',
						'targets' => [Account::TYPE_EXPENSE],
						'direction' => 'debit',
						'defaults' => [
							self::TYPE_DEBT => 'debit',
						],
					],
					[
						'label' => 'Compte de décaissement',
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
						'direction' => 'credit',
						'defaults' => [
							self::TYPE_REVENUE => 'debit',
							self::TYPE_TRANSFER => 'credit',
						],
					],
				],
				'label' => self::TYPES_NAMES[self::TYPE_EXPENSE],
				'help' => null,
			],
			self::TYPE_TRANSFER => [
				'accounts' => [
					[
						'label' => 'De',
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
						'direction' => 'credit',
						'defaults' => [
							self::TYPE_EXPENSE => 'credit',
							self::TYPE_REVENUE => 'debit',
						],
					],
					[
						'label' => 'Vers',
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
						'direction' => 'debit',
					],
				],
				'label' => self::TYPES_NAMES[self::TYPE_TRANSFER],
				'help' => 'Dépôt en banque, virement interne, etc.',
			],
			self::TYPE_DEBT => [
				'accounts' => [
					[
						'label' => 'Type de dette (dépense)',
						'targets' => [Account::TYPE_EXPENSE],
						'direction' => 'debit',
						'defaults' => [
							self::TYPE_EXPENSE => 'debit',
						],
					],
					[
						'label' => 'Compte de tiers',
						'targets' => [Account::TYPE_THIRD_PARTY],
						'direction' => 'credit',
						'defaults' => [
							self::TYPE_CREDIT => 'debit',
						],
					],
				],
				'label' => self::TYPES_NAMES[self::TYPE_DEBT],
				'help' => 'Quand l\'association doit de l\'argent à un membre ou un fournisseur',
			],
			self::TYPE_CREDIT => [
				'accounts' => [
					[
						'label' => 'Type de créance (recette)',
						'targets' => [Account::TYPE_REVENUE],
						'direction' => 'credit',
						'defaults' => [
							self::TYPE_REVENUE => 'credit',
						],
					],
					[
						'label' => 'Compte de tiers',
						'targets' => [Account::TYPE_THIRD_PARTY],
						'direction' => 'debit',
						'defaults' => [
							self::TYPE_DEBT => 'credit',
						],
					],
				],
				'label' => self::TYPES_NAMES[self::TYPE_CREDIT],
				'help' => 'Quand un membre ou un client doit de l\'argent à l\'association',
			],
			self::TYPE_ADVANCED => [
				'accounts' => [],
				'label' => self::TYPES_NAMES[self::TYPE_ADVANCED],
				'help' => 'Choisir les comptes du plan comptable, ventiler une écriture sur plusieurs comptes, etc.',
			],
		];

		// Find out which lines are credit and debit
		$current_accounts = [];

		foreach ($this->getLinesWithAccounts() as $i => $l) {
			if ($l->debit) {
				$current_accounts['debit'] = $l->account_selector;
			}
			elseif ($l->credit) {
				$current_accounts['credit'] = $l->account_selector;
			}

			if (count($current_accounts) == 2) {
				break;
			}
		}

		foreach ($details as $key => &$type) {
			$type = (object) $type;
			$type->id = $key;
			foreach ($type->accounts as &$account) {
				$account = (object) $account;
				$account->targets_string = implode(':', $account->targets);
				$account->selector_name = sprintf('simple[%s][%s]', $key, $account->direction);

				$d = null;

				// Copy selector value for current type
				if ($type->id == $this->type) {
					$d = $account->direction;
				}
				else {
					$d = $account->defaults[$this->type] ?? null;
				}

				if ($d) {
					$account->selector_value = $source['simple'][$key][$d] ?? ($current_accounts[$d] ?? null);
				}

				if (empty($account->selector_value) && isset($this->_default_selector[$key][$account->direction])) {
					$account->selector_value = $this->_default_selector[$key][$account->direction];
				}

				$account->id = isset($account->selector_value) ? key($account->selector_value) : null;
				$account->name = isset($account->selector_value) ? current($account->selector_value) : null;
			}
		}

		unset($account, $type);

		return $details;
	}

	public function getDetails(): ?array
	{
		if ($this->type == self::TYPE_ADVANCED) {
			return null;
		}

		$details = $this->getTypesDetails();

		return [
			'left' => $details[$this->type]->accounts[0],
			'right' => $details[$this->type]->accounts[1],
		];
	}

	public function payOffFrom(int $id): ?\stdClass
	{
		$this->_related = EntityManager::findOneById(self::class, $id);

		if (!$this->_related) {
			return null;
		}

		$this->id_related = $this->_related->id();
		$this->label = ($this->_related->type == Transaction::TYPE_DEBT ? 'Règlement de dette : ' : 'Règlement de créance : ') . $this->_related->label;
		$this->type = self::TYPE_ADVANCED;

		$out = (object) [
			'id'         => $this->_related->id,
			'amount'     => $this->_related->sum(),
			'id_project' => $this->_related->getProjectId(),
			'type'       => $this->_related->type,
		];

		return $out;
	}

	public function getTypeName(): string
	{
		return self::TYPES_NAMES[$this->type];
	}

	public function asDetailsArray(bool $modified = false): array
	{
		$lines = [];
		$debit = 0;
		$credit = 0;

		foreach ($this->getLines() as $i => $line) {
			$lines[$i+1] = $line->asDetailsArray();

			$debit += $line->debit;
			$credit +=$line->credit;
		}

		$src = $this->asArray();

		return [
			'Numéro'          => $src['id'] ?? '--',
			'Type'            => self::TYPES_NAMES[$src['type'] ?? self::TYPE_ADVANCED],
			'Libellé'         => $src['label'] ?? null,
			'Date'            => isset($src['date']) ? $src['date']->format('d/m/Y') : null,
			'Pièce comptable' => $src['reference'] ?? null,
			'Remarques'       => $src['notes'] ?? null,
			'Total crédit'    => Utils::money_format($debit),
			'Total débit'     => Utils::money_format($credit),
			'Lignes'          => $lines,
		];
	}

	public function asJournalArray(): array
	{
		$out = $this->asArray();

		if ($this->exists()) {
			$out['url'] = $this->url();
		}

		$out['lines'] = $this->getLinesWithAccounts();
		foreach ($out['lines'] as &$line) {
			unset($line->line);
		}
		unset($line);
		return $out;
	}

	/**
	 * Compare transaction, to see if something has changed
	 */
	public function diff(): ?array
	{
		$out = [
			'transaction' => [],
			'lines' => [],
			'lines_new' => [],
			'lines_removed' => [],
		];

		foreach ($this->_modified as $key => $old) {
			$out['transaction'][$key] = [$old, $this->$key];
		}

		static $keys = [
			'id_account' => 'Numéro de compte',
			'label'      => 'Libellé ligne',
			'reference'  => 'Référence ligne',
			'credit'     => 'Crédit',
			'debit'      => 'Débit',
			'id_project' => 'Projet',
		];

		$new_lines = [];
		$old_lines = [];

		foreach ($this->getLines() as $i => $line) {
			if ($line->exists()) {
				$diff = [];

				foreach ($keys as $key => $label) {
					if ($line->isModified($key)) {
						$diff[$key] = [$line->getModifiedProperty($key), $line->$key];
					}
				}

				if (count($diff)) {
					if (isset($diff['id_project'])) {
						$diff['project'] = [Projects::getName($diff['id_project'][0]), Projects::getName($diff['id_project'][1])];
					}

					if (isset($diff['id_account'])) {
						$diff['account'] = [Accounts::getCodeAndLabel($diff['id_account'][0]), Accounts::getCodeAndLabel($diff['id_account'][1])];
					}
				}

				$l = array_merge($line->asArray(), compact('diff'));

				$l['account'] = Accounts::getCodeAndLabel($l['id_account']);
				$l['project'] = Projects::getName($l['id_project']);

				$out['lines'][$i] = $l;
			}
			else {
				$new_line = [];

				foreach ($keys as $key => $label) {
					$new_line[$key] = $line->$key;
				}

				$new_lines[] = $new_line;
			}
		}

		foreach ($this->_old_lines as $line) {
			$old_line = [];

			foreach ($keys as $key => $label) {
				$old_line[$key] = $line->$key;
			}

			$old_lines[] = $old_line;
		}

		// Append new lines and changed lines
		foreach ($new_lines as $i => $new_line) {
			if (!in_array($new_line, $old_lines)) {
				$new_line['account'] = Accounts::getCodeAndLabel($new_line['id_account']);
				$new_line['project'] = Projects::getName($new_line['id_project']);
				$out['lines_new'][] = $new_line;
			}
		}

		// Append removed lines
		foreach ($old_lines as $i => $old_line) {
			if (!in_array($old_line, $new_lines)) {
				$old_line['account'] = Accounts::getCodeAndLabel($old_line['id_account']);
				$old_line['project'] = Projects::getName($old_line['id_project']);
				$out['lines_removed'][] = $old_line;
			}
		}

		if (!count($out['transaction']) && !count($out['lines']) && !count($out['lines_new']) && !count($out['lines_removed'])) {
			return null;
		}

		return $out;
	}

	public function url(): string
	{
		return Utils::getLocalURL('!acc/transactions/details.php?id=' . $this->id());
	}

	public function getProject(): ?array
	{
		$id = $this->getProjectId();

		if (!$id) {
			return null;
		}

		$name = Projects::getName($id);
		return compact('id', 'name');
	}

	public function getPaymentReference(): ?string
	{
		foreach ($this->getLines() as $line) {
			if ($line->reference) {
				return $line->reference;
			}
		}

		return null;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<










































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Accounting/Year.php version [b488c48138].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
<?php

namespace Garradin\Entities\Accounting;

use KD2\DB\EntityManager;
use Garradin\DB;
use Garradin\Entity;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Accounting\Accounts;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

class Year extends Entity
{
	const TABLE = 'acc_years';

	protected $id;
	protected $label;
	protected $start_date;
	protected $end_date;
	protected $closed = 0;
	protected $id_chart;

	protected $_types = [
		'id'         => 'int',
		'label'      => 'string',
		'start_date' => 'date',
		'end_date'   => 'date',
		'closed'     => 'int',
		'id_chart'   => 'int',
	];

	public function selfCheck(): void
	{
		$this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide.');
		$this->assert(strlen($this->label) <= 200, 'Le libellé ne peut faire plus de 200 caractères.');
		$this->assert($this->start_date instanceof \DateTime, 'La date de début de l\'exercice n\'est pas définie.');
		$this->assert($this->end_date instanceof \DateTime, 'La date de début de l\'exercice n\'est pas définie.');

		$this->assert($this->start_date < $this->end_date, 'La date de fin doit être postérieure à la date de début');
		$this->assert($this->closed === 0 || $this->closed === 1);

		$db = DB::getInstance();

		$this->assert($this->id_chart !== null);
		parent::selfCheck();

		if ($this->exists()) {
			$this->assert(
				!$db->test(Transaction::TABLE, 'id_year = ? AND date < ?', $this->id(), $this->start_date->format('Y-m-d')),
				'Des écritures de cet exercice ont une date antérieure à la date de début de l\'exercice.'
			);

			$this->assert(
				!$db->test(Transaction::TABLE, 'id_year = ? AND date > ?', $this->id(), $this->end_date->format('Y-m-d')),
				'Des écritures de cet exercice ont une date postérieure à la date de fin de l\'exercice.'
			);
		}
	}

	public function close(int $user_id): void
	{
		if ($this->closed) {
			throw new \LogicException('Cet exercice est déjà clôturé');
		}

		$this->set('closed', 1);
		$this->save();
	}

	public function reopen(int $user_id): void
	{
		if (!$this->closed) {
			throw new \LogicException('This year is already open');
		}

		$closing_id = $this->accounts()->getClosingAccountId();

		if (!$closing_id) {
			throw new UserException('Aucun compte n\'est indiqué comme compte de clôture dans le plan comptable');
		}

		$this->set('closed', 0);
		$this->save();

		// Create validated transaction to show that someone has reopened the year
		$t = new Transaction;
		$t->import([
			'id_year'    => $this->id(),
			'label'      => sprintf('Exercice réouvert le %s', date('d/m/Y à H:i:s')),
			'type'       => Transaction::TYPE_ADVANCED,
			'date'       => $this->end_date->format('d/m/Y'),
			'id_creator' => $user_id,
			'validated'  => 1,
			'notes'      => 'Écriture automatique créée lors de la réouverture, à des fins de transparence. Cette écriture ne peut pas être supprimée ni modifiée.',
		]);

		$line = new Line;
		$line->import([
			'debit' => 0,
			'credit' => 1,
			'id_account' => $closing_id,
		]);
		$t->addLine($line);

		$line = new Line;
		$line->import([
			'debit'      => 1,
			'credit'     => 0,
			'id_account' => $closing_id,
		]);
		$t->addLine($line);

		$t->save();
	}

	/**
	 * Splits an accounting year between the current year and another one, at a given date
	 * Any transaction after the given date will be moved to the target year.
	 */
	public function split(\DateTime $date, Year $target): void
	{
		if ($this->closed) {
			throw new \LogicException('Cet exercice est déjà clôturé');
		}

		if ($target->closed) {
			throw new \LogicException('L\'exercice cible est déjà clôturé');
		}

		DB::getInstance()->preparedQuery('UPDATE acc_transactions SET id_year = ? WHERE id_year = ? AND date > ?;',
			$target->id(), $this->id(), $date->format('Y-m-d'));
	}

	public function delete(): bool
	{
		$db = DB::getInstance();
		$ids = $db->getAssoc('SELECT id, id FROM acc_transactions WHERE id_year = ?;', $this->id());


		// Delete all files
		foreach ($ids as $id) {
			Files::delete(File::CONTEXT_TRANSACTION . '/' . $id);
		}

		// Manual delete of transactions, as there is a voluntary safeguard in SQL: no cascade
		$db->preparedQuery('DELETE FROM acc_transactions WHERE id_year = ?;', $this->id());

		return parent::delete();
	}

	public function countTransactions(): int
	{
		$db = DB::getInstance();
		return $db->count(Transaction::TABLE, $db->where('id_year', $this->id()));
	}

	public function chart()
	{
		return EntityManager::findOneById(Chart::class, $this->id_chart);
	}

	public function accounts()
	{
		return new Accounts($this->id_chart);
	}

	public function label_years()
	{
		$start = Utils::date_fr($this->start_date, 'Y');
		$end = Utils::date_fr($this->end_date, 'Y');
		return $start == $end ? $start : sprintf('%s-%s', $start, substr($end, -2));
	}


	/**
	 * List common accounts used in this year, grouped by type
	 * @return array
	 */
	public function listCommonAccountsGrouped(array $types = null, bool $hide_empty = false): array
	{
		if (null === $types) {
			// If we want all types, then we will get used or bookmarked accounts in common types
			// and only bookmarked accounts for other types, grouped in "Others"
			$target = Account::COMMON_TYPES;
		}
		else {
			$target = $types;
		}

		$out = [];

		foreach ($target as $type) {
			$out[$type] = (object) [
				'label'    => Account::TYPES_NAMES[$type],
				'type'     => $type,
				'accounts' => [],
			];
		}

		if (null === $types) {
			$out[0] = (object) [
				'label'    => 'Autres',
				'type'     => 0,
				'accounts' => [],
			];
		}

		$db = DB::getInstance();

		$sql = sprintf('SELECT a.* FROM acc_accounts a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			LEFT JOIN acc_transactions c ON c.id = b.id_transaction AND c.id_year = %d
			WHERE a.id_chart = %d AND ((a.%s AND (a.bookmark = 1 OR a.user = 1 OR c.id IS NOT NULL)) %s)
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;',
			$this->id(),
			$this->id_chart,
			$db->where('type', $target),
			(null === $types) ? 'OR (a.bookmark = 1)' : ''
		);

		$query = $db->iterate($sql);

		foreach ($query as $row) {
			$t = in_array($row->type, $target, true) ? $row->type : 0;
			$out[$t]->accounts[] = $row;
		}

		if ($hide_empty) {
			foreach ($out as $key => $v) {
				if (!count($v->accounts)) {
					unset($out[$key]);
				}
			}
		}

		return $out;
	}

	public function hasOpeningBalance(): bool
	{
		return DB::getInstance()->test(Transaction::TABLE, 'id_year = ? AND status & ?', $this->id(), Transaction::STATUS_OPENING_BALANCE);
	}

	public function deleteOpeningBalance(): void
	{
		$em = EntityManager::getInstance(Transaction::class);
		$list = $em->iterate('SELECT * FROM @TABLE WHERE id_year = ? AND status & ?', $this->id(), Transaction::STATUS_OPENING_BALANCE);

		foreach ($list as $t) {
			$t->delete();
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Files/File.php version [e97bbb4fd4].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
<?php

namespace Garradin\Entities\Files;

use KD2\Graphics\Image;
use KD2\DB\EntityManager as EM;
use KD2\Security;

use Garradin\DB;
use Garradin\Entity;
use Garradin\Plugin;
use Garradin\Template;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Membres\Session;
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;
use Garradin\Web\Render\Render;

use Garradin\Files\Files;

use const Garradin\{WWW_URL, BASE_URL, ENABLE_XSENDFILE, SECRET_KEY};

/**
 * This is a virtual entity, it cannot be saved to a SQL table
 */
class File extends Entity
{
	const TABLE = 'files';

	protected $id;

	/**
	 * Parent directory of file
	 */
	protected $parent;

	/**
	 * File name
	 */
	protected $name;

	/**
	 * Complete file path (parent + '/' + name)
	 */
	protected $path;

	/**
	 * Type of file: file or directory
	 */
	protected $type = self::TYPE_FILE;
	protected $mime;
	protected $size;
	protected $modified;
	protected $image;

	protected $_types = [
		'id'           => '?int',
		'path'         => 'string',
		'parent'       => '?string',
		'name'         => 'string',
		'type'         => 'int',
		'mime'         => '?string',
		'size'         => '?int',
		'modified'     => 'DateTime',
		'image'        => 'int',
	];

	const TYPE_FILE = 1;
	const TYPE_DIRECTORY = 2;
	const TYPE_LINK = 3;

	/**
	 * Tailles de miniatures autorisées, pour ne pas avoir 500 fichiers générés avec 500 tailles différentes
	 * @var array
	 */
	const ALLOWED_THUMB_SIZES = [
		'150px' => [['resize', 150]],
		'200px' => [['resize', 200]],
		'500px' => [['resize', 500]],
		'crop-256px' => [['cropResize', 256, 256]],
	];

	const THUMB_CACHE_ID = 'file.thumb.%s.%s';

	const THUMB_SIZE_TINY = '200px';
	const THUMB_SIZE_SMALL = '500px';

	const CONTEXT_DOCUMENTS = 'documents';
	const CONTEXT_USER = 'user';
	const CONTEXT_TRANSACTION = 'transaction';
	const CONTEXT_CONFIG = 'config';
	const CONTEXT_WEB = 'web';
	const CONTEXT_SKELETON = 'skel';

	const CONTEXTS_NAMES = [
		self::CONTEXT_DOCUMENTS => 'Documents',
		self::CONTEXT_USER => 'Membre',
		self::CONTEXT_TRANSACTION => 'Écriture comptable',
		self::CONTEXT_CONFIG => 'Configuration',
		self::CONTEXT_WEB => 'Site web',
		self::CONTEXT_SKELETON => 'Squelettes',
	];

	const IMAGE_TYPES = [
		'image/png',
		'image/gif',
		'image/jpeg',
		'image/webp',
	];

	const PREVIEW_TYPES = [
		// We expect modern browsers to be able to preview a PDF file
		// even if the user has disabled PDF opening in browser
		// (something we cannot detect)
		'application/pdf',
		'audio/mpeg',
		'audio/ogg',
		'audio/wave',
		'audio/wav',
		'audio/x-wav',
		'audio/x-pn-wav',
		'audio/webm',
		'video/webm',
		'video/ogg',
		'application/ogg',
		'video/mp4',
		'image/png',
		'image/gif',
		'image/jpeg',
		'image/webp',
		'image/svg+xml',
		'text/plain',
		'text/html',
	];

	// https://book.hacktricks.xyz/pentesting-web/file-upload
	const FORBIDDEN_EXTENSIONS = '!^(?:cgi|exe|sh|bash|com|pif|jspx?|js[wxv]|action|do|php(?:s|\d+)?|pht|phtml?|shtml|phar|htaccess|inc|cfml?|cfc|dbm|swf|pl|perl|py|pyc|asp|so)$!i';

	static public function getColumns(): array
	{
		return array_keys((new self)->_types);
	}

	public function selfCheck(): void
	{
		$this->assert($this->type === self::TYPE_DIRECTORY || $this->type === self::TYPE_FILE, 'Unknown file type');
		$this->assert($this->type === self::TYPE_DIRECTORY || $this->size !== null, 'File size must be set');
		$this->assert($this->image === 0 || $this->image === 1, 'Unknown image value');
		$this->assert(trim($this->name) !== '', 'Le nom de fichier ne peut rester vide');
		$this->assert(strlen($this->path), 'Le chemin ne peut rester vide');
		$this->assert(strlen($this->parent) || '' === $this->parent, 'Le chemin ne peut rester vide');
	}

	public function context(): string
	{
		return strtok($this->path, '/');
	}

	public function fullpath(): string
	{
		$path = Files::callStorage('getFullPath', $this);

		if (null === $path) {
			throw new \RuntimeException('File does not exist: ' . $this->path);
		}

		return $path;
	}

	/**
	 * Return TRUE if the file can be previewed natively in a browser
	 * @return bool
	 */
	public function canPreview(): bool
	{
		return in_array($this->mime, self::PREVIEW_TYPES);
	}

	public function delete(): bool
	{
		Files::callStorage('checkLock');

		// Delete actual file content
		Files::callStorage('delete', $this);

		Plugin::fireSignal('files.delete', ['file' => $this]);

		// clean up thumbnails
		foreach (self::ALLOWED_THUMB_SIZES as $key => $operations)
		{
			Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->pathHash(), $key));
		}

		DB::getInstance()->delete('files_search', 'path = ? OR path LIKE ?', $this->path, $this->path . '/%');

		if ($this->exists()) {
			return parent::delete();
		}

		return true;
	}

	/**
	 * Change ONLY the file name, not the parent path
	 * @param  string $new_name New file name
	 * @return bool
	 */
	public function changeFileName(string $new_name): bool
	{
		$new_name = self::filterName($new_name);
		return $this->rename(ltrim($this->parent . '/' . $new_name, '/'));
	}

	/**
	 * Change ONLY the directory where the file is
	 * @param  string $target New directory path
	 * @return bool
	 */
	public function move(string $target): bool
	{
		return $this->rename($target . '/' . $this->name);
	}

	/**
	 * Rename a file, this can include moving it (the UNIX way)
	 * @param  string $new_path Target path
	 * @return bool
	 */
	public function rename(string $new_path): bool
	{
		self::validatePath($new_path);
		self::validateFileName(Utils::basename($new_path));

		if ($new_path == $this->path || 0 === strpos($new_path . '/', $this->path . '/')) {
			throw new UserException('Impossible de renommer ou déplacer un fichier vers lui-même');
		}

		$return = Files::callStorage('move', $this, $new_path);

		Plugin::fireSignal('files.move', ['file' => $this, 'new_path' => $new_path]);

		$escaped = strtr($this->path, ['%' => '!%', '_' => '!_', '!' => '!!']);

		// Rename references in files_search
		DB::getInstance()->preparedQuery('UPDATE files_search
			SET path = ? || SUBSTR(path, 1+LENGTH(?))
			WHERE path LIKE ?;',
			$new_path . '/', $this->path . '/', $escaped . '%');

		return $return;
	}

	/**
	 * Copy the current file to a new location
	 * @param  string $target Target path
	 * @return self
	 */
	public function copy(string $target): self
	{
		return self::createAndStore(Utils::dirname($target), Utils::basename($target), Files::callStorage('getFullPath', $this), null);
	}

	public function setContent(string $content): self
	{
		$this->set('modified', new \DateTime);
		$this->store(null, rtrim($content));
		$this->indexForSearch($content);
		return $this;
	}

	/**
	 * Store contents in file, either from a local path or from a binary string
	 * If one parameter is supplied, the other must be NULL (you cannot omit one)
	 *
	 * @param  string $source_path
	 * @param  string $source_content
	 * @param  bool   $index_search Set to FALSE if you don't want the document to be indexed in the file search
	 * @return self
	 */
	public function store(?string $source_path, ?string $source_content, bool $index_search = true): self
	{
		if (!$this->path || !$this->name) {
			throw new \LogicException('Cannot store a file that does not have a target path and name');
		}

		if ($this->type == self::TYPE_DIRECTORY) {
			throw new \LogicException('Cannot store a directory');
		}

		if ($source_path && !$source_content)
		{
			$this->set('size', filesize($source_path));
		}
		else
		{
			$this->set('size', strlen($source_content));
		}

		Files::checkQuota($this->size);

		// Check that it's a real image
		if ($this->image) {
			try {
				if ($source_path && !$source_content) {
					$i = new Image($source_path);
				}
				else {
					$i = Image::createFromBlob($source_content);
				}

				// Recompress PNG files from base64, assuming they are coming
				// from JS canvas which doesn't know how to gzip (d'oh!)
				if ($i->format() == 'png' && null !== $source_content) {
					$source_content = $i->output('png', true);
					$this->set('size', strlen($source_content));
				}

				unset($i);
			}
			catch (\RuntimeException $e) {
				$this->set('image', 0);
			}
		}

		Files::callStorage('checkLock');

		// If a file of the same name already exists, define a new name
		if (Files::callStorage('exists', $this->path) && !$this->exists()) {
			$pos = strrpos($this->name, '.');
			$new_name = substr($this->name, 0, $pos) . '.' . substr(sha1(random_bytes(16)), 0, 10) . substr($this->name, $pos);
			$this->set('name', $new_name);
		}

		if (!$this->modified) {
			$this->set('modified', new \DateTime);
		}

		if (null !== $source_path) {
			$return = Files::callStorage('storePath', $this, $source_path);
		}
		else {
			$return = Files::callStorage('storeContent', $this, $source_content);
		}

		if (!$return) {
			throw new UserException('Le fichier n\'a pas pu être enregistré.');
		}

		Plugin::fireSignal('files.store', ['file' => $this]);

		if ($index_search) {
			$this->indexForSearch($source_content);
		}
		else {
			$this->removeFromSearch();
		}

		// clean up thumbnails
		foreach (self::ALLOWED_THUMB_SIZES as $key => $operations)
		{
			Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->pathHash(), $key));
		}

		return $this;
	}

	public function indexForSearch(?string $source_content, ?string $title = null, ?string $forced_mime = null): void
	{
		$mime = $forced_mime ?? $this->mime;

		// Store content in search table
		if (substr($mime, 0, 5) == 'text/') {
			$content = $source_content ?? Files::callStorage('fetch', $this);

			if ($mime === 'text/html' || $mime == 'text/xml') {
				$content = htmlspecialchars_decode(strip_tags($content));
			}
		}
		else {
			$content = null;
		}

		// Only index valid UTF-8
		if (isset($content) && preg_match('//u', $content)) {
			// Truncate content at 150KB
			$content = substr(trim($content), 0, 150*1024);
		}
		else {
			$content = null;
		}

		$db = DB::getInstance();
		$db->preparedQuery('DELETE FROM files_search WHERE path = ?;', $this->path);
		$db->preparedQuery('INSERT INTO files_search (path, title, content) VALUES (?, ?, ?);', $this->path, $title ?? $this->name, $content);
	}

	public function removeFromSearch(): void
	{
		$db = DB::getInstance();
		$db->preparedQuery('DELETE FROM files_search WHERE path = ?;', $this->path);
	}

	/**
	 * Create and store a file
	 * If one parameter is supplied, the other must be NULL (you cannot omit one)
	 * @param  string $path           Target path
	 * @param  string $name           Target name
	 * @param  string $source_path    Source file path
	 * @param  string $source_content OR source file content (binary string)
	 * @return self
	 */
	static public function createAndStore(string $path, string $name, ?string $source_path, ?string $source_content): self
	{
		$file = self::create($path, $name, $source_path, $source_content);

		$file->store($source_path, $source_content);

		return $file;
	}

	/**
	 * Create a new directory
	 * @param  string $path          Target parent path
	 * @param  string $name          Target name
	 * @param  bool   $create_parent Create parent directories if they don't exist
	 * @return self
	 */
	static public function createDirectory(string $path, string $name, bool $create_parent = true): self
	{
		$name = self::filterName($name);

		$fullpath = trim($path . '/' . $name, '/');

		self::validatePath($fullpath);
		Files::checkQuota();

		if (Files::callStorage('exists', $fullpath)) {
			throw new ValidationException('Le nom de répertoire choisi existe déjà: ' . $fullpath);
		}

		if ($path !== '' && $create_parent) {
			self::ensureDirectoryExists($path);
		}

		$file = new self;
		$file->set('path', $fullpath);
		$file->set('name', $name);
		$file->set('parent', $path);
		$file->set('type', self::TYPE_DIRECTORY);
		$file->set('image', 0);
		$file->set('modified', new \DateTime);

		Files::callStorage('mkdir', $file);

		Plugin::fireSignal('files.mkdir', ['file' => $file]);

		return $file;
	}

	static public function ensureDirectoryExists(string $path): void
	{
		$db = DB::getInstance();
		$parts = explode('/', $path);
		$tree = '';

		foreach ($parts as $part) {
			$tree = trim($tree . '/' . $part, '/');
			$exists = $db->test(File::TABLE, 'type = ? AND path = ?', self::TYPE_DIRECTORY, $tree);

			if (!$exists) {
				try {
					self::createDirectory(Utils::dirname($tree), Utils::basename($tree), false);
				}
				catch (ValidationException $e) {
					// Ignore when directory already exists
				}
			}
		}
	}

	static public function create(string $path, string $name, ?string $source_path, ?string $source_content): self
	{
		if (!isset($source_path) && !isset($source_content)) {
			throw new \InvalidArgumentException('Either source path or source content should be set but not both');
		}

		self::validateFileName($name);
		self::validatePath($path);
		self::ensureDirectoryExists($path);

		$finfo = \finfo_open(\FILEINFO_MIME_TYPE);

		$fullpath = $path . '/' . $name;
		$file = Files::callStorage('get', $fullpath) ?: new self;
		$file->set('path', $fullpath);
		$file->set('parent', $path);
		$file->set('name', $name);

		if ($source_path && !$source_content) {
			$file->set('mime', finfo_file($finfo, $source_path));
			$file->set('size', filesize($source_path));
			$file->set('modified', new \DateTime('@' . filemtime($source_path)));
		}
		else {
			$file->set('mime', finfo_buffer($finfo, $source_content));
			$file->set('size', strlen($source_content));
		}

		$file->set('image', (int) in_array($file->mime, self::IMAGE_TYPES));

		// Force empty files as text/plain
		if ($file->mime == 'application/x-empty' && !$file->size) {
			$file->set('mime', 'text/plain');
		}

		return $file;
	}

	/**
	 * Upload multiple files
	 * @param  string $path Target parent directory (eg. 'documents/Logos')
	 * @param  string $key  The name of the file input in the HTML form (this MUST have a '[]' at the end of the name)
	 * @return array list of File objects created
	 */
	static public function uploadMultiple(string $path, string $key): array
	{
		if (!isset($_FILES[$key]['name'][0])) {
			throw new UserException('Aucun fichier reçu');
		}

		// Transpose array
		// see https://www.php.net/manual/en/features.file-upload.multiple.php#53240
		$files = Utils::array_transpose($_FILES[$key]);
		$out = [];

		// First check all files
		foreach ($files as $file) {
			if (!empty($file['error'])) {
				throw new UserException(self::getErrorMessage($file['error']));
			}

			if (empty($file['size']) || empty($file['name'])) {
				throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.');
			}

			if (!is_uploaded_file($file['tmp_name'])) {
				throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.');
			}
		}

		// Then create files
		foreach ($files as $file) {
			$name = self::filterName($file['name']);

			$out[] = self::createAndStore($path, $name, $file['tmp_name'], null);
		}

		return $out;
	}

	/**
	 * Upload a file using POST from a HTML form
	 * @param  string $path Target parent directory (eg. 'documents/Logos')
	 * @param  string $key  The name of the file input in the HTML form
	 * @return self Created file object
	 */
	static public function upload(string $path, string $key, ?string $name = null): self
	{
		if (!isset($_FILES[$key]) || !is_array($_FILES[$key])) {
			throw new UserException('Aucun fichier reçu');
		}

		$file = $_FILES[$key];

		if (!empty($file['error'])) {
			throw new UserException(self::getErrorMessage($file['error']));
		}

		if (empty($file['size']) || empty($file['name'])) {
			throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.');
		}

		if (!is_uploaded_file($file['tmp_name'])) {
			throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.');
		}

		$name = self::filterName($name ?? $file['name']);

		return self::createAndStore($path, $name, $file['tmp_name'], null);
	}


	/**
	 * Récupération du message d'erreur
	 * @param  integer $error Code erreur du $_FILE
	 * @return string Message d'erreur
	 */
	static public function getErrorMessage($error)
	{
		switch ($error)
		{
			case UPLOAD_ERR_INI_SIZE:
				return 'Le fichier excède la taille permise par la configuration.';
			case UPLOAD_ERR_FORM_SIZE:
				return 'Le fichier excède la taille permise par le formulaire.';
			case UPLOAD_ERR_PARTIAL:
				return 'L\'envoi du fichier a été interrompu.';
			case UPLOAD_ERR_NO_FILE:
				return 'Aucun fichier n\'a été reçu.';
			case UPLOAD_ERR_NO_TMP_DIR:
				return 'Pas de répertoire temporaire pour stocker le fichier.';
			case UPLOAD_ERR_CANT_WRITE:
				return 'Impossible d\'écrire le fichier sur le disque du serveur.';
			case UPLOAD_ERR_EXTENSION:
				return 'Une extension du serveur a interrompu l\'envoi du fichier.';
			default:
				return 'Erreur inconnue: ' . $error;
		}
	}

	/**
	 * Returns true if this is a vector or bitmap image
	 * as 'image' property is only for bitmaps
	 * @return boolean
	 */
	public function isImage(): bool
	{
		if ($this->image || $this->mime == 'image/svg+xml') {
			return true;
		}

		return false;
	}

	/**
	 * Full URL with https://...
	 */
	public function url(bool $download = false): string
	{
		$base = in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_SKELETON, self::CONTEXT_CONFIG]) ? WWW_URL : BASE_URL;
		$url = $base . $this->uri();

		if ($download) {
			$url .= '?download';
		}

		return $url;
	}

	/**
	 * Returns local URI, eg. user/1245/file.jpg
	 */
	public function uri(): string
	{
		$parts = explode('/', $this->path);
		$parts = array_map('rawurlencode', $parts);

		if ($this->context() == self::CONTEXT_WEB) {
			$parts = array_slice($parts, -2);
		}

		return implode('/', $parts);
	}

	public function thumb_url($size = null): string
	{
		if (!$this->image) {
			return $this->url();
		}

		if (is_int($size)) {
			$size .= 'px';
		}

		$size = isset(self::ALLOWED_THUMB_SIZES[$size]) ? $size : key(self::ALLOWED_THUMB_SIZES);
		return sprintf('%s?%dpx', $this->url(), $size);
	}

	/**
	 * Envoie le fichier au client HTTP
	 */
	public function serve(?Session $session = null, bool $download = false, ?string $share_hash = null, ?string $share_password = null): void
	{
		$can_access = $this->checkReadAccess($session);

		if (!$can_access && $share_hash) {
			$can_access = $this->checkShareLink($share_hash, $share_password);

			if (!$can_access && $this->checkShareLinkRequiresPassword($share_hash)) {
				$tpl = Template::getInstance();
				$has_password = (bool) $share_password;

				$tpl->assign(compact('can_access', 'has_password'));
				$tpl->display('ask_share_password.tpl');
				exit;
			}
		}

		if (!$can_access) {
			header('HTTP/1.1 403 Forbidden', true, 403);
			throw new UserException('Vous n\'avez pas accès à ce fichier.');
			return;
		}

		// Only simple files can be served, not directories
		if ($this->type != self::TYPE_FILE) {
			header('HTTP/1.1 404 Not Found', true, 404);
			throw new UserException('Page non trouvée');
		}

		$path = Files::callStorage('getFullPath', $this);
		$content = null === $path ? Files::callStorage('fetch', $this) : null;

		$this->_serve($path, $content, $download);
	}

	/**
	 * Envoie une miniature à la taille indiquée au client HTTP
	 */
	public function serveThumbnail(?Session $session = null, string $size = null): void
	{
		if (!$this->checkReadAccess($session)) {
			header('HTTP/1.1 403 Forbidden', true, 403);
			throw new UserException('Accès interdit');
			return;
		}

		if (!$this->image) {
			throw new UserException('Il n\'est pas possible de fournir une miniature pour un fichier qui n\'est pas une image.');
		}

		if (!array_key_exists($size, self::ALLOWED_THUMB_SIZES)) {
			throw new UserException('Cette taille de miniature n\'est pas autorisée.');
		}

		$cache_id = sprintf(self::THUMB_CACHE_ID, $this->pathHash(), $size);
		$destination = Static_Cache::getPath($cache_id);

		if (!Static_Cache::exists($cache_id)) {
			try {
				if ($path = Files::callStorage('getFullPath', $this)) {
					$i = new Image($path);
				}
				elseif ($content = Files::callStorage('fetch', $this)) {
					$i = Image::createFromBlob($content);
				}
				else {
					throw new \RuntimeException('Unable to fetch file');
				}

				// Always autorotate first
				$i->autoRotate();

				$operations = self::ALLOWED_THUMB_SIZES[$size];
				$allowed_operations = ['resize', 'cropResize', 'flip', 'rotate', 'crop'];

				foreach ($operations as $operation) {
					$arguments = array_slice($operation, 1);
					$operation = $operation[0];

					if (!in_array($operation, $allowed_operations)) {
						throw new \InvalidArgumentException('Opération invalide: ' . $operation);
					}

					call_user_func_array([$i, $operation], $arguments);
				}

				$format = null;

				if ($i->format() !== 'gif') {
					$format = ['webp', null];
				}

				$i->save($destination, $format);
			}
			catch (\RuntimeException $e) {
				throw new UserException('Impossible de créer la miniature');
			}
		}

		$this->_serve($destination, null);
	}

	/**
	 * Servir un fichier local en HTTP
	 * @param  string $path Chemin vers le fichier local
	 * @param  string $type Type MIME du fichier
	 * @param  string $name Nom du fichier avec extension
	 * @param  integer $size Taille du fichier en octets (facultatif)
	 */
	protected function _serve(?string $path, ?string $content, bool $download = false): void
	{
		if ($this->isPublic()) {
			Utils::HTTPCache(md5($this->path . $this->size . $this->modified->getTimestamp()), $this->modified->getTimestamp());
		}
		else {
			// Disable browser cache
			header('Pragma: private');
			header('Expires: -1');
			header('Cache-Control: private, must-revalidate, post-check=0, pre-check=0');
		}

		$type = $this->mime;

		// Force CSS mimetype
		if (substr($this->name, -4) == '.css') {
			$type = 'text/css';
		}
		elseif (substr($this->name, -3) == '.js') {
			$type = 'text/javascript';
		}

		if (substr($type, 0, 5) == 'text/') {
			$type .= ';charset=utf-8';
		}

		header(sprintf('Content-Type: %s', $type));
		header(sprintf('Content-Disposition: %s; filename="%s"', $download ? 'attachment' : 'inline', $this->name));

		// Utilisation de XSendFile si disponible
		if (null !== $path && ENABLE_XSENDFILE && isset($_SERVER['SERVER_SOFTWARE']))
		{
			if (stristr($_SERVER['SERVER_SOFTWARE'], 'apache')
				&& function_exists('apache_get_modules')
				&& in_array('mod_xsendfile', apache_get_modules()))
			{
				header('X-Sendfile: ' . $path);
				return;
			}
			else if (stristr($_SERVER['SERVER_SOFTWARE'], 'lighttpd'))
			{
				header('X-Sendfile: ' . $path);
				return;
			}
		}

		// Désactiver gzip
		if (function_exists('apache_setenv'))
		{
			@apache_setenv('no-gzip', 1);
		}

		@ini_set('zlib.output_compression', 'Off');

		header(sprintf('Content-Length: %d', $path ? filesize($path) : strlen($content)));

		if (@ob_get_length()) {
			@ob_clean();
		}

		flush();

		if (null !== $path) {
			readfile($path);
		}
		else {
			echo $content;
		}
	}

	public function fetch()
	{
		if ($this->type == self::TYPE_DIRECTORY) {
			throw new \LogicException('Cannot fetch a directory');
		}

		return Files::callStorage('fetch', $this);
	}

	public function render(?string $user_prefix = null)
	{
		$editor_type = $this->renderFormat();

		if ($editor_type == 'text') {
			return sprintf('<pre>%s</pre>', htmlspecialchars($this->fetch()));
		}
		elseif (!$editor_type) {
			throw new \LogicException('Cannot render file of this type');
		}
		else {
			return Render::render($editor_type, $this, $this->fetch(), $user_prefix);
		}
	}

	public function checkReadAccess(?Session $session): bool
	{
		// Web pages and config files are always public
		if ($this->isPublic()) {
			return true;
		}

		$context = $this->context();
		$ref = strtok(substr($this->path, strpos($this->path, '/')), '/');

		if (null === $session || !$session->isLogged()) {
			return false;
		}

		if ($context == self::CONTEXT_TRANSACTION && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)) {
			return true;
		}
		// The user can access his own profile files
		else if ($context == self::CONTEXT_USER && $ref == $session->getUser()->id) {
			return true;
		}
		// Only users able to manage users can see their profile files
		else if ($context == self::CONTEXT_USER && $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)) {
			return true;
		}
		// Only users with right to access documents can read documents
		else if ($context == self::CONTEXT_DOCUMENTS && $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)) {
			return true;
		}

		return false;
	}

	public function checkWriteAccess(?Session $session): bool
	{
		if (null === $session) {
			return false;
		}

		switch ($this->context()) {
			case self::CONTEXT_WEB:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE);
			case self::CONTEXT_DOCUMENTS:
				// Only admins can delete files
				return $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_WRITE);
			case self::CONTEXT_CONFIG:
				return $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);
			case self::CONTEXT_TRANSACTION:
				return $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE);
			case self::CONTEXT_SKELETON:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN);
			case self::CONTEXT_USER:
				return $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
		}

		return false;
	}

	public function checkDeleteAccess(?Session $session): bool
	{
		if (null === $session) {
			return false;
		}

		switch ($this->context()) {
			case self::CONTEXT_WEB:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE);
			case self::CONTEXT_DOCUMENTS:
				// Only admins can delete files
				return $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_ADMIN);
			case self::CONTEXT_CONFIG:
				return $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);
			case self::CONTEXT_TRANSACTION:
				return $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);
			case self::CONTEXT_SKELETON:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN);
			case self::CONTEXT_USER:
				return $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
		}

		return false;
	}

	static public function checkCreateAccess(string $path, ?Session $session): bool
	{
		if (null === $session) {
			return false;
		}

		$context = strtok($path, '/');

		switch ($context) {
			case self::CONTEXT_WEB:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE);
			case self::CONTEXT_DOCUMENTS:
				return $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_WRITE);
			case self::CONTEXT_CONFIG:
				return $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);
			case self::CONTEXT_TRANSACTION:
				return $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE);
			case self::CONTEXT_SKELETON:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN);
			case self::CONTEXT_USER:
				return $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
		}

		return false;
	}

	public function pathHash(): string
	{
		return sha1($this->path);
	}

	public function isPublic(): bool
	{
		$context = $this->context();

		if ($context == self::CONTEXT_SKELETON || $context == self::CONTEXT_CONFIG || $context == self::CONTEXT_WEB) {
			return true;
		}

		return false;
	}

	public function path_uri(): string
	{
		return rawurlencode($this->path);
	}

	static public function filterName(string $name): string
	{
		return preg_replace('/[^\w\d\p{L}_. -]+/iu', '-', trim($name));
	}

	static public function validateFileName(string $name): void
	{
		if (substr($name[0], 0, 1) === '.') {
			throw new ValidationException('Le nom de fichier ne peut commencer par un point');
		}

		if (strpos($name, "\0") !== false) {
			throw new ValidationException('Nom de fichier invalide');
		}

		if (strlen($name) > 250) {
			throw new ValidationException('Nom de fichier trop long');
		}

		$extension = strtolower(substr($name, strrpos($name, '.')+1));

		if (preg_match(self::FORBIDDEN_EXTENSIONS, $extension)) {
			throw new ValidationException('Extension de fichier non autorisée, merci de renommer le fichier avant envoi.');
		}
	}

	static public function validatePath(string $path): array
	{
		$parts = explode('/', $path);

		if (count($parts) < 1) {
			throw new ValidationException('Chemin invalide: ' . $path);
		}

		$context = array_shift($parts);

		if (!array_key_exists($context, self::CONTEXTS_NAMES)) {
			throw new ValidationException('Chemin invalide: ' . $path);
		}

		foreach ($parts as $part) {
			if (substr($part, 0, 1) == '.') {
				throw new ValidationException('Chemin invalide: ' . $path);
			}
		}

		$name = array_pop($parts);
		$ref = implode('/', $parts);
		return [$context, $ref ?: null, $name];
	}

	public function renderFormat(): ?string
	{
		if (substr($this->name, -6) == '.skriv') {
			$format = Render::FORMAT_SKRIV;
		}
		elseif (substr($this->name, -3) == '.md') {
			$format = Render::FORMAT_MARKDOWN;
		}
		else if (substr($this->mime, 0, 5) == 'text/') {
			$format = 'text';
		}
		else if ($this->size == 0) {
			$format = 'text';
		}
		else {
			$format = null;
		}

		return $format;
	}

	public function editorType(): ?string
	{
		$format = $this->renderFormat();

		if ($format == 'text') {
			return 'code';
		}
		elseif ($format == Render::FORMAT_SKRIV || $format == Render::FORMAT_MARKDOWN) {
			return 'web';
		}

		return null;
	}

	/**
	 * Returns a sharing link for a file, valid
	 * @param  int $expiry Expiry, in hours
	 * @param  string|null $password
	 * @return string
	 */
	public function createShareLink(int $expiry = 24, ?string $password = null): string
	{
		$expiry = intval(time() / 3600) + $expiry;

		$hash = $this->_createShareHash($expiry, $password);

		$expiry -= intval(gmmktime(0, 0, 0, 8, 1, 2022) / 3600);
		$expiry = base_convert($expiry, 10, 36);

		return sprintf('%s?s=%s%s:%s', $this->url(), $password ? ':' : '', $hash, $expiry);
	}

	protected function _createShareHash(int $expiry, ?string $password): string
	{
		$password = trim((string)$password) ?: null;

		$str = sprintf('%s:%s:%s:%s', SECRET_KEY, $this->path, $expiry, $password);

		$hash = hash('sha256', $str, true);
		$hash = substr($hash, 0, 10);
		$hash = Security::base64_encode_url_safe($hash);
		return $hash;
	}

	public function checkShareLinkRequiresPassword(string $str): bool
	{
		return substr($str, 0, 1) == ':';
	}

	public function checkShareLink(string $str, ?string $password): bool
	{
		$str = ltrim($str, ':');

		$hash = strtok($str, ':');
		$expiry = strtok(false);

		if (!ctype_alnum($expiry)) {
			return false;
		}

		$expiry = (int)base_convert($expiry, 36, 10);
		$expiry += intval(gmmktime(0, 0, 0, 8, 1, 2022) / 3600);

		if ($expiry < time()/3600) {
			return false;
		}

		$hash_check = $this->_createShareHash($expiry, $password);

		return hash_equals($hash, $hash_check);
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
















































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Services/Fee.php version [7689846f7f].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
<?php

namespace Garradin\Entities\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Utils;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Project;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;
use KD2\DB\DB_Exception;

class Fee extends Entity
{
	const TABLE = 'services_fees';

	protected ?int $id;
	protected string $label;
	protected ?string $description = null;
	protected ?int $amount = null;
	protected ?string $formula = null;
	protected int $id_service;
	protected ?int $id_account = null;
	protected ?int $id_year = null;
	protected ?int $id_project = null;

	public function filterUserValue(string $type, $value, string $key)
	{
		if ($key == 'amount' && $value !== null) {
			$value = Utils::moneyToInteger($value);
		}

		return $value;
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['account']) && is_array($source['account'])) {
			$source['id_account'] = (int)key($source['account']);
		}

		if (isset($source['amount_type'])) {
			if ($source['amount_type'] == 2) {
				$source['amount'] = null;
			}
			elseif ($source['amount_type'] == 1) {
				$source['formula'] = null;
			}
			else {
				$source['amount'] = $source['formula'] = null;
			}
		}

		if (empty($source['accounting'])) {
			$source['id_account'] = $source['id_year'] = null;
		}
		elseif (!empty($source['accounting']) && empty($source['id_account'])) {
			$source['id_account'] = null;
		}

		return parent::importForm($source);
	}

	public function selfCheck(): void
	{
		$db = DB::getInstance();
		parent::selfCheck();

		$this->assert(trim($this->label) !== '', 'Le libellé doit être renseigné');
		$this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
		$this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
		$this->assert(null === $this->amount || $this->amount > 0, 'Le montant est invalide : ' . $this->amount);
		$this->assert($this->id_service, 'Aucun service n\'a été indiqué pour ce tarif.');
		$this->assert((null === $this->id_account && null === $this->id_year)
			|| (null !== $this->id_account && null !== $this->id_year), 'Le compte doit être indiqué avec l\'exercice');
		$this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ?', $this->id_account), 'Le compte indiqué n\'existe pas');
		$this->assert(null === $this->id_year || $db->test(Year::TABLE, 'id = ?', $this->id_year), 'L\'exercice indiqué n\'existe pas');
		$this->assert(null === $this->id_account || $db->test(Account::TABLE, 'id = ? AND id_chart = (SELECT id_chart FROM acc_years WHERE id = ?)', $this->id_account, $this->id_year), 'Le compte sélectionné ne correspond pas à l\'exercice');
		$this->assert(null === $this->id_project || $db->test(Project::TABLE, 'id = ?', $this->id_project), 'Le projet sélectionné n\'existe pas.');

		if (null !== $this->formula && ($error = $this->checkFormula())) {
			throw new ValidationException('Formule de calcul invalide: ' . $error);
		}

		$this->assert(null === $this->amount || null === $this->formula, 'Il n\'est pas possible de spécifier à la fois une formule et un montant');
	}

	public function getAmountForUser(int $user_id): ?int
	{
		if ($this->amount) {
			return $this->amount;
		}
		elseif ($this->formula) {
			$db = DB::getInstance();
			return (int) $db->firstColumn($this->getFormulaSQL(), $user_id);
		}

		return null;
	}

	protected function getFormulaSQL()
	{
		return sprintf('SELECT %s FROM membres WHERE id = ?;', $this->formula);
	}

	protected function checkFormula(): ?string
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['membres' => null], $sql);
			return null;
		}
		catch (DB_Exception $e) {
			return $e->getMessage();
		}
	}

	public function service()
	{
		return EntityManager::findOneById(Service::class, $this->id_service);
	}

	public function allUsersList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
				'select' => 'su.id_user',
			],
			'user_number' => [
				'label' => 'Numéro de membre',
				'select' => 'm.numero',
				'export_only' => true,
			],
			'identity' => [
				'label' => 'Membre',
				'select' => 'm.' . $identity,
			],
			'paid' => [
				'label' => 'Payé ?',
				'select' => 'su.paid',
				'order' => 'su.paid %s, su.date %1$s',
			],
			'paid_amount' => [
				'label' => 'Montant payé',
				'select' => 'SUM(l.credit)',
			],
			'date' => [
				'label' => 'Date',
				'select' => 'su.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN membres m ON m.id = su.id_user
			INNER JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) AS su2 ON su2.id = su.id
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines l ON l.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.id_fee = %d
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->orderBy('paid', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');

		$list->setExportCallback(function (&$row) {
			$row->paid_amount = $row->paid_amount ? Utils::money_format($row->paid_amount, '.', '', false) : null;
		});

		return $list;
	}

	public function activeUsersList(): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_fee = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND su.paid = 1
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function unpaidUsersList(): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.paid = 0 AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.expiry_date < date() AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}


	public function getUsers(bool $paid_only = false): array
	{
		$where = $paid_only ? 'AND paid = 1' : '';
		$id_field = Config::getInstance()->champ_identite;
		$sql = sprintf('SELECT su.id_user, u.%s FROM services_users su INNER JOIN membres u ON u.id = su.id_user WHERE su.id_fee = ? %s;', $id_field, $where);
		return DB::getInstance()->getAssoc($sql, $this->id());
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




















































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Services/Reminder.php version [52650ee9cf].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
<?php

namespace Garradin\Entities\Services;

use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Config;
use KD2\DB\EntityManager;

class Reminder extends Entity
{
	const TABLE = 'services_reminders';

	protected $id;
	protected $id_service;
	protected $delay;
	protected $subject;
	protected $body;

	protected $_types = [
		'id'         => 'int',
		'id_service' => 'int',
		'delay'      => 'int',
		'subject'    => 'string',
		'body'       => 'string',
	];

	public function selfCheck(): void
	{
		parent::selfCheck();
		$this->assert($this->id_service, 'Aucun service n\'a été indiqué pour ce tarif.');
		$this->assert(trim($this->subject) !== '', 'Le sujet doit être renseigné');
		$this->assert(strlen($this->subject) <= 200, 'Le sujet doit faire moins de 200 caractères');
		$this->assert(trim($this->body) !== '', 'Le corps du message doit être renseigné');
		$this->assert(strlen($this->body) <= 64000, 'Le corps du message doit faire moins de 64.000 caractères');
		$this->assert($this->delay !== null, 'Le délai de rappel doit être renseigné');
	}

	public function service()
	{
		return EntityManager::findOneById(Service::class, $this->id_service);
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}


		if (isset($source['delay_type'])) {
			if (1 == $source['delay_type'] && !empty($source['delay_before'])) {
				$source['delay'] = (int)$source['delay_before'] * -1;
			}
			elseif (2 == $source['delay_type'] && !empty($source['delay_after'])) {
				$source['delay'] = (int)$source['delay_after'];
			}
			else {
				$source['delay'] = 0;
			}
		}

		parent::importForm($source);
	}

	public function sentList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
				'select' => 'srs.id_user',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => 'm.' . $identity,
			],
			'email' => [
				'label' => 'Adresse e-mail',
				'select' => 'm.email',
			],
			'date' => [
				'label' => 'Date d\'envoi',
				'select' => 'srs.sent_date',
				'order' => 'srs.sent_date %s, srs.id %1$s',
			],
		];

		$tables = 'services_reminders_sent srs
			INNER JOIN membres m ON m.id = srs.id_user';
		$conditions = sprintf('srs.id_reminder = %d', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		return $list;
	}

}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




































































































































































































Deleted src/include/lib/Garradin/Entities/Services/Service.php version [1019ca2d5b].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
<?php

namespace Garradin\Entities\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Utils;
use Garradin\Services\Fees;

class Service extends Entity
{
	const TABLE = 'services';

	protected $id;
	protected $label;
	protected $description;
	protected $duration;
	protected $start_date;
	protected $end_date;

	protected $_types = [
		'id'          => 'int',
		'label'       => 'string',
		'description' => '?string',
		'duration'    => '?int',
		'start_date'  => '?date',
		'end_date'    => '?date',
	];

	public function selfCheck(): void
	{
		parent::selfCheck();
		$this->assert(trim((string) $this->label) !== '', 'Le libellé doit être renseigné');
		$this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
		$this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
		$this->assert(!isset($this->duration, $this->start_date, $this->end_date) || $this->duration || ($this->start_date && $this->end_date), 'Seulement une option doit être choisie : durée ou dates de début et de fin de validité');
		$this->assert(null === $this->start_date || $this->start_date instanceof \DateTimeInterface);
		$this->assert(null === $this->end_date || $this->end_date instanceof \DateTimeInterface);
		$this->assert(null === $this->duration || (is_int($this->duration) && $this->duration > 0), 'La durée n\'est pas valide');
		$this->assert(null === $this->start_date || $this->end_date >= $this->start_date, 'La date de fin de validité ne peut être avant la date de début');
	}

	public function importForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['period'])) {
			if (1 == $source['period']) {
				$source['start_date'] = $source['end_date'] = null;
			}
			elseif (2 == $source['period']) {
				$source['duration'] = null;
			}
			else {
				$source['duration'] = $source['start_date'] = $source['end_date'] = null;
			}
		}

		parent::importForm($source);
	}

	public function fees()
	{
		return new Fees($this->id());
	}

	public function allUsersList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
			],
			'end_date' => [
			],
			'user_number' => [
				'label' => 'Numéro de membre',
				'select' => 'm.numero',
				'export_only' => true,
			],
			'identity' => [
				'label' => 'Membre',
				'select' => 'm.' . $identity,
			],
			'status' => [
				'label' => 'Statut',
				'select' => 'CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END',
			],
			'paid' => [
				'label' => 'Payé ?',
				'select' => 'su.paid',
				'order' => 'su.paid %s, su.date %1$s',
			],
			'expiry' => [
				'label' => 'Date d\'expiration',
				'select' => 'MAX(su.expiry_date)',
			],
			'fee' => [
				'label' => 'Tarif',
				'select' => 'sf.label',
			],
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'su.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN membres m ON m.id = su.id_user
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) AS su2 ON su2.id = su.id';
		$conditions = sprintf('su.id_service = %d
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->orderBy('paid', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');
		return $list;
	}

	public function activeUsersList(): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_service = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND su.paid = 1
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function unpaidUsersList(): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_service = %d AND su.paid = 0 AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_service = %d AND su.expiry_date < date() AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function getUsers(bool $paid_only = false) {
		$where = $paid_only ? 'AND paid = 1' : '';
		$id_field = Config::getInstance()->champ_identite;
		$sql = sprintf('SELECT su.id_user, u.%s FROM services_users su INNER JOIN membres u ON u.id = su.id_user WHERE su.id_service = ? %s;', $id_field, $where);
		return DB::getInstance()->getAssoc($sql, $this->id());
	}

	public function long_label(): string
	{
		if ($this->duration) {
			$duration = sprintf('%d jours', $this->duration);
		}
		elseif ($this->start_date)
			$duration = sprintf('du %s au %s', $this->start_date->format('d/m/Y'), $this->end_date->format('d/m/Y'));
		else {
			$duration = 'ponctuelle';
		}

		return sprintf('%s — %s', $this->label, $duration);
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


























































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Services/Service_User.php version [b960ae24c8].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
<?php

namespace Garradin\Entities\Services;

use Garradin\DB;
use Garradin\Entity;
use Garradin\Membres;
use Garradin\ValidationException;
use Garradin\Services\Fees;
use Garradin\Services\Services;
use Garradin\Accounting\Transactions;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Line;

use KD2\DB\Date;

class Service_User extends Entity
{
	const TABLE = 'services_users';

	protected ?int $id;
	protected int $id_user;
	protected int $id_service;
	/**
	 * This can be NULL if there is no fee for the service
	 * @var null|int
	 */
	protected ?int $id_fee = null;
	protected bool $paid;
	protected ?int $expected_amount = null;
	protected Date $date;
	protected ?Date $expiry_date = null;

	protected $_service, $_fee;

	public function selfCheck(): void
	{
		$this->assert($this->id_service, 'Aucune activité spécifiée');
		$this->assert($this->id_user, 'Aucun membre spécifié');
		$this->assert(!$this->isDuplicate(), 'Cette activité a déjà été enregistrée pour ce membre et cette date');

		$db = DB::getInstance();
		// don't allow an id_fee that does not match a service
		if (null !== $this->id_fee && !$db->test(Fee::TABLE, 'id = ? AND id_service = ?', $this->id_fee, $this->id_service)) {
			$this->set('id_fee', null);
		}
	}

	public function isDuplicate(bool $using_date = true): bool
	{
		if (!isset($this->id_user, $this->id_service)) {
			throw new \LogicException('Entity does not define either user or service');
		}

		$params = [
			'id_user' => $this->id_user,
			'id_service' => $this->id_service,
		];

		if ($using_date) {
			$params['date'] = $this->date->format('Y-m-d');
		}

		$where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params));
		$where = implode(' AND ', $where);

		if ($this->exists()) {
			$where .= sprintf(' AND id != %d', $this->id());
		}

		return DB::getInstance()->test(self::TABLE, $where, array_values($params));
	}

	public function importForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$service = null;

		if (!empty($source['id_service']) && empty($source['expiry_date'])) {
			$service = $this->_service = Services::get((int) $source['id_service']);

			if (!$service) {
				throw new \LogicException('The requested service is not found');
			}

			if ($service->duration) {
				$dt = new Date;
				$dt->modify(sprintf('+%d days', $service->duration));
				$this->set('expiry_date', $dt);
			}
			elseif ($service->end_date) {
				$this->set('expiry_date', $service->end_date);
			}
			else {
				$this->set('expiry_date', null);
			}
		}

		if (!empty($source['id_service'])) {
			if (!$service) {
				$service = $this->_service = Services::get((int) $source['id_service']);
			}
		}

		return parent::importForm($source);
	}

	public function service(): Service
	{
		if (null === $this->_service) {
			$this->_service = Services::get($this->id_service);
		}

		return $this->_service;
	}

	/**
	 * Returns the Fee entity linked to this subscription
	 * This can be NULL if there was no fee existing at the time of subscription
	 * (that way you can use subscriptions without fees if you want)
	 */
	public function fee(): ?Fee
	{
		if (null === $this->id_fee) {
			return null;
		}

		if (null === $this->_fee) {
			$this->_fee = Fees::get($this->id_fee);
		}

		return $this->_fee;
	}

	public function addPayment(int $user_id, ?array $source = null): Transaction
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (!$this->id_fee) {
			throw new \RuntimeException('Cannot add a payment to a subscription that is not linked to a fee');
		}

		if (!$this->fee()->id_year) {
			throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié');
		}

		if (empty($source['amount'])) {
			throw new ValidationException('Montant non précisé');
		}

		if (empty($source['account_selector']) || !is_array($source['account_selector']) || !key($source['account_selector'])) {
			throw new ValidationException('Aucune compte n\'a été sélectionné.');
		}

		$label = $this->service()->label;

		if ($this->fee()->label != $label) {
			$label .= ' - ' . $this->fee()->label;
		}

		$label .= sprintf(' (%s)', (new Membres)->getNom($this->id_user));

		$transaction = Transactions::create(array_merge($source, [
			'type' => Transaction::TYPE_REVENUE,
			'label' => $label,
			'id_project' => $source['id_project'] ?? $this->fee()->id_project,
			'simple' => [Transaction::TYPE_REVENUE => [
				'credit' => [$this->fee()->id_account => null],
				'debit' => $source['account_selector'],
			]],
			'id_year' => $this->fee()->id_year,
		]));

		$transaction->id_creator = $user_id;
		$transaction->id_year = $this->fee()->id_year;
		$transaction->type = Transaction::TYPE_REVENUE;

		$transaction->save();
		$transaction->linkToUser($this->id_user, $this->id());

		return $transaction;
	}

	public function updateExpectedAmount(): void
	{
		$fee = $this->fee();

		if ($fee && $fee->id_account && $this->id_user) {
			$this->set('expected_amount', $fee->getAmountForUser($this->id_user));
		}
		else {
			$this->set('expected_amount', null);
		}
	}

	static public function createFromForm(array $users, int $creator_id, bool $from_copy = false, ?array $source = null): self
	{
		if (null === $source) {
			$source = $_POST;
		}

		$db = DB::getInstance();
		$db->begin();

		if (!count($users)) {
			throw new ValidationException('Aucun membre n\'a été sélectionné.');
		}

		foreach ($users as $id => $name) {
			$su = new self;
			$su->date = new Date;
			$su->importForm($source);
			$su->id_user = (int) $id;

			if (empty($su->id_service)) {
				throw new ValidationException('Aucune activité n\'a été sélectionnée.');
			}

			$su->updateExpectedAmount();

			if ($su->isDuplicate($from_copy ? false : true)) {
				if ($from_copy) {
					continue;
				}
				else {
					throw new ValidationException(sprintf('%s : Cette activité a déjà été enregistrée pour ce membre et cette date', $name));
				}
			}

			$su->save();

			if ($su->id_fee && $su->fee()->id_account
				&& !empty($source['amount'])
				&& !empty($source['create_payment'])) {
				try {
					$su->addPayment($creator_id, $source);
				}
				catch (ValidationException $e) {
					if ($e->getMessage() == 'Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé') {
						throw new ValidationException('Impossible d\'enregistrer l\'inscription : ce tarif d\'activité est lié à un exercice clôturé. Merci de modifier le tarif et choisir un autre exercice.', 0, $e);
					}
					else {
						throw $e;
					}
				}
			}
		}

		$db->commit();

		return $su;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Users/Category.php version [2e075cd1f0].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
<?php

namespace Garradin\Entities\Users;

use Garradin\Membres\Session;
use Garradin\Config;
use Garradin\DB;
use Garradin\UserException;
use Garradin\Entity;

class Category extends Entity
{
	const TABLE = 'users_categories';

	protected $id;
	protected $name;

	protected $hidden = 0;

	protected $perm_web = 0;
	protected $perm_documents = 0;
	protected $perm_users = 0;
	protected $perm_accounting = 0;

	protected $perm_subscribe = 0;
	protected $perm_connect = 0;
	protected $perm_config = 0;

	protected $_types = [
		'id'              => 'int',
		'name'            => 'string',
		'hidden'          => 'int',
		'perm_web'        => 'int',
		'perm_documents'  => 'int',
		'perm_users'      => 'int',
		'perm_accounting' => 'int',
		'perm_subscribe'  => 'int',
		'perm_connect'    => 'int',
		'perm_config'     => 'int',
	];

	const PERMISSIONS = [
		'connect' => [
			'label' => 'Les membres de cette catégorie peuvent-ils se connecter ?',
			'shape' => 'C',
			'options' => [
				Session::ACCESS_NONE => 'N\'a pas le droit de se connecter',
				Session::ACCESS_READ => 'A le droit de se connecter',
			],
		],
		'users' => [
			'label' => 'Gestion des membres',
			'shape' => 'M',
			'options' => [
				Session::ACCESS_NONE => 'Pas d\'accès',
				Session::ACCESS_READ => 'Lecture uniquement (peut voir les informations personnelles de tous les membres, y compris leurs inscriptions à des activités)',
				Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter et modifier des membres, mais pas les supprimer ni les changer de catégorie, peut inscrire des membres à des activités)',
				Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
			],
		],
		'accounting' => [
			'label' => 'Comptabilité',
			'shape' => '€',
			'options' => [
				Session::ACCESS_NONE => 'Pas d\'accès',
				Session::ACCESS_READ => 'Lecture uniquement (peut lire toutes les informations de tous les exercices)',
				Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter des écritures, mais pas les modifier ni les supprimer)',
				Session::ACCESS_ADMIN => 'Administration (peut modifier et supprimer des écritures, gérer les comptes, les exercices, etc.)',
			],
		],
		'documents' => [
			'label' => 'Documents',
			'shape' => 'D',
			'options' => [
				Session::ACCESS_NONE => 'Pas d\'accès',
				Session::ACCESS_READ => 'Lecture uniquement (peut lire tous les fichiers)',
				Session::ACCESS_WRITE => 'Lecture & écriture (peut lire, ajouter, modifier et déplacer des fichiers, mais pas les supprimer)',
				Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
			],
		],
		'web' => [
			'label' => 'Gestion du site web',
			'shape' => 'W',
			'options' => [
				Session::ACCESS_NONE => 'Pas d\'accès',
				Session::ACCESS_READ => 'Lecture uniquement (peut lire les pages)',
				Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter, modifier et supprimer des pages et catégories, mais pas modifier la configuration)',
				Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
			],
		],
		'config' => [
			'label' => 'Les membres de cette catégorie peuvent-ils modifier la configuration ?',
			'shape' => '☑',
			'options' => [
				Session::ACCESS_NONE => 'Ne peut pas modifier la configuration',
				Session::ACCESS_ADMIN => 'Peut modifier la configuration',
			],
		],
	];

	public function selfCheck(): void
	{
		parent::selfCheck();

		$this->assert(trim($this->name) !== '', 'Le nom de catégorie ne peut rester vide.');
		$this->assert($this->hidden === 0 || $this->hidden === 1, 'Wrong value for hidden');

		foreach (self::PERMISSIONS as $key => $perm) {
			$this->assert(array_key_exists($this->{'perm_' . $key}, $perm['options']), 'Invalid value for perm_' . $key);
		}
	}

	public function delete(): bool
	{
		$db = DB::getInstance();
		$config = Config::getInstance();

		if ($this->id() == $config->get('categorie_membres')) {
			throw new UserException('Il est interdit de supprimer la catégorie définie par défaut dans la configuration.');
		}

		if ($db->test('membres', 'id_category = ?', $this->id())) {
			throw new UserException('La catégorie contient encore des membres, il n\'est pas possible de la supprimer.');
		}

		return parent::delete();
	}

	public function setAllPermissions(int $access): void
	{
		foreach (self::PERMISSIONS as $key => $perm) {
			// Restrict to the maximum access level, as some permissions only allow up to READ
			$perm_access = min($access, max(array_keys($perm['options'])));
			$this->set('perm_' . $key, $perm_access);
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


















































































































































































































































































Deleted src/include/lib/Garradin/Entities/Users/Email.php version [a217d30bd0].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
<?php
declare(strict_types=1);

namespace Garradin\Entities\Users;

use Garradin\Entity;
use Garradin\UserException;
use Garradin\Users\Emails;

use KD2\SMTP;

use const Garradin\{WWW_URL, SECRET_KEY};

class Email extends Entity
{
	const TABLE = 'emails';

	/**
	 * Antispam services that require to do a manual action to accept emails
	 */
	const BLACKLIST_MANUAL_VALIDATION_MX = '/mailinblack\.com|spamenmoins\.com/';

	const COMMON_DOMAINS = ['laposte.net', 'gmail.com', 'hotmail.fr', 'hotmail.com', 'wanadoo.fr', 'free.fr', 'sfr.fr', 'yahoo.fr', 'orange.fr', 'live.fr', 'outlook.fr', 'yahoo.com', 'neuf.fr', 'outlook.com', 'icloud.com', 'riseup.net', 'vivaldi.net', 'aol.com', 'gmx.de', 'lilo.org', 'mailo.com', 'protonmail.com'];

	protected int $id;
	protected string $hash;
	protected bool $verified = false;
	protected bool $optout = false;
	protected bool $invalid = false;
	protected int $sent_count = 0;
	protected int $fail_count = 0;
	protected ?string $fail_log;
	protected \DateTime $added;
	protected ?\DateTime $last_sent;

	/**
	 * Normalize email address and create a hash from this
	 */
	static public function getHash(string $email): string
	{
		$email = strtolower(trim($email));

		$host = substr($email, strrpos($email, '@')+1);
		$host = idn_to_ascii($host);

		$email = substr($email, 0, strrpos($email, '@')+1) . $host;

		return sha1($email);
	}

	static public function getOptoutURL(string $hash = null): string
	{
		$hash = hex2bin($hash);
		$hash = base64_encode($hash);
		// Make base64 hash valid for URLs
		$hash = rtrim(strtr($hash, '+/', '-_'), '=');
		return sprintf('%s?un=%s', WWW_URL, $hash);
	}

	public function getVerificationCode(): string
	{
		$code = sha1($this->hash . SECRET_KEY);
		return substr($code, 0, 10);
	}

	public function sendVerification(string $email): void
	{
		if (self::getHash($email) !== $this->hash) {
			throw new UserException('Adresse email inconnue');
		}

		$message = "Bonjour,\n\nPour vérifier votre adresse e-mail pour notre association,\ncliquez sur le lien ci-dessous :\n\n";
		$message.= self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode();
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le.";

		Emails::queue(Emails::CONTEXT_SYSTEM, [$email => null], null, 'Confirmez votre adresse e-mail', $message);
	}

	public function verify(string $code): bool
	{
		if ($code !== $this->getVerificationCode()) {
			return false;
		}

		$this->set('verified', true);
		$this->set('optout', false);
		$this->set('invalid', false);
		$this->set('fail_count', 0);
		$this->set('fail_log', null);
		return true;
	}

	public function validate(string $email): bool
	{
		if (!$this->canSend()) {
			return false;
		}

		try {
			self::validateAddress($email);
		}
		catch (UserException $e) {
			$this->hasFailed(['type' => 'permanent', 'message' => $e->getMessage()]);
			return false;
		}

		return true;
	}

	static public function validateAddress(string $email): void
	{
		$pos = strrpos($email, '@');

		if ($pos === false) {
			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		$user = substr($email, 0, $pos);
		$host = substr($email, $pos+1);

		// Ce domaine n'existe pas (MX inexistant), erreur de saisie courante
		if ($host == 'gmail.fr') {
			throw new UserException('Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"');
		}

		if (preg_match('![/@]!', $user)) {
			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		if (!SMTP::checkEmailIsValid($email, false)) {
			if (!trim($host)) {
				throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
			}

			foreach (self::COMMON_DOMAINS as $common_domain) {
				similar_text($common_domain, $host, $percent);

				if ($percent > 90) {
					throw new UserException(sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain));
				}
			}

			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		// Windows does not support MX lookups
		if (PHP_OS_FAMILY == 'Windows') {
			return;
		}

		getmxrr($host, $mx_list);

		if (empty($mx_list)) {
			throw new UserException('Adresse e-mail invalide (le domaine indiqué n\'a pas de service e-mail) : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		foreach ($mx_list as $mx) {
  			if (preg_match(self::BLACKLIST_MANUAL_VALIDATION_MX, $mx)) {
				throw new UserException('Adresse e-mail invalide : impossible d\'envoyer des mails à un service (de type mailinblack ou spamenmoins) qui demande une validation manuelle de l\'expéditeur. Merci de choisir une autre adresse e-mail.');
			}
		}
	}

	public function canSend(): bool
	{
		if (!empty($this->optout)) {
			return false;
		}

		if (!empty($this->invalid)) {
			return false;
		}

		if ($this->hasReachedFailLimit()) {
			return false;
		}

		return true;
	}

	public function hasReachedFailLimit(): bool
	{
		return !empty($this->fail_count) && ($this->fail_count >= Emails::FAIL_LIMIT);
	}

	public function incrementSentCount(): void
	{
		$this->set('sent_count', $this->sent_count+1);
	}

	public function setOptout(): void
	{
		$this->set('optout', true);
		$this->appendFailLog('Demande de désinscription');
	}

	public function appendFailLog(string $message): void
	{
		$log = $this->fail_log ?? '';

		if ($log) {
			$log .= "\n";
		}

		$log .= date('d/m/Y H:i:s - ') . trim($message);
		$this->set('fail_log', $log);
	}

	public function hasFailed(array $return): void
	{
		if (!isset($return['type'])) {
			throw new \InvalidArgumentException('Bounce email type not supplied in argument.');
		}

		// Treat complaints as opt-out
		if ($return['type'] == 'complaint') {
			$this->set('optout', true);
			$this->appendFailLog("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']);
		}
		elseif ($return['type'] == 'permanent') {
			$this->set('invalid', true);
			$this->set('fail_count', $this->fail_count+1);
			$this->appendFailLog($return['message']);
		}
		elseif ($return['type'] == 'temporary') {
			$this->set('fail_count', $this->fail_count+1);
			$this->appendFailLog($return['message']);
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Entities/Web/Page.php version [21fb3dd712].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
<?php

namespace Garradin\Entities\Web;

use Garradin\DB;
use Garradin\Entity;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Entities\Files\File;
use Garradin\Files\Files;
use Garradin\Web\Render\Render;
use Garradin\Web\Web;

use KD2\DB\EntityManager as EM;

use const Garradin\WWW_URL;

class Page extends Entity
{
	const TABLE = 'web_pages';

	protected $id;
	protected $parent;
	protected $path;
	protected $uri;
	protected $_name = 'index.txt';
	protected $file_path;
	protected $title;
	protected $type;
	protected $status;
	protected $format;
	protected $published;
	protected $modified;
	protected $content;

	protected $_types = [
		'id'        => 'int',
		'parent'    => 'string',
		'path'      => 'string',
		'uri'       => 'string',
		'file_path' => 'string',
		'title'     => 'string',
		'type'      => 'int',
		'status'    => 'string',
		'format'    => 'string',
		'published' => 'DateTime',
		'modified'  => 'DateTime',
		'content'   => 'string',
	];

	const FORMATS_LIST = [
		Render::FORMAT_MARKDOWN => 'MarkDown',
		Render::FORMAT_ENCRYPTED => 'Chiffré',
		Render::FORMAT_SKRIV => 'SkrivML',
	];

	const STATUS_ONLINE = 'online';
	const STATUS_DRAFT = 'draft';

	const STATUS_LIST = [
		self::STATUS_ONLINE => 'En ligne',
		self::STATUS_DRAFT => 'Brouillon',
	];

	const TYPE_CATEGORY = 1;
	const TYPE_PAGE = 2;