Overview
Comment:Merge dev branch
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | payment
Files: files | file ages | folders
SHA3-256: 0bc6030888babc729cbd1c70162afc0c43409e0222f4b620875023e8be20c172
User & Date: alinaar on 2023-06-05 14:19:19
Other Links: branch diff | manifest | tags
Context
2023-06-05
14:19
Merge dev branch Leaf check-in: 0bc6030888 user: alinaar tags: payment
14:17
Fix payment without registered author check-in: e2bd902d18 user: alinaar tags: payment
10:28
Make sure to cast the column when copying the table data, so that if by error a string is present for an integer, this will not break the app Leaf check-in: 14d6d002a7 user: bohwaz tags: dev
Changes

Modified doc/admin/brindille_functions.md from [910660e9b2] to [002be74ad6].

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
## 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 URI 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 type="application/svg+xml"}}
{{:http type="pdf" download="liste_membres_ca.pdf"}}
```

## include

Permet d'inclure un autre squelette.







|











>







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
## 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.
328
329
330
331
332
333
334
335


336
337
338
339
340

341
342
343
344
345
346
347
348
</form>
```

## redirect

Redirige vers une nouvelle page.

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 redirection se passe dans la page parente.



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 |
| :- | :- | :- |

| `to` | optionnel | Adresse de redirection |

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








|
>
>





>
|







329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
</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

Modified doc/admin/brindille_sections.md from [a1ed96afd1] to [e15d05f9ab].

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

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. |


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 :



| `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







>















>
>







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

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
271
272
273
274
275
276
277
278









279
280
281
282
283
284
285
## 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)<. |










## years

Liste les exercices comptables

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







|
>
>
>
>
>
>
>
>
>







274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
## 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 |
| :- | :- |

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

Modified src/Makefile from [64ffb982f0] to [5d53242d18].

44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
	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
	cd /tmp/paheko-build/paheko/src/data; mkdir plugins && cd plugins; \
		wget https://fossil.kd2.org/paheko-plugins/uv/welcome.tar.gz \
		&& wget https://fossil.kd2.org/paheko-plugins/uv/caisse.tar.gz \
		&& wget https://fossil.kd2.org/paheko-plugins/uv/taima.tar.gz \
		&& wget https://fossil.kd2.org/paheko-plugins/uv/dompdf.tar.gz \
		&& wget https://fossil.kd2.org/paheko-plugins/uv/reservations.tar.gz \
		&& wget https://fossil.kd2.org/paheko-plugins/uv/webstats.tar.gz \
		&& wget https://fossil.kd2.org/paheko-plugins/uv/stock_velos.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







|
|
|
|
|
|
|
|







44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
	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
	#cd /tmp/paheko-build/paheko/src/data; mkdir plugins && cd plugins; \
	#	wget https://fossil.kd2.org/paheko-plugins/uv/welcome.tar.gz \
	#	&& wget https://fossil.kd2.org/paheko-plugins/uv/caisse.tar.gz \
	#	&& wget https://fossil.kd2.org/paheko-plugins/uv/taima.tar.gz \
	#	&& wget https://fossil.kd2.org/paheko-plugins/uv/dompdf.tar.gz \
	#	&& wget https://fossil.kd2.org/paheko-plugins/uv/reservations.tar.gz \
	#	&& wget https://fossil.kd2.org/paheko-plugins/uv/webstats.tar.gz \
	#	&& wget https://fossil.kd2.org/paheko-plugins/uv/stock_velos.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

Modified src/include/lib/Garradin/Accounting/Reports.php from [0f7acbbe10] to [be49fc6e79].

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

		$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;







|










|







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

		$account->all_debit = $debit;
		$account->all_credit = $credit;

		yield $account;
	}

	static public function getJournal(array $criterias, bool $reverse_order = false): \Generator
	{
		$where = self::getWhereClause($criterias, 't', 'l', 'a');

		$sql = sprintf('SELECT
			t.id_year, l.id_account, l.debit, l.credit, t.id, t.date, t.reference,
			l.reference AS line_reference, t.label, l.label AS line_label,
			a.label AS account_label, a.code AS account_code
			FROM acc_transactions t
			INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
			INNER JOIN acc_accounts a ON l.id_account = a.id
			WHERE %s ORDER BY t.date %s, t.id %2$s;', $where, $reverse_order ? 'DESC' : 'ASC');

		$transaction = null;
		$db = DB::getInstance();

		foreach ($db->iterate($sql) as $row) {
			if (null !== $transaction && $transaction->id != $row->id) {
				yield $transaction;

Modified src/include/lib/Garradin/AdvancedSearch.php from [5d8ccf1b9e] to [b6223d2681].

75
76
77
78
79
80
81

82
83
84
85
86
87
88
		}

		DB::getInstance()->toggleUnicodeLike(true);

		$list = new DynamicList($select_columns, $tables, $conditions->where);

		$list->orderBy($order, $query->desc ?? $default_desc);

		return $list;
	}

	/**
	 * Redirects to a URL if only one result is found for a simple search
	 */
	public function redirect(DynamicList $list): void







>







75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
		}

		DB::getInstance()->toggleUnicodeLike(true);

		$list = new DynamicList($select_columns, $tables, $conditions->where);

		$list->orderBy($order, $query->desc ?? $default_desc);
		$list->setTitle('Recherche');
		return $list;
	}

	/**
	 * Redirects to a URL if only one result is found for a simple search
	 */
	public function redirect(DynamicList $list): void

Modified src/include/lib/Garradin/Config.php from [38db93d71b] to [9254da223c].

58
59
60
61
62
63
64


65
66
67
68
69
70
71

	protected array $files = [];

	protected bool $site_disabled;

	protected int $log_retention;
	protected bool $analytical_set_all;



	static protected $_instance = null;

	static public function getInstance()
	{
		return self::$_instance ?: self::$_instance = new self;
	}







>
>







58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

	protected array $files = [];

	protected bool $site_disabled;

	protected int $log_retention;
	protected bool $analytical_set_all;

	protected ?int $auto_logout = 0;

	static protected $_instance = null;

	static public function getInstance()
	{
		return self::$_instance ?: self::$_instance = new self;
	}

Modified src/include/lib/Garradin/Entities/Accounting/Project.php from [58624a9c0e] to [66b8f38ddf].

1
2
3
4
5
6
7
8
9
10
11
12

13
14
15
16
17
18
19
<?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;












>







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

namespace Garradin\Entities\Accounting;

use Garradin\DB;
use Garradin\Entity;

/**
 * Analytical projects
 */
class Project extends Entity
{
	const NAME = 'Projet analytique';
	const TABLE = 'acc_projects';

	protected ?int $id;
	protected ?string $code;
	protected string $label;
	protected ?string $description;
	protected bool $archived = false;

Modified src/include/lib/Garradin/Entities/Email/Mailing.php from [8edd3b7d1b] to [99064c2e70].

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
<?php

namespace Garradin\Entities\Email;

use Garradin\Config;
use Garradin\CSV;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;

use Garradin\UserException;
use Garradin\Email\Emails;
use Garradin\Users\DynamicFields;
use Garradin\Users\Users;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Web\Render\Render;

use DateTime;
use stdClass;

class Mailing extends Entity
{
	const TABLE = 'mailings';



	protected ?int $id = null;
	protected string $subject;
	protected ?string $body;

	/**
	 * Leave sender name and email NULL to use org name + email









>













>
>







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
<?php

namespace Garradin\Entities\Email;

use Garradin\Config;
use Garradin\CSV;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\Log;
use Garradin\UserException;
use Garradin\Email\Emails;
use Garradin\Users\DynamicFields;
use Garradin\Users\Users;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Web\Render\Render;

use DateTime;
use stdClass;

class Mailing extends Entity
{
	const TABLE = 'mailings';
	const NAME = 'Message collectif';
	const PRIVATE_URL = '!users/mailing/details.php?id=%d';

	protected ?int $id = null;
	protected string $subject;
	protected ?string $body;

	/**
	 * Leave sender name and email NULL to use org name + email
300
301
302
303
304
305
306


307
308
309
310
311
312
313
314
315
316
317
318
319
			$this->getBody(),
			Render::FORMAT_MARKDOWN
		);

		$this->set('sent', new DateTime);

		$this->save();


	}

	public function export(string $format): void
	{
		$rows = [];

		foreach ($this->listRecipients() as $row) {
			$rows[] = [$row->email ?? '(Anonymisée)', $row->name];
		}

		CSV::export($format, 'Destinataires message collectif', $rows, ['Adresse e-mail', 'Identité']);
	}
}







>
>













303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
			$this->getBody(),
			Render::FORMAT_MARKDOWN
		);

		$this->set('sent', new DateTime);

		$this->save();

		Log::add(Log::SENT, ['entity' => get_class($this), 'id' => $this->id()]);
	}

	public function export(string $format): void
	{
		$rows = [];

		foreach ($this->listRecipients() as $row) {
			$rows[] = [$row->email ?? '(Anonymisée)', $row->name];
		}

		CSV::export($format, 'Destinataires message collectif', $rows, ['Adresse e-mail', 'Identité']);
	}
}

Modified src/include/lib/Garradin/Entities/Files/File.php from [eb5ed83d98] to [d76fee0a10].

74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
	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.%d';

	const THUMB_SIZE_TINY = '200px';
	const THUMB_SIZE_SMALL = '500px';

	const CONTEXT_DOCUMENTS = 'documents';
	const CONTEXT_USER = 'user';
	const CONTEXT_TRANSACTION = 'transaction';







|







74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
	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';
319
320
321
322
323
324
325



326
327
328
329
330
331
332
		$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







>
>
>







319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
		$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 . '%');

		$this->set('parent', Utils::dirname($new_path));
		$this->set('path', $new_path);

		return $return;
	}

	/**
	 * Copy the current file to a new location
	 * @param  string $target Target path
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
				}
				elseif ($content = Files::callStorage('fetch', $this)) {
					$i = Image::createFromBlob($content);
				}
				else {
					throw new \RuntimeException('Unable to fetch file');
				}




				$operations = self::ALLOWED_THUMB_SIZES[$size];
				$allowed_operations = ['resize', 'cropResize', 'flip', 'rotate', 'autoRotate', '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);
				}







				$i->save($destination);
			}
			catch (\RuntimeException $e) {
				throw new UserException('Impossible de créer la miniature');
			}
		}

		$this->_serve($destination, null);








>
>
>

|












>
>
>
>
>
>
|







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
				}
				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);

Modified src/include/lib/Garradin/Entities/Module.php from [82fcb8989b] to [aca5a90182].

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
<?php

namespace Garradin\Entities;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Plugins;
use Garradin\UserException;
use Garradin\Files\Files;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Users\Session;
use Garradin\Web\Cache;


use Garradin\Entities\Files\File;

use const Garradin\{ROOT, WWW_URL};

class Module extends Entity
{
	const ROOT = File::CONTEXT_MODULES;
	const DIST_ROOT = ROOT . '/modules';
	const META_FILE = 'module.ini';
	const ICON_FILE = 'icon.svg';
	const README_FILE = 'README.md';
	const CONFIG_FILE = 'config.html';
	const INDEX_FILE = 'index.html';


	// Snippets, don't forget to create alias constant in UserTemplate\Modules class
	const SNIPPET_TRANSACTION = 'snippets/transaction_details.html';
	const SNIPPET_USER = 'snippets/user_details.html';
	const SNIPPET_HOME_BUTTON = 'snippets/home_button.html';



	const SNIPPETS = [
		self::SNIPPET_HOME_BUTTON => 'Icône sur la page d\'accueil',
		self::SNIPPET_USER => 'En bas de la fiche d\'un membre',
		self::SNIPPET_TRANSACTION => 'En bas de la fiche d\'une écriture',


	];

	const VALID_NAME_REGEXP = '/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/';

	const TABLE = 'modules';

	protected ?int $id;












>











<


>





>
>





>
>







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
<?php

namespace Garradin\Entities;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Plugins;
use Garradin\UserException;
use Garradin\Files\Files;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Users\Session;
use Garradin\Web\Cache;
use Garradin\Web\Router;

use Garradin\Entities\Files\File;

use const Garradin\{ROOT, WWW_URL};

class Module extends Entity
{
	const ROOT = File::CONTEXT_MODULES;
	const DIST_ROOT = ROOT . '/modules';
	const META_FILE = 'module.ini';
	const ICON_FILE = 'icon.svg';

	const CONFIG_FILE = 'config.html';
	const INDEX_FILE = 'index.html';
	const README_FILE = 'README.md';

	// Snippets, don't forget to create alias constant in UserTemplate\Modules class
	const SNIPPET_TRANSACTION = 'snippets/transaction_details.html';
	const SNIPPET_USER = 'snippets/user_details.html';
	const SNIPPET_HOME_BUTTON = 'snippets/home_button.html';
	const SNIPPET_MY_SERVICES = 'snippets/my_services.html';
	const SNIPPET_MY_DETAILS = 'snippets/my_details.html';

	const SNIPPETS = [
		self::SNIPPET_HOME_BUTTON => 'Icône sur la page d\'accueil',
		self::SNIPPET_USER => 'En bas de la fiche d\'un membre',
		self::SNIPPET_TRANSACTION => 'En bas de la fiche d\'une écriture',
		self::SNIPPET_MY_SERVICES => 'Page "Mes activités"',
		self::SNIPPET_MY_DETAILS => 'Page "Mes infos personnelles"',
	];

	const VALID_NAME_REGEXP = '/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/';

	const TABLE = 'modules';

	protected ?int $id;
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
					'dist'      => true,
					'file_path' => $base . $path . '/' . $file,
				];
			}
		}

		foreach ($out as &$file) {
			$file['editable'] = UserTemplate::isTemplate($file['path'])
				|| substr($file['type'], 0, 5) === 'text/'
				|| preg_match('/\.(?:json|md|skriv|html|css|js|ini)$/', $file['name']);
			$file['open_url'] = '!common/files/preview.php?p=' . rawurlencode($file['file_path']);
			$file['edit_url'] = '!common/files/edit.php?p=' . rawurlencode($file['file_path']);
			$file['delete_url'] = '!common/files/delete.php?p=' . rawurlencode($file['file_path']);
		}

		unset($file);








|

|







325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
					'dist'      => true,
					'file_path' => $base . $path . '/' . $file,
				];
			}
		}

		foreach ($out as &$file) {
			$file['editable'] = !$file['dir'] && (UserTemplate::isTemplate($file['path'])
				|| substr($file['type'], 0, 5) === 'text/'
				|| preg_match('/\.(?:json|md|skriv|html|css|js|ini)$/', $file['name']));
			$file['open_url'] = '!common/files/preview.php?p=' . rawurlencode($file['file_path']);
			$file['edit_url'] = '!common/files/edit.php?p=' . rawurlencode($file['file_path']);
			$file['delete_url'] = '!common/files/delete.php?p=' . rawurlencode($file['file_path']);
		}

		unset($file);

428
429
430
431
432
433
434



















435
436
437
438
439
440
441
				$this->serveWeb($path, $params);
				return;
			}
			else {
				$ut = $this->template($path);
				$ut->serve($params);
			}



















		}
		// Serve a static file from a user module
		elseif ($has_local_file) {
			$file = Files::get(File::CONTEXT_MODULES . '/' . $this->name . '/' . $path);

			if (!$file) {
				throw new UserException('Invalid path');







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







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
				$this->serveWeb($path, $params);
				return;
			}
			else {
				$ut = $this->template($path);
				$ut->serve($params);
			}

			return;
		}
		// Render a markdown file
		elseif (substr($path, -3) === '.md') {
			if ($has_local_file) {
				$file = Files::get(File::CONTEXT_MODULES . '/' . $this->name . '/' . $path);

				if (!$file) {
					throw new UserException('Invalid path');
				}

				$text = $file->fetch();
			}
			else {
				$text = @file_get_contents($this->distPath($path));
			}

			Router::markdown($text);
		}
		// Serve a static file from a user module
		elseif ($has_local_file) {
			$file = Files::get(File::CONTEXT_MODULES . '/' . $this->name . '/' . $path);

			if (!$file) {
				throw new UserException('Invalid path');

Modified src/include/lib/Garradin/Entities/Plugin.php from [03a3f05ec0] to [b92e8bee25].

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

namespace Garradin\Entities;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Plugins;
use Garradin\Template;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Files\Files;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Users\Session;
use \KD2\HTML\Markdown;

use Garradin\Entities\Files\File;

use const Garradin\{PLUGINS_ROOT, WWW_URL, ROOT, ADMIN_URL};

class Plugin extends Entity
{













|







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

namespace Garradin\Entities;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Plugins;
use Garradin\Template;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Files\Files;
use Garradin\UserTemplate\UserTemplate;
use Garradin\Users\Session;
use Garradin\Web\Router;

use Garradin\Entities\Files\File;

use const Garradin\{PLUGINS_ROOT, WWW_URL, ROOT, ADMIN_URL};

class Plugin extends Entity
{
111
112
113
114
115
116
117




118
119
120
121
122
123
124
	}

	/**
	 * Fills information from plugin.ini file
	 */
	public function updateFromINI(): bool
	{




		$ini = parse_ini_file($this->path(self::META_FILE), false, \INI_SCANNER_TYPED);

		if (empty($ini)) {
			return false;
		}

		$ini = (object) $ini;







>
>
>
>







111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
	}

	/**
	 * Fills information from plugin.ini file
	 */
	public function updateFromINI(): bool
	{
		if (!$this->hasFile(self::META_FILE)) {
			return false;
		}

		$ini = parse_ini_file($this->path(self::META_FILE), false, \INI_SCANNER_TYPED);

		if (empty($ini)) {
			return false;
		}

		$ini = (object) $ini;
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
				$tpl->assign('plugin_root', \Garradin\PLUGIN_ROOT);
			}

			$plugin = $this;

			include $path;
		}
		elseif (substr($file, -3) === '.md' && $is_private) {
			$md = new Markdown;
			header('Content-Type: text/html');

			printf('<!DOCYPE html><head>
				<style type="text/css">body { font-family: Verdana, sans-serif; padding: .5em; margin: 0; background: #fff; color: #000; }</style>
				<link rel="stylesheet" type="text/css" href="%scss.php" /></head><body>', ADMIN_URL);
			echo $md->text(file_get_contents($path));
		}
		else {
			// Récupération du type MIME à partir de l'extension
			$pos = strrpos($path, '.');
			$ext = substr($path, $pos+1);

			$mime = self::MIME_TYPES[$ext] ?? 'text/plain';







|
<
<
<
<
<
<
|







369
370
371
372
373
374
375
376






377
378
379
380
381
382
383
384
				$tpl->assign('plugin_root', \Garradin\PLUGIN_ROOT);
			}

			$plugin = $this;

			include $path;
		}
		elseif (substr($file, -3) === '.md') {






			Router::markdown(file_get_contents($path));
		}
		else {
			// Récupération du type MIME à partir de l'extension
			$pos = strrpos($path, '.');
			$ext = substr($path, $pos+1);

			$mime = self::MIME_TYPES[$ext] ?? 'text/plain';

Modified src/include/lib/Garradin/Entities/Search.php from [3bf6c6ad87] to [963f668a8c].

56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

		$this->assert(strlen('label') > 0, 'Le champ libellé doit être renseigné');
		$this->assert(strlen('label') <= 500, 'Le champ libellé est trop long');

		$db = DB::getInstance();

		if ($this->id_user !== null) {
			$this->assert($db->test('users', 'id = ?', $data['id_user']), 'Numéro de membre inconnu');
		}

		$this->assert(array_key_exists($this->type, self::TYPES));
		$this->assert(array_key_exists($this->target, self::TARGETS));

		$this->assert(strlen($this->content), 'Le contenu de la recherche ne peut être vide');








|







56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

		$this->assert(strlen('label') > 0, 'Le champ libellé doit être renseigné');
		$this->assert(strlen('label') <= 500, 'Le champ libellé est trop long');

		$db = DB::getInstance();

		if ($this->id_user !== null) {
			$this->assert($db->test('users', 'id = ?', $this->id_user), 'Numéro de membre inconnu');
		}

		$this->assert(array_key_exists($this->type, self::TYPES));
		$this->assert(array_key_exists($this->target, self::TARGETS));

		$this->assert(strlen($this->content), 'Le contenu de la recherche ne peut être vide');

123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
			$sql = $this->content;
		}

		$has_limit = preg_match('/LIMIT\s+\d+/i', $sql);

		// force LIMIT
		if (!empty($options['limit'])) {
			$sql = preg_replace($has_limit ? '/LIMIT\s+.*;?\s*$/' : '/;?\s*$/', '', $sql);
			$sql .= ' LIMIT ' . (int) $options['limit'];
		}
		elseif (!empty($options['no_limit']) && $has_limit) {
			$sql = preg_replace('/LIMIT\s+.*;?\s*$/', '', $sql);
		}

		if (!empty($options['select_also'])) {







|







123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
			$sql = $this->content;
		}

		$has_limit = preg_match('/LIMIT\s+\d+/i', $sql);

		// force LIMIT
		if (!empty($options['limit'])) {
			$sql = preg_replace($has_limit ? '/LIMIT\s+.*$/is' : '/;.*$/s', '', trim($sql));
			$sql .= ' LIMIT ' . (int) $options['limit'];
		}
		elseif (!empty($options['no_limit']) && $has_limit) {
			$sql = preg_replace('/LIMIT\s+.*;?\s*$/', '', $sql);
		}

		if (!empty($options['select_also'])) {
169
170
171
172
173
174
175

176
177
178
179
180
181
182
			if (empty($options['no_cache'])) {
				$this->_result = $result;
			}

			return $result;
		}
		catch (DB_Exception $e) {

			throw new UserException('Erreur dans la requête : ' . $e->getMessage(), 0, $e);
		}
		finally {
			$db->toggleUnicodeLike(false);
		}
	}








>







169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
			if (empty($options['no_cache'])) {
				$this->_result = $result;
			}

			return $result;
		}
		catch (DB_Exception $e) {
			throw $e;
			throw new UserException('Erreur dans la requête : ' . $e->getMessage(), 0, $e);
		}
		finally {
			$db->toggleUnicodeLike(false);
		}
	}

205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
	{
		$sql = $this->SQL();

		if (!preg_match('/(?:FROM|JOIN)\s+users/i', $sql)) {
			return false;
		}

		$header = $this->getHeader(['limit' => 1, 'ignore_cache' => true]);

		if (!in_array('id', $header) && !in_array('_user_id', $header)) {
			return false;
		}

		return true;
	}







|







206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
	{
		$sql = $this->SQL();

		if (!preg_match('/(?:FROM|JOIN)\s+users/i', $sql)) {
			return false;
		}

		$header = $this->getHeader(['limit' => 1, 'no_cache' => true]);

		if (!in_array('id', $header) && !in_array('_user_id', $header)) {
			return false;
		}

		return true;
	}

Modified src/include/lib/Garradin/Entities/Services/Fee.php from [50d2be14e4] to [13cbc5b2de].

108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
		}

		return null;
	}

	protected function getFormulaSQL()
	{
		return sprintf('SELECT %s FROM users WHERE id = ?;', $this->formula);
	}

	protected function checkFormula(): ?string
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['users' => null], $sql);
			return null;
		}
		catch (DB_Exception $e) {
			return $e->getMessage();
		}
	}








|







|







108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
		}

		return null;
	}

	protected function getFormulaSQL()
	{
		return sprintf('SELECT (%s) FROM users WHERE id = ?;', $this->formula);
	}

	protected function checkFormula(): ?string
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['users' => null, 'services_users' => null, 'services' => null, 'services_fees' => null], $sql);
			return null;
		}
		catch (DB_Exception $e) {
			return $e->getMessage();
		}
	}

Modified src/include/lib/Garradin/Entities/Services/Reminder.php from [5eb7d7e44e] to [897f767e6b].

1
2
3
4
5

6
7
8
9
10
11
12
13


14
15
16
17
18
19
20
<?php

namespace Garradin\Entities\Services;

use Garradin\DynamicList;

use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Users\DynamicFields;

use KD2\DB\EntityManager;

class Reminder extends Entity
{


	const TABLE = 'services_reminders';

	protected $id;
	protected $id_service;
	protected $delay;
	protected $subject;
	protected $body;





>








>
>







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

namespace Garradin\Entities\Services;

use Garradin\DynamicList;
use Garradin\DB;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Users\DynamicFields;

use KD2\DB\EntityManager;

class Reminder extends Entity
{
	const NAME = 'Rappel';

	const TABLE = 'services_reminders';

	protected $id;
	protected $id_service;
	protected $delay;
	protected $subject;
	protected $body;

Modified src/include/lib/Garradin/Entities/Users/DynamicField.php from [9c8197c3d0] to [1fff10af23].

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
		'month'    => '?string',
		'year'     => '?int',
		'file'     => '?string',
		'password' => '?string',
		'number'   => '?int|float',
		'tel'      => '?string',
		'select'   => '?string',
		'multiple' => 'int',
		'country'  => '?string',
		'text'     => '?string',
		'textarea' => '?string',
		'generated'=> 'dynamic',
	];

	const SQL_TYPES = [
		'email'    => 'TEXT',
		'url'      => 'TEXT',
		'checkbox' => 'INTEGER NOT NULL DEFAULT 0',
		'date'     => 'TEXT',
		'datetime' => 'TEXT',
		'month'    => 'TEXT',
		'year'     => 'INTEGER',
		'file'     => 'TEXT',
		'password' => 'TEXT',
		'number'   => 'INTEGER',
		'tel'      => 'TEXT',
		'select'   => 'TEXT',
		'multiple' => 'INTEGER NOT NULL DEFAULT 0',
		'country'  => 'TEXT',
		'text'     => 'TEXT',
		'textarea' => 'TEXT',
		'generated'=> 'GENERATED',
	];

	const SEARCH_TYPES = [







|



















|







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
		'month'    => '?string',
		'year'     => '?int',
		'file'     => '?string',
		'password' => '?string',
		'number'   => '?int|float',
		'tel'      => '?string',
		'select'   => '?string',
		'multiple' => '?int',
		'country'  => '?string',
		'text'     => '?string',
		'textarea' => '?string',
		'generated'=> 'dynamic',
	];

	const SQL_TYPES = [
		'email'    => 'TEXT',
		'url'      => 'TEXT',
		'checkbox' => 'INTEGER NOT NULL DEFAULT 0',
		'date'     => 'TEXT',
		'datetime' => 'TEXT',
		'month'    => 'TEXT',
		'year'     => 'INTEGER',
		'file'     => 'TEXT',
		'password' => 'TEXT',
		'number'   => 'INTEGER',
		'tel'      => 'TEXT',
		'select'   => 'TEXT',
		'multiple' => 'INTEGER',
		'country'  => 'TEXT',
		'text'     => 'TEXT',
		'textarea' => 'TEXT',
		'generated'=> 'GENERATED',
	];

	const SEARCH_TYPES = [
192
193
194
195
196
197
198









199
200
201
202
203
204
205
		'date_updated TEXT NULL CHECK (date_updated IS NULL OR datetime(date_updated) = date_updated),',
		'otp_secret TEXT NULL,',
		'pgp_key TEXT NULL,',
		'id_parent INTEGER NULL REFERENCES users(id) ON DELETE SET NULL CHECK (id_parent IS NULL OR is_parent = 0),',
		'is_parent INTEGER NOT NULL DEFAULT 0,',
		'preferences TEXT NULL,'
	];










	public function delete(): bool
	{
		if (!$this->canDelete()) {
			throw new ValidationException('Ce champ est utilisé en interne, il n\'est pas possible de le supprimer');
		}








>
>
>
>
>
>
>
>
>







192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
		'date_updated TEXT NULL CHECK (date_updated IS NULL OR datetime(date_updated) = date_updated),',
		'otp_secret TEXT NULL,',
		'pgp_key TEXT NULL,',
		'id_parent INTEGER NULL REFERENCES users(id) ON DELETE SET NULL CHECK (id_parent IS NULL OR is_parent = 0),',
		'is_parent INTEGER NOT NULL DEFAULT 0,',
		'preferences TEXT NULL,'
	];

	public function sql_type(): string
	{
		if ($this->type == 'checkbox') {
			return 'INTEGER';
		}

		return self::SQL_TYPES[$this->type];
	}

	public function delete(): bool
	{
		if (!$this->canDelete()) {
			throw new ValidationException('Ce champ est utilisé en interne, il n\'est pas possible de le supprimer');
		}

Modified src/include/lib/Garradin/Entities/Users/User.php from [6a62a114e5] to [699fa88d94].

319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
			$v = 0;

			foreach (array_keys($source[$f->name] ?? []) as $k) {
				$k = 0x01 << $k;
				$v |= $k;
			}

			$source[$f->name] = $v;
		}

		return parent::importForm($source);
	}

	public function importSecurityForm(bool $user_mode = true, array $source = null)
	{







|







319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
			$v = 0;

			foreach (array_keys($source[$f->name] ?? []) as $k) {
				$k = 0x01 << $k;
				$v |= $k;
			}

			$source[$f->name] = $v ?: null;
		}

		return parent::importForm($source);
	}

	public function importSecurityForm(bool $user_mode = true, array $source = null)
	{

Modified src/include/lib/Garradin/Entities/Web/Page.php from [304428ee4a] to [6248ab559a].

62
63
64
65
66
67
68
69
70
71


72
73
74
75
76
77
78
	const TEMPLATES = [
		self::TYPE_PAGE => 'article.html',
		self::TYPE_CATEGORY => 'category.html',
	];

	const DUPLICATE_URI_ERROR = 42;

	protected $_file;
	protected $_attachments;
	protected $_tagged_attachments;



	static public function create(int $type, ?string $parent, string $title, string $status = self::STATUS_ONLINE): self
	{
		$page = new self;
		$data = compact('type', 'parent', 'title', 'status');
		$data['content'] = '';








|
|
|
>
>







62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
	const TEMPLATES = [
		self::TYPE_PAGE => 'article.html',
		self::TYPE_CATEGORY => 'category.html',
	];

	const DUPLICATE_URI_ERROR = 42;

	protected ?File $_file = null;
	protected ?array $_attachments = null;
	protected ?array $_tagged_attachments = null;
	protected ?string $_html = null;
	protected ?\DateTime $_html_modified = null;

	static public function create(int $type, ?string $parent, string $title, string $status = self::STATUS_ONLINE): self
	{
		$page = new self;
		$data = compact('type', 'parent', 'title', 'status');
		$data['content'] = '';

134
135
136
137
138
139
140




141



142
143
144
145
146
147
148

	public function render(?string $user_prefix = null): string
	{
		if (!$this->file()) {
			throw new \LogicException('File does not exist: '  . $this->file_path);
		}





		return Render::render($this->format, $this->file(), $this->content, $user_prefix);



	}

	public function excerpt(int $length = 600): string
	{
		return $this->preview(mb_substr($this->content, 0, $length) . "\n\n…");
	}








>
>
>
>
|
>
>
>







136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157

	public function render(?string $user_prefix = null): string
	{
		if (!$this->file()) {
			throw new \LogicException('File does not exist: '  . $this->file_path);
		}

		if ($this->_html_modified != $this->file()->modified) {
			$this->_html_modified = $this->_html = null;
		}

		$this->_html ??= Render::render($this->format, $this->file(), $this->content, $user_prefix);
		$this->_html_modified ??= $this->file()->modified;

		return $this->_html;
	}

	public function excerpt(int $length = 600): string
	{
		return $this->preview(mb_substr($this->content, 0, $length) . "\n\n…");
	}

Modified src/include/lib/Garradin/Entity.php from [620eaeef49] to [08bdf3b889].

79
80
81
82
83
84
85
86












87
88
89
90
91
92
93
			if ($value instanceof Date) {
				return $value;
			}
			elseif ($value instanceof \DateTimeInterface) {
				return Date::createFromInterface($value);
			}

			return self::filterUserDateValue($value);












		}
		elseif ($type == 'DateTime' && is_string($value)) {
			if (preg_match('!^\d{2}/\d{2}/\d{4}\s\d{1,2}:\d{2}$!', $value)) {
				return \DateTime::createFromFormat('d/m/Y H:i', $value);
			}
		}








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







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
			if ($value instanceof Date) {
				return $value;
			}
			elseif ($value instanceof \DateTimeInterface) {
				return Date::createFromInterface($value);
			}

			$d = self::filterUserDateValue($value);

			if (!$d) {
				return $d;
			}

			$y = $d->format('Y');
			if ($y < 1900 || $y > 2100) {
				throw new ValidationException('Date invalide: doit être entre 1900 et 2100');
			}

			return $d;

		}
		elseif ($type == 'DateTime' && is_string($value)) {
			if (preg_match('!^\d{2}/\d{2}/\d{4}\s\d{1,2}:\d{2}$!', $value)) {
				return \DateTime::createFromFormat('d/m/Y H:i', $value);
			}
		}

137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
			return true;
		}

		$return = parent::save(false);

		// Log creation/edit, but don't record stuff that doesn't change anything
		if ($this::NAME && ($new || $modified)) {
			$type = str_replace('Garradin\Entities\\', '', get_class($this));
			Log::add($new ? Log::CREATE : Log::EDIT, ['entity' => $type, 'id' => $this->id()]);
		}

		Plugins::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'new' => $new]);

		Plugins::fireSignal('entity.save.after', ['entity' => $this, 'success' => $return, 'new' => $new]);

		return $return;







<
|







149
150
151
152
153
154
155

156
157
158
159
160
161
162
163
			return true;
		}

		$return = parent::save(false);

		// Log creation/edit, but don't record stuff that doesn't change anything
		if ($this::NAME && ($new || $modified)) {

			Log::add($new ? Log::CREATE : Log::EDIT, ['entity' => get_class($this), 'id' => $this->id()]);
		}

		Plugins::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'new' => $new]);

		Plugins::fireSignal('entity.save.after', ['entity' => $this, 'success' => $return, 'new' => $new]);

		return $return;
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
		if (Plugins::fireSignal('entity.delete.before', ['entity' => $this, 'id' => $id])) {
			return true;
		}

		$return = parent::delete();

		if ($this::NAME) {
			Log::add(Log::DELETE, ['entity' => $name, 'id' => $id]);
		}

		Plugins::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'id' => $id]);
		Plugins::fireSignal('entity.delete.after', ['entity' => $this, 'success' => $return, 'id' => $id]);

		return $return;
	}
}







|








179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
		if (Plugins::fireSignal('entity.delete.before', ['entity' => $this, 'id' => $id])) {
			return true;
		}

		$return = parent::delete();

		if ($this::NAME) {
			Log::add(Log::DELETE, ['entity' => get_class($this), 'id' => $id]);
		}

		Plugins::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'id' => $id]);
		Plugins::fireSignal('entity.delete.after', ['entity' => $this, 'success' => $return, 'id' => $id]);

		return $return;
	}
}

Modified src/include/lib/Garradin/Files/Storage/FileSystem.php from [e490f9d582] to [1bae585d73].

105
106
107
108
109
110
111
112
113
114
115
116
117
118
119

	static public function touch(string $path, $date = null): bool
	{
		if ($date instanceof \DateTimeInterface) {
			$date = $date->getTimestamp();
		}

		return touch(self::_getRealPath($path), $date ?: null);
	}

	static protected function _getRealPath(string $path): ?string
	{
		if (substr(trim($path, '/'), 0, 1) == '.') {
			return null;
		}







|







105
106
107
108
109
110
111
112
113
114
115
116
117
118
119

	static public function touch(string $path, $date = null): bool
	{
		if ($date instanceof \DateTimeInterface) {
			$date = $date->getTimestamp();
		}

		return touch(self::_getRealPath($path), $date ?? time());
	}

	static protected function _getRealPath(string $path): ?string
	{
		if (substr(trim($path, '/'), 0, 1) == '.') {
			return null;
		}

Modified src/include/lib/Garradin/Install.php from [1d576f102a] to [c7cd9b91ab].

233
234
235
236
237
238
239

240
241
242
243
244
245
246
		$config->import([
			'org_name'      => $name,
			'org_email'     => $user_email,
			'currency'      => $currency,
			'country'       => $country_code,
			'site_disabled' => true,
			'log_retention' => 365,

			'analytical_set_all' => true,
		]);

		$fields = DynamicFields::getInstance();
		$fields->install();

		// Create default category for common users







>







233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
		$config->import([
			'org_name'      => $name,
			'org_email'     => $user_email,
			'currency'      => $currency,
			'country'       => $country_code,
			'site_disabled' => true,
			'log_retention' => 365,
			'auto_logout'   => 2*60,
			'analytical_set_all' => true,
		]);

		$fields = DynamicFields::getInstance();
		$fields->install();

		// Create default category for common users

Modified src/include/lib/Garradin/Log.php from [4abbd78347] to [80e26a18a5].

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
	const LOGIN_PASSWORD_CHANGE = 4;
	const LOGIN_CHANGE = 5;
	const LOGIN_AS = 6;

	const CREATE = 10;
	const DELETE = 11;
	const EDIT = 12;


	const ACTIONS = [
		self::LOGIN_FAIL => 'Connexion refusée',
		self::LOGIN_SUCCESS => 'Connexion réussie',
		self::LOGIN_RECOVER => 'Mot de passe perdu',
		self::LOGIN_PASSWORD_CHANGE => 'Modification de mot de passe',
		self::LOGIN_CHANGE => 'Modification d\'identifiant',
		self::LOGIN_AS => 'Connexion par un administrateur',

		self::CREATE => 'Création',
		self::DELETE => 'Suppression',
		self::EDIT => 'Modification',

	];

	static public function add(int $type, ?array $details = null, int $id_user = null): void
	{
		if (defined('Garradin\INSTALL_PROCESS')) {
			return;
		}

		if ($type != self::LOGIN_FAIL) {
			$keep = Config::getInstance()->log_retention;

			// Don't log anything
			if ($keep == 0) {
				return;
			}
		}





		$ip = Utils::getIP();
		$session = Session::getInstance();
		$id_user ??= Session::getUserId();

		DB::getInstance()->insert('logs', [
			'id_user'    => $id_user,







>












>
















>
>
>
>







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
	const LOGIN_PASSWORD_CHANGE = 4;
	const LOGIN_CHANGE = 5;
	const LOGIN_AS = 6;

	const CREATE = 10;
	const DELETE = 11;
	const EDIT = 12;
	const SENT = 13;

	const ACTIONS = [
		self::LOGIN_FAIL => 'Connexion refusée',
		self::LOGIN_SUCCESS => 'Connexion réussie',
		self::LOGIN_RECOVER => 'Mot de passe perdu',
		self::LOGIN_PASSWORD_CHANGE => 'Modification de mot de passe',
		self::LOGIN_CHANGE => 'Modification d\'identifiant',
		self::LOGIN_AS => 'Connexion par un administrateur',

		self::CREATE => 'Création',
		self::DELETE => 'Suppression',
		self::EDIT => 'Modification',
		self::SENT => 'Envoi',
	];

	static public function add(int $type, ?array $details = null, int $id_user = null): void
	{
		if (defined('Garradin\INSTALL_PROCESS')) {
			return;
		}

		if ($type != self::LOGIN_FAIL) {
			$keep = Config::getInstance()->log_retention;

			// Don't log anything
			if ($keep == 0) {
				return;
			}
		}

		if (isset($details['entity'])) {
			$details['entity'] = str_replace('Garradin\Entities\\', '', $details['entity']);
		}

		$ip = Utils::getIP();
		$session = Session::getInstance();
		$id_user ??= Session::getUserId();

		DB::getInstance()->insert('logs', [
			'id_user'    => $id_user,
163
164
165
166
167
168
169


170


171
172
173


174


175
176
177
178
179
180
181
		$list->orderBy('created', true);
		$list->setCount('COUNT(logs.id)');
		$list->setModifier(function (&$row) {
			$row->created = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->created);
			$row->details = $row->details ? json_decode($row->details) : null;
			$row->type_label = self::ACTIONS[$row->type];



			if (isset($row->details->entity) && defined('Garradin\Entities\\' . $row->details->entity . '::NAME')) {


				$row->entity_name = constant('Garradin\Entities\\' . $row->details->entity . '::NAME');
			}



			if (isset($row->details->id, $row->details->entity) && defined('Garradin\Entities\\' . $row->details->entity . '::PRIVATE_URL')) {


				$row->entity_url = sprintf(constant('Garradin\Entities\\' . $row->details->entity . '::PRIVATE_URL'), $row->details->id);
			}
		});

		return $list;
	}
}







>
>
|
>
>
|


>
>
|
>
>
|






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
		$list->orderBy('created', true);
		$list->setCount('COUNT(logs.id)');
		$list->setModifier(function (&$row) {
			$row->created = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->created);
			$row->details = $row->details ? json_decode($row->details) : null;
			$row->type_label = self::ACTIONS[$row->type];

			$const = 'Garradin\Entities\\' . $row->details->entity . '::NAME';

			if (isset($row->details->entity)
				&& defined($const)
				&& ($value = constant($const))) {
				$row->entity_name = $value;
			}

			$const = 'Garradin\Entities\\' . $row->details->entity . '::PRIVATE_URL';

			if (isset($row->details->id, $row->details->entity)
				&& defined($const)
				&& ($value = constant($const))) {
				$row->entity_url = sprintf($value, $row->details->id);
			}
		});

		return $list;
	}
}

Modified src/include/lib/Garradin/Plugins.php from [7e87d31149] to [30dcf74c9a].

167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
		foreach ($list as &$item) {
			$type = isset($item['plugin']) ? 'plugin' : 'module';
			$c = $item[$type];
			$item = $c->asArray();
			$item[$type] = $c;
			$item['icon_url'] = $c->icon_url();
			$item['config_url'] = $c->hasConfig() ? $c->url($c::CONFIG_FILE) : null;
			$item['readme_url'] = $c->hasFile($c::README_FILE) ? $c->url($c::README_FILE) : null;
			$item['installed'] = $type == 'plugin' ? $c->exists() : true;
			$item['broken'] = $type == 'plugin' ? !$c->hasCode() : false;
			$item['broken_message'] = $type == 'plugin' ? $c->getBrokenMessage() : false;

			$item['url'] = null;

			if ($c->hasFile($c::INDEX_FILE)) {







|







167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
		foreach ($list as &$item) {
			$type = isset($item['plugin']) ? 'plugin' : 'module';
			$c = $item[$type];
			$item = $c->asArray();
			$item[$type] = $c;
			$item['icon_url'] = $c->icon_url();
			$item['config_url'] = $c->hasConfig() ? $c->url($c::CONFIG_FILE) : null;
			$item['readme_url'] = $c->enabled && $c->hasFile($c::README_FILE) ? $c->url($c::README_FILE) : null;
			$item['installed'] = $type == 'plugin' ? $c->exists() : true;
			$item['broken'] = $type == 'plugin' ? !$c->hasCode() : false;
			$item['broken_message'] = $type == 'plugin' ? $c->getBrokenMessage() : false;

			$item['url'] = null;

			if ($c->hasFile($c::INDEX_FILE)) {

Modified src/include/lib/Garradin/Services/Fees.php from [b73b6d4c72] to [a09b1bea4a].

55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

		foreach ($result as &$row) {
			if (!$row->formula) {
				continue;
			}

			try {
				$sql = sprintf('SELECT %s FROM users WHERE id = %d;', $row->formula, $user_id);
				$row->user_amount = $db->firstColumn($sql);
			}
			catch (DB_Exception $e) {
				$row->label .= sprintf(' (**FORMULE DE CALCUL INVALIDE: %s**)', $e->getMessage());
				$row->description .= "\n\n**MERCI DE CORRIGER LA FORMULE**";
				$row->user_amount = -1;
			}







|







55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

		foreach ($result as &$row) {
			if (!$row->formula) {
				continue;
			}

			try {
				$sql = sprintf('SELECT (%s) FROM users WHERE id = %d;', $row->formula, $user_id);
				$row->user_amount = $db->firstColumn($sql);
			}
			catch (DB_Exception $e) {
				$row->label .= sprintf(' (**FORMULE DE CALCUL INVALIDE: %s**)', $e->getMessage());
				$row->description .= "\n\n**MERCI DE CORRIGER LA FORMULE**";
				$row->user_amount = -1;
			}

Modified src/include/lib/Garradin/Template.php from [c7200fc438] to [944f3a8d61].

510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
			}
			else {
				$required_label =  ' <i>(facultatif)</i>';
			}

			$out  = sprintf('<dt><label for="f_%s_0">%s</label>%s<input type="hidden" name="%s_present" value="1" /></dt>', $key, htmlspecialchars($field->label), $required_label, $key);

			if ($field->help) {
				$out .= sprintf('<dd class="help">%s</dd>', htmlspecialchars($help));
			}

			foreach ($options as $k => $v)
			{
				$b = 0x01 << (int)$k;

				$p = [







|
|







510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
			}
			else {
				$required_label =  ' <i>(facultatif)</i>';
			}

			$out  = sprintf('<dt><label for="f_%s_0">%s</label>%s<input type="hidden" name="%s_present" value="1" /></dt>', $key, htmlspecialchars($field->label), $required_label, $key);

			if ($field->help ?? null) {
				$out .= sprintf('<dd class="help">%s</dd>', htmlspecialchars($field->help));
			}

			foreach ($options as $k => $v)
			{
				$b = 0x01 << (int)$k;

				$p = [

Modified src/include/lib/Garradin/UserTemplate/Functions.php from [583c59a6d5] to [8625fb4a27].

601
602
603
604
605
606
607







608
609
610

		}

		return '';
	}

	static public function redirect(array $params): void
	{







		Utils::redirectDialog($params['to'] ?? null);
	}
}








>
>
>
>
>
>
>
|
|
|
>
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
		}

		return '';
	}

	static public function redirect(array $params): void
	{
		if (isset($params['force'])) {
			Utils::redirectDialog($params['force']);
		}
		elseif (isset($_GET['_dialog'])) {
			Utils::reloadParentFrame();
		}
		else {
			Utils::redirectDialog($params['to'] ?? null);
		}
	}
}

Modified src/include/lib/Garradin/UserTemplate/Modules.php from [ff48e1b187] to [2092841626].

23
24
25
26
27
28
29


30
31
32
33
34
35
36

class Modules
{
	// Shortcuts so that code calling snippets method don't have to use Module entity
	const SNIPPET_TRANSACTION = Module::SNIPPET_TRANSACTION;
	const SNIPPET_USER = Module::SNIPPET_USER;
	const SNIPPET_HOME_BUTTON = Module::SNIPPET_HOME_BUTTON;



	static public function fetchDistFile(string $path): ?string
	{
		if (substr($path, 0, strlen('modules/')) === 'modules/') {
			$path = substr($path, strlen('modules/'));
		}








>
>







23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

class Modules
{
	// Shortcuts so that code calling snippets method don't have to use Module entity
	const SNIPPET_TRANSACTION = Module::SNIPPET_TRANSACTION;
	const SNIPPET_USER = Module::SNIPPET_USER;
	const SNIPPET_HOME_BUTTON = Module::SNIPPET_HOME_BUTTON;
	const SNIPPET_MY_SERVICES = Module::SNIPPET_MY_SERVICES;
	const SNIPPET_MY_DETAILS = Module::SNIPPET_MY_DETAILS;

	static public function fetchDistFile(string $path): ?string
	{
		if (substr($path, 0, strlen('modules/')) === 'modules/') {
			$path = substr($path, strlen('modules/'));
		}

Modified src/include/lib/Garradin/UserTemplate/Sections.php from [414a4bc1b1] to [8a6c6f3cb2].

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

		echo '</tbody>';
		echo '</table>';


		echo $list->getHTMLPagination();
	}


















	static public function balances(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$db = DB::getInstance();

		$params['where'] ??= '';
		$params['tables'] = 'acc_accounts_balances';

		if (isset($params['codes'])) {
			if (!is_array($params['codes'])) {
				$params['codes'] = explode(',', $params['codes']);
			}

			foreach ($params['codes'] as &$code) {
				$code = 'code LIKE ' . $db->quote($code);
			}

			$params['where'] .= sprintf(' AND (%s)', implode(' OR ', $params['codes']));

			unset($code, $params['codes']);
		}

		if (isset($params['year'])) {
			$params['where'] .= ' AND id_year = :year';
			$params[':year'] = $params['year'];
			unset($params['year']);
		}







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









<
<
<
<
<
<
<
<
|
<
|







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

		echo '</tbody>';
		echo '</table>';


		echo $list->getHTMLPagination();
	}

	static protected function getAccountCodeCondition($codes, string $column = 'code')
	{
		if (!is_array($codes)) {
			$codes = explode(',', $codes);
		}

		$db = DB::getInstance();

		foreach ($codes as &$code) {
			$code = $column . ' LIKE ' . $db->quote($code);
		}

		unset($code);

		return implode(' OR ', $codes);
	}

	static public function balances(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$db = DB::getInstance();

		$params['where'] ??= '';
		$params['tables'] = 'acc_accounts_balances';

		if (isset($params['codes'])) {








			$params['where'] .= sprintf(' AND (%s)', self::getAccountCodeCondition($params['codes']));

			unset($params['codes']);
		}

		if (isset($params['year'])) {
			$params['where'] .= ' AND id_year = :year';
			$params[':year'] = $params['year'];
			unset($params['year']);
		}
654
655
656
657
658
659
660





661
662
663
664
665
666
667
			unset($params['id']);
		}
		elseif (isset($params['id'])) {
			$params['where'] .= ' AND users.id = :id';
			$params[':id'] = (int) $params['id'];
			unset($params['id']);
		}






		if (!empty($params['search_name'])) {
			$params['tables'] .= sprintf(' INNER JOIN users_search AS us ON us.id = users.id AND %s LIKE :search_name ESCAPE \'\\\' COLLATE NOCASE',
				DynamicFields::getNameFieldsSQL('us'));
			$params[':search_name'] = '%' . Utils::unicodeTransliterate($params['search_name']) . '%';
			unset($params['search_name']);
		}







>
>
>
>
>







662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
			unset($params['id']);
		}
		elseif (isset($params['id'])) {
			$params['where'] .= ' AND users.id = :id';
			$params[':id'] = (int) $params['id'];
			unset($params['id']);
		}
		elseif (isset($params['id_parent'])) {
			$params['where'] .= ' AND users.id_parent = :id_parent';
			$params[':id_parent'] = (int) $params['id_parent'];
			unset($params['id_parent']);
		}

		if (!empty($params['search_name'])) {
			$params['tables'] .= sprintf(' INNER JOIN users_search AS us ON us.id = users.id AND %s LIKE :search_name ESCAPE \'\\\' COLLATE NOCASE',
				DynamicFields::getNameFieldsSQL('us'));
			$params[':search_name'] = '%' . Utils::unicodeTransliterate($params['search_name']) . '%';
			unset($params['search_name']);
		}
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
		return self::sql($params, $tpl, $line);
	}

	static public function transactions(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$params['where'] ??= '';

		if (isset($params['id'])) {
			$params['where'] .= ' AND t.id = :id';
			$params[':id'] = (int) $params['id'];
			unset($params['id']);
		}

		$id_field = DynamicFields::getNameFieldsSQL();

		$params['select'] = sprintf('t.*, SUM(l.credit) AS credit, SUM(l.debit) AS debit,
			GROUP_CONCAT(DISTINCT a.code) AS accounts_codes,
			(SELECT GROUP_CONCAT(DISTINCT %s) FROM users WHERE id IN (SELECT id_user FROM acc_transactions_users WHERE id_transaction = t.id)) AS users_names', $id_field);
		$params['tables'] = 'acc_transactions AS t
			INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id
			INNER JOIN acc_accounts AS a ON l.id_account = a.id';
		$params['group'] = 't.id';



























		return self::sql($params, $tpl, $line);
	}

	static public function transaction_lines(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$params['where'] ??= '';







<
<
<
<
<
<









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







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
		return self::sql($params, $tpl, $line);
	}

	static public function transactions(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$params['where'] ??= '';







		$id_field = DynamicFields::getNameFieldsSQL();

		$params['select'] = sprintf('t.*, SUM(l.credit) AS credit, SUM(l.debit) AS debit,
			GROUP_CONCAT(DISTINCT a.code) AS accounts_codes,
			(SELECT GROUP_CONCAT(DISTINCT %s) FROM users WHERE id IN (SELECT id_user FROM acc_transactions_users WHERE id_transaction = t.id)) AS users_names', $id_field);
		$params['tables'] = 'acc_transactions AS t
			INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id
			INNER JOIN acc_accounts AS a ON l.id_account = a.id';
		$params['group'] = 't.id';

		if (isset($params['id'])) {
			$params['where'] .= ' AND t.id = :id';
			$params[':id'] = (int) $params['id'];
			unset($params['id']);
		}
		elseif (isset($params['user'])) {
			$params['where'] .= ' AND tu.id_user = :id_user';
			$params[':id_user'] = (int) $params['user'];
			unset($params['user']);

			$params['tables'] .= ' INNER JOIN acc_transactions_users AS tu ON tu.id_transaction = t.id';
		}

		if (isset($params['debit_codes'])) {
			$params['where'] .= sprintf(' AND l.debit > 0 AND (%s)', self::getAccountCodeCondition($params['debit_codes'], 'a.code'));
		}
		elseif (isset($params['credit_codes'])) {
			$params['where'] .= sprintf(' AND l.debit > 0 AND (%s)', self::getAccountCodeCondition($params['credit_codes'], 'a.code'));
		}

		unset($params['debit_codes'], $params['credit_codes']);

		if (isset($params['order']) && ctype_alpha(substr((string) $params['order'], 0, 1))) {
			$params['order'] = 't.' . $params['order'];
		}

		return self::sql($params, $tpl, $line);
	}

	static public function transaction_lines(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$params['where'] ??= '';

Modified src/include/lib/Garradin/UserTemplate/UserTemplate.php from [9208421c53] to [f6edb30b14].

258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
		try {
			$code = $this->compile($source);
			file_put_contents($tmp_path, $code);

			require $tmp_path;
		}
		catch (Brindille_Exception $e) {
			$path = $this->file ? $this->file->name : ($this->code ? 'code' : Utils::basename($this->path));

			$message = sprintf("Erreur dans '%s' :\n%s", $path, $e->getMessage());

			if (0 === strpos($this->path ?? '', self::DIST_ROOT)) {
				// We want errors in shipped code to be reported, it is not normal
				throw new \RuntimeException($message, 0, $e);
			}







|







258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
		try {
			$code = $this->compile($source);
			file_put_contents($tmp_path, $code);

			require $tmp_path;
		}
		catch (Brindille_Exception $e) {
			$path = $this->file ? $this->file->path : ($this->code ? 'code' : str_replace(ROOT, '…', $this->path));

			$message = sprintf("Erreur dans '%s' :\n%s", $path, $e->getMessage());

			if (0 === strpos($this->path ?? '', self::DIST_ROOT)) {
				// We want errors in shipped code to be reported, it is not normal
				throw new \RuntimeException($message, 0, $e);
			}
429
430
431
432
433
434
435
436
437
438
439
440
		try {
			return call_user_func($this->_functions[$name], $params, $this, $line);
		}
		catch (UserException $e) {
			throw $e;
		}
		catch (\Exception $e) {
			throw new Brindille_Exception(sprintf("line %d: function '%s' has returned an error: %s\nParameters: %s", $line, $name, $e->getMessage(), json_encode($params)));
		}
	}

}







|




429
430
431
432
433
434
435
436
437
438
439
440
		try {
			return call_user_func($this->_functions[$name], $params, $this, $line);
		}
		catch (UserException $e) {
			throw $e;
		}
		catch (\Exception $e) {
			throw new Brindille_Exception(sprintf("line %d: function '%s' has returned an error: %s\nParameters: %s", $line, $name, $e->getMessage(), substr(var_export($params, true), 6)), 0, $e);
		}
	}

}

Modified src/include/lib/Garradin/Users/Categories.php from [45faa8b425] to [2d9990cfea].

1
2
3
4
5
6

7
8
9
10
11
12
13
<?php

namespace Garradin\Users;

use Garradin\DB;
use Garradin\Entities\Users\Category;

use KD2\DB\EntityManager as EM;

class Categories
{
	const HIDDEN_ONLY = 1;
	const WITHOUT_HIDDEN = 0;







>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

namespace Garradin\Users;

use Garradin\DB;
use Garradin\Entities\Users\Category;
use Garradin\Entities\Users\User;
use KD2\DB\EntityManager as EM;

class Categories
{
	const HIDDEN_ONLY = 1;
	const WITHOUT_HIDDEN = 0;

30
31
32
33
34
35
36





























37
38
39
40
41
42
43
44
45
46

	static public function listAssoc(?int $hidden = null): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s WHERE 1 %s ORDER BY name COLLATE U_NOCASE;',
			Category::TABLE, self::getHiddenClause($hidden)
		));
	}






























	static public function listWithStats(?int $hidden = null): array
	{
		return DB::getInstance()->getGrouped(sprintf('SELECT c.id, c.*,
			(SELECT COUNT(*) FROM users WHERE id_category = c.id) AS count
			FROM %s c WHERE 1 %s ORDER BY c.name COLLATE U_NOCASE;',
			Category::TABLE, self::getHiddenClause($hidden)
		));
	}
}







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










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

	static public function listAssoc(?int $hidden = null): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s WHERE 1 %s ORDER BY name COLLATE U_NOCASE;',
			Category::TABLE, self::getHiddenClause($hidden)
		));
	}

	static public function listAssocWithStats(?int $hidden = null): array
	{
		$db = DB::getInstance();

		$categories = [0 => (object) [
			'label' => 'Toutes, sauf cachées',
			'count' => $db->count(User::TABLE, 'id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'),
		]];

		if ($hidden !== self::WITHOUT_HIDDEN) {
			$categories[-1] = (object) [
				'label' => 'Toutes, même cachées',
				'count' => $db->count(User::TABLE),
			];
		}

		$format = '%s (%d membres)';

		return $categories + $db->getGrouped(sprintf(
			'SELECT id, name AS label, (SELECT COUNT(*) FROM %s WHERE %1$s.id_category = %s.id) AS count
			FROM %2$s
			WHERE 1 %s
			ORDER BY name COLLATE U_NOCASE;',
			User::TABLE,
			Category::TABLE,
			self::getHiddenClause($hidden)
		));
	}

	static public function listWithStats(?int $hidden = null): array
	{
		return DB::getInstance()->getGrouped(sprintf('SELECT c.id, c.*,
			(SELECT COUNT(*) FROM users WHERE id_category = c.id) AS count
			FROM %s c WHERE 1 %s ORDER BY c.name COLLATE U_NOCASE;',
			Category::TABLE, self::getHiddenClause($hidden)
		));
	}
}

Modified src/include/lib/Garradin/Users/DynamicFields.php from [1a3960695b] to [b9ee91a2f6].

597
598
599
600
601
602
603

604











605
606
607
608
609
610
611
		return $c = array_combine($c, $c);
	}

	public function getSQLCopy(string $old_table_name, string $new_table_name = User::TABLE, array $fields = null, string $function = null): string
	{
		$db = DB::getInstance();
		unset($fields['id']);

		$source = array_map([$db, 'quoteIdentifier'], array_keys($fields));












		if ($function) {
			$source = array_map(fn($a) => $function . '(' . $a . ')', $source);
		}

		return sprintf('INSERT INTO %s (id, %s) SELECT id, %s FROM %s;',
			$new_table_name,







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







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
		return $c = array_combine($c, $c);
	}

	public function getSQLCopy(string $old_table_name, string $new_table_name = User::TABLE, array $fields = null, string $function = null): string
	{
		$db = DB::getInstance();
		unset($fields['id']);

		$source = [];

		foreach ($fields as $src_key => $dst_key) {
			$field = $this->get($dst_key);

			if ($field) {
				$source[] = sprintf('CAST(%s AS %s)', $db->quoteIdentifier($src_key), $field->sql_type());
			}
			else {
				$source[] = $src_key;
			}
		}

		if ($function) {
			$source = array_map(fn($a) => $function . '(' . $a . ')', $source);
		}

		return sprintf('INSERT INTO %s (id, %s) SELECT id, %s FROM %s;',
			$new_table_name,
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
		$db = DB::getInstance();

		// First check that the field can be used as login
		if (!$this->isUnique($new_field)) {
			throw new UserException(sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->_fields[$new_field]->label));
		}


		$sql = sprintf('UPDATE %s SET system = system & ~%d WHERE system & %2$d;
			UPDATE %1$s SET system = system | %2$d WHERE name = %s;',
			self::TABLE,
			DynamicField::LOGIN,
			$db->quote($new_field)
		);

		$db->exec($sql);




		// Regenerate login index
		$db->exec('DROP INDEX IF EXISTS users_id_field;');
		$this->createIndexes();

		$this->reload();
	}

	public function listEligibleNameFields(): array
	{
		$out = [];

		foreach ($this->_fields as $field) {







>








>
>
>




<
<







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
		$db = DB::getInstance();

		// First check that the field can be used as login
		if (!$this->isUnique($new_field)) {
			throw new UserException(sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->_fields[$new_field]->label));
		}

		// Change login field in fields config table
		$sql = sprintf('UPDATE %s SET system = system & ~%d WHERE system & %2$d;
			UPDATE %1$s SET system = system | %2$d WHERE name = %s;',
			self::TABLE,
			DynamicField::LOGIN,
			$db->quote($new_field)
		);

		$db->exec($sql);

		// Reload dynamic fields cache
		$this->reload();

		// Regenerate login index
		$db->exec('DROP INDEX IF EXISTS users_id_field;');
		$this->createIndexes();


	}

	public function listEligibleNameFields(): array
	{
		$out = [];

		foreach ($this->_fields as $field) {

Modified src/include/lib/Garradin/Users/Users.php from [4ae5046163] to [48d8e7ac83].

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
	{
		$df = DynamicFields::getInstance();
		$number_field = $df->getNumberField();
		$name_fields = $df->getNameFields();

		$columns = [
			'_user_id' => [
				'select' => 'id',
			],
		];

		$number_column = [
			'label' => 'Num.',
			'select' => $number_field,
		];

		$identity_column = [
			'label' => $df->getNameLabel(),
			'select' => $df->getNameFieldsSQL(),

		];

		$fields = $df->getListedFields();

		foreach ($fields as $key => $config) {
			// Skip number field
			if ($key === $number_field) {







|





|




|
>







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
	{
		$df = DynamicFields::getInstance();
		$number_field = $df->getNumberField();
		$name_fields = $df->getNameFields();

		$columns = [
			'_user_id' => [
				'select' => 'users.id',
			],
		];

		$number_column = [
			'label' => 'Num.',
			'select' => 'users.' . $number_field,
		];

		$identity_column = [
			'label' => $df->getNameLabel(),
			'select' => $df->getNameFieldsSQL('users'),
			'order' => 'identity COLLATE U_NOCASE %s',
		];

		$fields = $df->getListedFields();

		foreach ($fields as $key => $config) {
			// Skip number field
			if ($key === $number_field) {
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
					$identity_column = null;
				}

				continue;
			}

			$columns[$key] = [
				'label' => $config->label,

			];




		}

		if (null !== $identity_column) {
			$columns['identity'] = $identity_column;
		}

		$tables = User::TABLE;






















		if (!$id_category) {
			$conditions = sprintf('id_category IN (SELECT id FROM users_categories WHERE hidden = 0)');
		}
		elseif ($id_category > 0) {
			$conditions = sprintf('id_category = %d', $id_category);
		}
		else {
			$conditions = '1';
		}

		$order = 'identity';








|
>

>
>
>
>







>

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

|


|







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
					$identity_column = null;
				}

				continue;
			}

			$columns[$key] = [
				'label'  => $config->label,
				'select' => 'users.' . $key,
			];

			if ($df->isText($key)) {
				$columns[$key]['order'] = sprintf('%s COLLATE U_NOCASE %%s', $key);
			}
		}

		if (null !== $identity_column) {
			$columns['identity'] = $identity_column;
		}

		$tables = User::TABLE;
		$db = DB::getInstance();

		if ($db->test('users', 'is_parent = 1')) {
			$tables .= ' LEFT JOIN users b ON b.id = users.id_parent';

			$columns['id_parent'] = [
				'label'  => 'Rattaché à',
				'select' => 'users.id_parent',
				'order'  => 'users.id_parent IS NULL, _parent_name COLLATE U_NOCASE %s, identity COLLATE U_NOCASE %1$s',
			];

			$columns['_parent_name'] = [
				'select' => sprintf('CASE WHEN users.id_parent IS NOT NULL THEN %s ELSE NULL END', $df->getNameFieldsSQL('b')),
			];

			$columns['is_parent'] = [
				'label' => 'Responsable',
				'select' => 'users.is_parent',
				'order' => 'users.is_parent DESC, identity COLLATE U_NOCASE %1$s',
			];
		}

		if (!$id_category) {
			$conditions = sprintf('users.id_category IN (SELECT id FROM users_categories WHERE hidden = 0)');
		}
		elseif ($id_category > 0) {
			$conditions = sprintf('users.id_category = %d', $id_category);
		}
		else {
			$conditions = '1';
		}

		$order = 'identity';

Modified src/include/lib/Garradin/Web/Render/AbstractRender.php from [b9ae79bdbd] to [a060bb9bc7].

1
2
3
4
5

6
7
8
9
10
11
12
<?php

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;

use Garradin\Utils;

use const Garradin\{WWW_URL, ADMIN_URL};

abstract class AbstractRender
{
	protected $current_path;





>







1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;
use Garradin\Files\Files;
use Garradin\Utils;

use const Garradin\{WWW_URL, ADMIN_URL};

abstract class AbstractRender
{
	protected $current_path;
35
36
37
38
39
40
41




















42
43
44
45
46
47
48
		return isset($this->current_path);
	}

	public function registerAttachment(string $uri)
	{
		Render::registerAttachment($this->file, $uri);
	}





















	public function resolveAttachment(string $uri)
	{
		$prefix = $this->current_path;
		$pos = strpos($uri, '/');

		if ($pos === 0) {







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







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
		return isset($this->current_path);
	}

	public function registerAttachment(string $uri)
	{
		Render::registerAttachment($this->file, $uri);
	}

	public function listImages(): array
	{
		if (!$this->file) {
			return [];
		}

		$out = [];
		$list = Files::list(Utils::dirname($this->file->path));

		foreach ($list as $file) {
			if (!$file->image) {
				continue;
			}

			$out[] = $file->name;
		}

		return $out;
	}

	public function resolveAttachment(string $uri)
	{
		$prefix = $this->current_path;
		$pos = strpos($uri, '/');

		if ($pos === 0) {

Modified src/include/lib/Garradin/Web/Render/Extensions.php from [79ca1d8f76] to [5f09fceffa].

27
28
29
30
31
32
33


34
35
36
37
38
39













































40
41
42
43
44
45
46

	public function getList(): array
	{
		$list = [
			'file'     => [$this, 'file'],
			'fichier'  => [$this, 'file'],
			'image'    => [$this, 'image'],


		];

		Plugins::fireSignal('render.extensions.init', ['extensions' => &$list]);

		return $list;
	}














































	public function file(bool $block, array $args): string
	{
		$name = $args[0] ?? null;
		$caption = $args[1] ?? null;

		if (!$name || !$this->renderer->hasPath()) {







>
>






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







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

	public function getList(): array
	{
		$list = [
			'file'     => [$this, 'file'],
			'fichier'  => [$this, 'file'],
			'image'    => [$this, 'image'],
			'gallery'  => [$this, 'gallery'],
			'video'    => [$this, 'video'],
		];

		Plugins::fireSignal('render.extensions.init', ['extensions' => &$list]);

		return $list;
	}

	public function gallery(bool $block, array $args, ?string $content): string
	{
		$type = 'gallery';

		if (isset($args['type'])) {
			$type = $args['type'];
		}
		elseif (isset($args[0])) {
			$type = $args[0];
		}

		if (!in_array($type, ['gallery', 'slideshow'])) {
			$type = 'gallery';
		}

		$out = sprintf('<div class="%s"><div class="images">', $type);
		$index = '';

		if (trim((string)$content) === '') {
			$images = $this->renderer->listImages();
		}
		else {
			$images = explode("\n", $content);
		}

		$i = 1;

		foreach ($images as $line) {
			$line = trim($line);

			if ($line === '') {
				continue;
			}

			$img = strtok($line, '|');
			$label = strtok(false);
			$size = $type == 'slideshow' ? 500 : 200;

			$out .= sprintf('<figure>%s</figure>', $this->img($img, $size, $label ?: null));
		}

		$out .= '</div></div>';
		return $out;
	}

	public function file(bool $block, array $args): string
	{
		$name = $args[0] ?? null;
		$caption = $args[1] ?? null;

		if (!$name || !$this->renderer->hasPath()) {
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

















		$ext = substr($name, strrpos($name, '.')+1);

		return sprintf(
			'<aside class="file" data-type="%s"><a href="%s" class="internal-file"><b>%s</b> <small>(%s)</small></a></aside>',
			htmlspecialchars($ext), htmlspecialchars($url), htmlspecialchars($caption), htmlspecialchars(strtoupper($ext))
		);
	}









































	public function image(bool $block, array $args): string
	{
		static $align_replace = ['gauche' => 'left', 'droite' => 'right', 'centre' => 'center'];

		$name = $args['file'] ?? ($args[0] ?? null);
		$align = $args['align'] ?? ($args[1] ?? null);
		$caption = $args['caption'] ?? (isset($args[2]) ? implode(' ', array_slice($args, 2)) : null);

		$align = strtr((string)$align, $align_replace);

		if (!$name || !$this->renderer->hasPath()) {
			return self::error('Tag image : aucun nom de fichier indiqué.');
		}

		$url = $this->renderer->resolveAttachment($name);
		$size = $align == 'center' ? 500 : 200;
		$svg = substr($name, -4) == '.svg';
		$thumb_url = null;

		if (!$svg) {
			$thumb_url = sprintf('%s?%spx', $url, $size);
		}

		$out = sprintf('<a href="%s" class="internal-image" target="_image"><img src="%s" alt="%s" loading="lazy" /></a>',
			htmlspecialchars($url),
			htmlspecialchars($thumb_url ?? $url),
			htmlspecialchars($caption ?? '')
		);

		if (!empty($align)) {
			if ($caption) {
				$caption = sprintf('<figcaption>%s</figcaption>', htmlspecialchars($caption));
			}

			$out = sprintf('<figure class="image img-%s">%s%s</figure>', $align, $out, $caption);
		}

		return $out;
	}
}
























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















<

<
<
|
<
<
<
<
<
<
<
<
<











|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
		$ext = substr($name, strrpos($name, '.')+1);

		return sprintf(
			'<aside class="file" data-type="%s"><a href="%s" class="internal-file"><b>%s</b> <small>(%s)</small></a></aside>',
			htmlspecialchars($ext), htmlspecialchars($url), htmlspecialchars($caption), htmlspecialchars(strtoupper($ext))
		);
	}

	public function video(bool $block, array $args): string
	{
		$name = $args['file'] ?? ($args[0] ?? null);

		if (!$name || !$this->renderer->hasPath()) {
			return self::error('Tag image : aucun nom de fichier indiqué.');
		}

		$poster = $args['poster'] ?? ($args[1] ?? null);
		$subs = $args['subtitles'] ?? ($args[2] ?? null);
		$url = $this->renderer->resolveAttachment($name);

		if ($poster) {
			$poster = $this->renderer->resolveAttachment($poster);
		}

		if ($subs) {
			$subs = $this->renderer->resolveAttachment($subs);
			$subs = sprintf('<track kind="subtitles" default="true" src="%s" />', htmlspecialchars($subs));
		}

		$params = '';

		if (isset($args['width'])) {
			$params .= sprintf(' width="%d"', $args['width']);
		}

		if (isset($args['height'])) {
			$params .= sprintf(' height="%d"', $args['height']);
		}

		return sprintf('<video controls="true" preload="%s" poster="%s" src="%s"%s>%s</video>',
			$poster ? 'metadata' : 'none',
			htmlspecialchars($poster),
			htmlspecialchars($url),
			$params,
			$subs
		);
	}

	public function image(bool $block, array $args): string
	{
		static $align_replace = ['gauche' => 'left', 'droite' => 'right', 'centre' => 'center'];

		$name = $args['file'] ?? ($args[0] ?? null);
		$align = $args['align'] ?? ($args[1] ?? null);
		$caption = $args['caption'] ?? (isset($args[2]) ? implode(' ', array_slice($args, 2)) : null);

		$align = strtr((string)$align, $align_replace);

		if (!$name || !$this->renderer->hasPath()) {
			return self::error('Tag image : aucun nom de fichier indiqué.');
		}


		$size = $align == 'center' ? 500 : 200;


		$out = $this->img($name, $size, $caption);










		if (!empty($align)) {
			if ($caption) {
				$caption = sprintf('<figcaption>%s</figcaption>', htmlspecialchars($caption));
			}

			$out = sprintf('<figure class="image img-%s">%s%s</figure>', $align, $out, $caption);
		}

		return $out;
	}

	protected function img(string $name, ?int $thumb_size = 200, ?string $caption = null): string
	{
		$url = $this->renderer->resolveAttachment($name);
		$svg = substr($name, -4) == '.svg';
		$thumb_url = null;

		if (!$svg) {
			$thumb_url = sprintf('%s?%spx', $url, $thumb_size);
		}

		return sprintf('<a href="%s" class="internal-image" target="_image"><img src="%s" alt="%s" loading="lazy" /></a>',
			htmlspecialchars($url),
			htmlspecialchars($thumb_url ?? $url),
			htmlspecialchars($caption ?? '')
		);
	}
}

Modified src/include/lib/Garradin/Web/Render/Markdown.php from [9b15706bc5] to [7b5fba1897].

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
	/**
	 * Used by doc_md_to_html.php script
	 */
	public $toc = [];

	public function render(?string $content = null): string
	{








		$md = Markdown_Parser::instance();
		Markdown_Extensions::register($md);

		// Register Paheko extensions
		$ext = new Extensions($this);

		foreach ($ext->getList() as $name => $callback) {
			$md->registerExtension($name, $callback);
		}

		$str = $content ?? $this->file->fetch();
		$str = $md->text($str);
		unset($md);

		$str = preg_replace_callback(';<a href="([\w_-]+?)">;i', function ($matches) {
			return sprintf('<a href="%s">', htmlspecialchars($this->resolveLink(htmlspecialchars_decode($matches[1]))));
		}, $str);

		return sprintf('<div class="web-content">%s</div>', $str);
	}
}







>
>
>
>
>
>
>
>










<
|


|

|

|


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
	/**
	 * Used by doc_md_to_html.php script
	 */
	public $toc = [];

	public function render(?string $content = null): string
	{
		if (null === $content && $this->file) {
			$content = $this->file->fetch();
		}

		if (empty($content)) {
			return $content;
		}

		$md = Markdown_Parser::instance();
		Markdown_Extensions::register($md);

		// Register Paheko extensions
		$ext = new Extensions($this);

		foreach ($ext->getList() as $name => $callback) {
			$md->registerExtension($name, $callback);
		}


		$content = $md->text($content);
		unset($md);

		$content = preg_replace_callback(';<a href="([\w_-]+?)">;i', function ($matches) {
			return sprintf('<a href="%s">', htmlspecialchars($this->resolveLink(htmlspecialchars_decode($matches[1]))));
		}, $content);

		return sprintf('<div class="web-content">%s</div>', $content);
	}
}

Modified src/include/lib/Garradin/Web/Router.php from [e588ab7eaa] to [3c49b52d72].

12
13
14
15
16
17
18


19
20
21
22
23
24
25
use Garradin\Config;
use Garradin\Plugins;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\UserTemplate\Modules;

use Garradin\Users\Session;



use const Garradin\{WWW_URI, ADMIN_URL, ROOT, HTTP_LOG_FILE, ENABLE_XSENDFILE};

class Router
{
	const DAV_ROUTES = [
		'dav',







>
>







12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use Garradin\Config;
use Garradin\Plugins;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\UserTemplate\Modules;

use Garradin\Users\Session;

use \KD2\HTML\Markdown;

use const Garradin\{WWW_URI, ADMIN_URL, ROOT, HTTP_LOG_FILE, ENABLE_XSENDFILE};

class Router
{
	const DAV_ROUTES = [
		'dav',
128
129
130
131
132
133
134




















135
136
137
138
139
140
141

			Plugins::fireSignal('http.request.file.after', compact('file', 'uri', 'session'));

			return;
		}

		Modules::route($uri);




















	}

	static public function log(string $message, ...$params)
	{
		if (!HTTP_LOG_FILE) {
			return;
		}







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







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

			Plugins::fireSignal('http.request.file.after', compact('file', 'uri', 'session'));

			return;
		}

		Modules::route($uri);
	}

	static public function markdown(string $text)
	{
		$md = new Markdown;
		header('Content-Type: text/html');

		$text = $md->text($text);
		$title = '';

		if (preg_match('!<h1[^>]*>(.*?)</h1>!is', $text, $match)) {
			$title = strip_tags($match[1]);
		}

		printf('<!DOCYPE html><head><title>%s</title>
			<style type="text/css">body { font-family: Verdana, sans-serif; padding: .5em; margin: 0; background: #fff; color: #000; }</style>
			<link rel="stylesheet" type="text/css" href="%scss.php" /></head><body>', $title, ADMIN_URL);

		echo $text;

	}

	static public function log(string $message, ...$params)
	{
		if (!HTTP_LOG_FILE) {
			return;
		}

Modified src/modules/carte_membre/_carte.html from [4ddefd6010] to [57aa1ec8a2].

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
{{if $module.config.photo}}
	{{:assign var="_photo" from="%s.0"|args:$module.config.photo}}
{{/if}}

<article{{if $module.config.logo == 1 || $_photo}} class="with-images"{{/if}}>
	{{if $module.config.logo == 1 && $config.files.logo}}
		<img src="{{$config.files.logo}}?150px" alt="" class="logo" />
	{{elseif $module.config.logo == 2 && $config.files.logo}}
		<img src="{{$config.files.logo}}?500px" alt="" class="bglogo" />
	{{/if}}

	{{if $_photo}}
		<img src="{{$_photo.url}}?150px" alt="" class="photo" />
	{{/if}}

	<div>
		{{if $module.config.fields|has:$number_field}}
			<div class="number"><span>N°</span><span>{{$_number}}</span></div>
		{{/if}}

		{{if $module.config.header}}
		<div class="content">
			{{$module.config.header|markdown|raw}}
		</div>
		{{/if}}

		<h1>{{$_name}}</h1>

		{{if $module.config.fields}}
		{{#select name, label FROM config_users_fields}}
			{{:assign var="fields.%s"|args:$name value=$label}}
		{{/select}}
		<ul class="fields">








			{{#foreach from=$module.config.fields item="key"}}
				{{:assign var="value" from=$key}}
				{{if $value && $key != $number_field}}

					<li>{{if $module.config.show_fields_names}}{{:assign var="label" from="fields.%s"|args:$key}}{{$label}}&nbsp;: {{/if}}{{$value}}</li>


				{{/if}}
			{{/foreach}}
		</ul>
		{{/if}}

		{{if $module.config.id_service}}
			{{#subscriptions user=$id id_service=$module.config.id_service active=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
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
{{if $module.config.photo}}
	{{:assign var="_photo" from="%s.0"|args:$module.config.photo}}
{{/if}}

<article{{if $module.config.logo == 1 || $_photo}} class="with-images"{{/if}}>
	{{if $module.config.logo == 1 && $config.files.logo}}
		<img src="{{$config.files.logo}}?150px" alt="" class="logo" />
	{{elseif $module.config.logo == 2 && $config.files.logo}}
		<img src="{{$config.files.logo}}?500px" alt="" class="bglogo" />
	{{/if}}

	{{if $_photo}}
		<img src="{{$_photo.url}}?crop-256px" alt="" class="photo" />
	{{/if}}

	<div>
		{{if $module.config.fields|has:$number_field}}
			<div class="number{{if $_photo}} with-photo{{/if}}{{if $module.config.logo == 1}} with-logo{{/if}}"><span>N°</span><span>{{$_number}}</span></div>
		{{/if}}

		{{if $module.config.header}}
		<div class="content">
			{{$module.config.header|markdown|raw}}
		</div>
		{{/if}}

		<h1>{{$_name}}</h1>

		{{if $module.config.fields}}
		{{#select name, label FROM config_users_fields}}
			{{:assign var="fields.%s"|args:$name value=$label}}
		{{/select}}
		<ul class="fields">
			{{if $module.config.fields|has:"_category"}}
				<li>
					{{if $module.config.show_fields_names}}Catégorie&nbsp;:{{/if}}
					{{#select name FROM users_categories WHERE id = {$id_category|intval};}}
					{{$name}}
					{{/select}}
				</li>
			{{/if}}
			{{#foreach from=$module.config.fields item="key"}}
				{{:assign var="value" from=$key}}
				{{if $value && $key != $number_field}}
					<li>
						{{if $module.config.show_fields_names}}{{:assign var="label" from="fields.%s"|args:$key}}{{$label}}&nbsp;:{{/if}}
						{{$value}}
					</li>
				{{/if}}
			{{/foreach}}
		</ul>
		{{/if}}

		{{if $module.config.id_service}}
			{{#subscriptions user=$id id_service=$module.config.id_service active=true}}

Modified src/modules/carte_membre/carte.css from [ab0902fbf3] to [76742380e2].

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

main {
	display: flex;
	flex-wrap: wrap;
	grid-gap: 10mm;
	align-items: center;
	justify-content: center;
	border-bottom: 1px solid red;
}

main.preview {
	background: #fff;
	padding: 2em;
}








<







14
15
16
17
18
19
20

21
22
23
24
25
26
27

main {
	display: flex;
	flex-wrap: wrap;
	grid-gap: 10mm;
	align-items: center;
	justify-content: center;

}

main.preview {
	background: #fff;
	padding: 2em;
}

64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
}
ul {
	list-style: none;
}

.logo {
	max-width: 100px;
	max-height: 150px;
	position: absolute;
	right: 0;
	bottom: 0;
}
.photo {
	max-width: 100px;
	max-height: 150px;
	position: absolute;
	right: 0;
	top: 0;
	z-index: 100;
}
.bglogo {
	position: absolute;







|






|







63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
}
ul {
	list-style: none;
}

.logo {
	max-width: 100px;
	max-height: 100px;
	position: absolute;
	right: 0;
	bottom: 0;
}
.photo {
	max-width: 100px;
	max-height: 100px;
	position: absolute;
	right: 0;
	top: 0;
	z-index: 100;
}
.bglogo {
	position: absolute;
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
}
h1 {
	font-size: 14pt;
	margin-bottom: .2em;
}
.number {
	position: absolute;
	right: calc(100px / 2);
	top: calc(55mm / 2 - 15mm / 2);
	bottom: 0;
	border-radius: 50%;
	height: 15mm;
	width: 15mm;
	background: #666;
	color: #fff;
	display: flex;
	align-items: center;
	flex-direction: column;
	justify-content: center;
	border: 3px solid #fff;
	z-index: 10000;
}








.number span:nth-child(1) {
	font-size: 8pt;
	margin-top: -.7em;
}
.number span:nth-child(2) {
	font-size: 14pt;
}
h3 {
	margin-top: .5em;
	font-weight: normal;
	font-size: 11pt;
}







|
|
<












>
>
>
>
>
>
>
>












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
}
h1 {
	font-size: 14pt;
	margin-bottom: .2em;
}
.number {
	position: absolute;
	right: 5px;
	top: 5px;

	border-radius: 50%;
	height: 15mm;
	width: 15mm;
	background: #666;
	color: #fff;
	display: flex;
	align-items: center;
	flex-direction: column;
	justify-content: center;
	border: 3px solid #fff;
	z-index: 10000;
}
.number.with-photo {
	bottom: 5px;
	top: auto;
}
.number.with-photo.with-logo {
	right: calc(100px / 2);
	top: calc(55mm / 2 - 15mm / 2);
}
.number span:nth-child(1) {
	font-size: 8pt;
	margin-top: -.7em;
}
.number span:nth-child(2) {
	font-size: 14pt;
}
h3 {
	margin-top: .5em;
	font-weight: normal;
	font-size: 11pt;
}

Modified src/modules/carte_membre/config.html from [66c0fe91c5] to [2c4270c273].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{{:admin_header title="Configuration des cartes de membres"}}

{{if $_POST.save}}
	{{:save key="config"
		validate_schema="./config.schema.json"
		header=$_POST.header|trim|or:null
		logo=$_POST.logo|intval
		fields=$_POST.fields|or:null
		photo=$_POST.photo|or:null
		id_service=$_POST.id_service|intval|or:null
		show_fields_names=$_POST.show_fields_names|boolval
	}}
	{{:http redirect="?ok=1"}}
{{/if}}

{{if $_GET.ok}}
	<p class="block confirm">Configuration enregistrée.</p>
{{/if}}

<form method="post" action="">



|









|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{{:admin_header title="Configuration des cartes de membres"}}

{{#form on="save"}}
	{{:save key="config"
		validate_schema="./config.schema.json"
		header=$_POST.header|trim|or:null
		logo=$_POST.logo|intval
		fields=$_POST.fields|or:null
		photo=$_POST.photo|or:null
		id_service=$_POST.id_service|intval|or:null
		show_fields_names=$_POST.show_fields_names|boolval
	}}
	{{:redirect to="?ok=1"}}
{{/form}}

{{if $_GET.ok}}
	<p class="block confirm">Configuration enregistrée.</p>
{{/if}}

<form method="post" action="">

30
31
32
33
34
35
36







37
38
39
40
41
42
43
		<dt><label for="f_logo_0">Affichage du logo de l'association</label></dt>
		{{:input type="radio" name="logo" value=0 source=$module.config label="Ne pas afficher" default=1}}
		{{:input type="radio" name="logo" value=1 source=$module.config label="En petit en bas à droite" default=1}}
		{{:input type="radio" name="logo" value=2 source=$module.config label="En filigrane (fond)"}}

		<dt>Champs des fiches membre à afficher sur la carte de membre</dt>
		<dd class="help"><em>(Le nom du membre est toujours affiché.)</em></dd>








		{{#select * FROM config_users_fields WHERE type NOT IN ('file', 'password', 'generated', 'multiple', 'checkbox', 'date', 'datetime') AND system & (1 << 4) = 0 ORDER BY sort_order}}
			{{if $module.config.fields|has:$name}}
				{{:assign var="checked" value=$name}}
			{{else}}
				{{:assign var="checked" value=null}}
			{{/if}}







>
>
>
>
>
>
>







30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
		<dt><label for="f_logo_0">Affichage du logo de l'association</label></dt>
		{{:input type="radio" name="logo" value=0 source=$module.config label="Ne pas afficher" default=1}}
		{{:input type="radio" name="logo" value=1 source=$module.config label="En petit en bas à droite" default=1}}
		{{:input type="radio" name="logo" value=2 source=$module.config label="En filigrane (fond)"}}

		<dt>Champs des fiches membre à afficher sur la carte de membre</dt>
		<dd class="help"><em>(Le nom du membre est toujours affiché.)</em></dd>
		{{if $module.config.fields|has:"_category"}}
			{{:assign var="checked" value="_category"}}
		{{else}}
			{{:assign var="checked" value=null}}
		{{/if}}

		{{:input type="checkbox" name="fields[]" value="_category" label="Catégorie" default=$checked}}

		{{#select * FROM config_users_fields WHERE type NOT IN ('file', 'password', 'generated', 'multiple', 'checkbox', 'date', 'datetime') AND system & (1 << 4) = 0 ORDER BY sort_order}}
			{{if $module.config.fields|has:$name}}
				{{:assign var="checked" value=$name}}
			{{else}}
				{{:assign var="checked" value=null}}
			{{/if}}

Added src/modules/carte_membre/snippets/my_details.html version [c8b74bdcfe].



>
1
{{:include file="./user_details.html"}}

Modified src/modules/carte_membre/snippets/user_details.html from [63b08612f1] to [0ba9219156].

1
2
3
4
5
6
7
8
9
10
11
12
<h2 class="ruler">{{$module.label}}</h2>
<div style="display: flex; justify-content: center; gap: 30px">
	<div style="width: 85mm; height: 55mm; box-shadow: 2px 2px 10px #000; border: 1px solid #000; overflow: hidden;" >
		<iframe src="{{"%scarte.html?id=%d&mode=embed"|args:$module.url:$user.id}}" scrolling="no" frameborder="0" width="100%" height="100%"></iframe>
	</div>

	<p style="display: flex; flex-direction: column;">
		{{:linkbutton href="%scarte.html?id=%d&mode=preview"|args:$module.url:$user.id target="_dialog" label="Prévisualiser" shape="eye"}}
		{{:linkbutton href="%scarte.html?id=%d&print=pdf&mode=print"|args:$module.url:$user.id label="Télécharger en PDF" shape="download"}}
		{{:linkbutton href="%scarte.html?id=%d&print=yes&mode=print"|args:$module.url:$user.id target="_blank" label="Imprimer" shape="print"}}
	</p>
</div>







<




1
2
3
4
5
6
7

8
9
10
11
<h2 class="ruler">{{$module.label}}</h2>
<div style="display: flex; justify-content: center; gap: 30px">
	<div style="width: 85mm; height: 55mm; box-shadow: 2px 2px 10px #000; border: 1px solid #000; overflow: hidden;" >
		<iframe src="{{"%scarte.html?id=%d&mode=embed"|args:$module.url:$user.id}}" scrolling="no" frameborder="0" width="100%" height="100%"></iframe>
	</div>

	<p style="display: flex; flex-direction: column;">

		{{:linkbutton href="%scarte.html?id=%d&print=pdf&mode=print"|args:$module.url:$user.id label="Télécharger en PDF" shape="download"}}
		{{:linkbutton href="%scarte.html?id=%d&print=yes&mode=print"|args:$module.url:$user.id target="_blank" label="Imprimer" shape="print"}}
	</p>
</div>

Modified src/modules/ouvertures/config.js from [19286da6a6] to [1cf866bf6c].

1
2
3
4
5
6
7
8
9
10
11
12
if (!open_data) {
	open_data = {
		'closed': [{'close_day': '25', 'close_month': 'december', 'reopen_day': '2', 'reopen_month': 'january'}],
		'open': [{
			'frequency': '',
			'day': 'saturday',
			'open': '15:00',
			'close': '19:00'
		}]
	};
}





|







1
2
3
4
5
6
7
8
9
10
11
12
if (!open_data) {
	open_data = {
		'closed': [{'close_day': '25', 'close_month': 'december', 'reopen_day': '2', 'reopen_month': 'january'}],
		'open': [{
			'frequency': 'this',
			'day': 'saturday',
			'open': '15:00',
			'close': '19:00'
		}]
	};
}

Name change from src/modules/receipt/recu_paiement/icon.svg to src/modules/receipt/icon.svg.

Modified src/modules/receipt/index.html from [98428c1160] to [0558d5863a].












1
2



3
4
5
6
7
8
9
10











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



{{if !$_GET.id}}

	{{:admin_header title="Reçu de paiement" current="acc"}}

	<p class="error block">Aucun numéro d'écriture n'a été fourni.</p>

	<form method="get" action="">
		<fieldset>
>
>
>
>
>
>
>
>
>
>
>
|
|
>
>
>
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{{if $_GET.me}}
	{{#transactions user=$logged_user.id}}
		{{if $id == $_GET.me}}
			{{:assign id=$id}}
			{{:break}}
		{{/if}}
	{{/transactions}}
	{{if !$id}}
		{{:error message="Ce reçu n'existe pas"}}
	{{/if}}
{{else}}
	{{#restrict block=true section="accounting" level="read"}}
	{{/restrict}}
	{{:assign id=$_GET.id}}
{{/if}}

{{if !$id}}

	{{:admin_header title="Reçu de paiement" current="acc"}}

	<p class="error block">Aucun numéro d'écriture n'a été fourni.</p>

	<form method="get" action="">
		<fieldset>
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
			{{:button type="submit" name="save" label="Voir le reçu (PDF)" shape="right" class="main"}}
		</p>
	</form>

	{{:admin_footer}}

{{else}}
	{{#transactions id=$_GET.id}}
		{{:include file="./_header.html" page_size="A5" title="Reçu de paiement %d - %s"|args:$id:$users_names}}

			<h1>Reçu de paiement</h1>

			<h4>Référence n°{{$id}} — {{$date|date_short}}</h4>

			<p>L'association «&nbsp;{{$config.nom_asso}}&nbsp;» atteste avoir reçu de la part de&nbsp;:</p>







|







32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
			{{:button type="submit" name="save" label="Voir le reçu (PDF)" shape="right" class="main"}}
		</p>
	</form>

	{{:admin_footer}}

{{else}}
	{{#transactions id=$id}}
		{{:include file="./_header.html" page_size="A5" title="Reçu de paiement %d - %s"|args:$id:$users_names}}

			<h1>Reçu de paiement</h1>

			<h4>Référence n°{{$id}} — {{$date|date_short}}</h4>

			<p>L'association «&nbsp;{{$config.nom_asso}}&nbsp;» atteste avoir reçu de la part de&nbsp;:</p>

Modified src/modules/receipt/module.ini from [718766f439] to [3dc086e733].

1
2
3
4
5
name="Reçu de paiement"
description="Reçu de paiement simple"
author="Paheko"
author_url="https://paheko.cloud/"
system=1

|



1
2
3
4
5
name="Reçu de paiement"
description="Reçu de paiement simple. Le reçu sera accessible sous chaque écriture comptable, et dans la page 'Mes activités' de chaque membre."
author="Paheko"
author_url="https://paheko.cloud/"
system=1

Deleted src/modules/receipt/recu_paiement/index.html version [d1084f817e].

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
{{#restrict block=true section="accounting" level="read"}}
{{/restrict}}
{{if !$_GET.id}}

	{{:admin_header title="Reçu de paiement" current="acc"}}

	<p class="error block">Aucun numéro d'écriture n'a été fourni.</p>

	<form method="get" action="">
		<fieldset>
			<legend>Créer un reçu de paiement</legend>
			<dl>
				{{:input type="number" name="id" label="Numéro d'écriture" required=true}}
			</dl>
		</fieldset>

		<p class="submit">
			{{:button type="submit" name="save" label="Voir le reçu (PDF)" shape="right" class="main"}}
		</p>
	</form>

	{{:admin_footer}}

{{else}}
	{{#transactions id=$_GET.id}}
		{{:include file="modules/_header.html" page_size="A5" title="Reçu de paiement %d - %s"|args:$id:$users_names}}

			<h1>Reçu de paiement</h1>

			<h4>Référence n°{{$id}} — {{$date|date_short}}</h4>

			<p>L'association «&nbsp;{{$config.nom_asso}}&nbsp;» atteste avoir reçu de la part de&nbsp;:</p>

			<h3>{{$users_names}}</h3>

			<p>un paiement d'un montant de&nbsp;:</p>

			<h2>{{$credit|raw|money_currency:false}}</h2>

			<p>pour le motif suivant&nbsp;:</p>

			<h3>{{$label}}</h3>

			<p>
				<em>Ce reçu n'est pas un reçu fiscal et ne donne pas droit à une réduction d'impôt au bénéfice des dispositions des articles 200, 238 bis et 885-0 V bis A du code général des impôts.</em>
			</p>

			{{:signature}}

		{{:include file="modules/_footer.html"}}
	{{else}}
		{{:error message="Le numéro d'écriture fourni n'existe pas."}}
	{{/transactions}}
{{/if}}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































Deleted src/modules/receipt/recu_paiement/module.ini version [9fd092515c].

1
2
3
4
name="Reçu de paiement"
description="Reçu de paiement, pour les écritures liées à un membre"
author="Paheko"
author_url="https://paheko.cloud/"
<
<
<
<








Deleted src/modules/receipt/recu_paiement/snippets/transaction_details.html version [669dfa2423].

1
2
3
4
5
6
7
8
{{if $related_users|count >= 1}}
	<h2 class="ruler">{{$module.label}}</h2>
	<p class="actions-center">
		{{:linkbutton href="%s?id=%d"|args:$module.url:$transaction.id target="_dialog" label="Prévisualiser" shape="eye"}}
		{{:linkbutton href="%s?id=%d&print=pdf"|args:$module.url:$transaction.id label="Télécharger en PDF" shape="download"}}
		{{:linkbutton href="%s?id=%d&print=yes"|args:$module.url:$transaction.id target="_blank" label="Imprimer" shape="print"}}
	</p>
{{/if}}
<
<
<
<
<
<
<
<
















Added src/modules/receipt/snippets/my_services.html version [4a0e099d84].

























>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
<h2 class="ruler">Reçus de paiements</h2>

<table class="list">
{{#transactions user=$logged_user.id order="date DESC" debit_codes="5%"}}
	<tr>
		<th>{{$date|date_short}}</th>
		<td>{{$label}}</td>
		<td>{{$debit|money_currency}}</td>
		<td class="actions">{{:linkbutton href="%s?me=%d&print=pdf"|args:$module.url:$id shape="download" label="Télécharger le reçu (PDF)"}}</td>
	</tr>
{{/transactions}}
</table>

Modified src/modules/recus_fiscaux/nouveau.html from [8bd4466c59] to [554af72263].

317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332

	let p = $('[name=preview]')[0];

	p.addEventListener('click', (e) => {
		let form = e.target.form;
		form.action = "previsualiser.html";
		form.target = "dialog";
		g.openFrameDialog('about:blank', 'auto');
		form.submit();
		form.action = "";
		form.target = "";
	});
	</script>
{{/if}}

{{:admin_footer}}







|








317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332

	let p = $('[name=preview]')[0];

	p.addEventListener('click', (e) => {
		let form = e.target.form;
		form.action = "previsualiser.html";
		form.target = "dialog";
		g.openFrameDialog('about:blank', {height: 'auto'});
		form.submit();
		form.action = "";
		form.target = "";
	});
	</script>
{{/if}}

{{:admin_footer}}

Modified src/modules/remise_cheques/snippets/transaction_details.html from [103614231d] to [12502e2b48].

1
2
3
4
5
6
7
{{if $module.config.accounts === null}}
	{{:assign var="module.config.accounts[]" value='5112'}}
{{/if}}

{{* FIXME: ne proposer le bordereau que pour les écritures de dépot *}}

{{:include file="/receipt/snippets/transaction_details.html"}}

|





1
2
3
4
5
6
7
{{if $module.config.accounts === null}}
	{{:assign var="module.config.accounts" value='5112'}}
{{/if}}

{{* FIXME: ne proposer le bordereau que pour les écritures de dépot *}}

{{:include file="/receipt/snippets/transaction_details.html"}}

Modified src/modules/web/content.css from [5103742927] to [6a0e1ef717].

1
2
3
4
5
6
7
8
9
10





11
12
13
14
15
16
17
/**
 * Ce fichier contient les styles CSS qui s'appliquent au contenu des articles et catégorie,
 * que ce soit sur le site public ou dans la prévisualisation de l'administration.
 *
 * Généralement il n'est pas nécessaire de le modifier.
 */

.protected-contact::before {
    content: attr(data-a) "\0040" attr(data-b) "." attr(data-c);
}






.web-content p, .web-content h1, .web-content h2, .web-content h3, .web-content h4, .web-content h5, .web-content h6,
.web-content ul, .web-content ol, .web-content table, .web-content blockquote, .web-content pre {
    margin-bottom: 1rem;
}

.web-content ul, .web-content ol, .web-content dd {










>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * Ce fichier contient les styles CSS qui s'appliquent au contenu des articles et catégorie,
 * que ce soit sur le site public ou dans la prévisualisation de l'administration.
 *
 * Généralement il n'est pas nécessaire de le modifier.
 */

.protected-contact::before {
    content: attr(data-a) "\0040" attr(data-b) "." attr(data-c);
}

.web-content figure {
    margin: 0;
    padding: 0;
}

.web-content p, .web-content h1, .web-content h2, .web-content h3, .web-content h4, .web-content h5, .web-content h6,
.web-content ul, .web-content ol, .web-content table, .web-content blockquote, .web-content pre {
    margin-bottom: 1rem;
}

.web-content ul, .web-content ol, .web-content dd {
251
252
253
254
255
256
257





258
259
260
261
262
263
264
    right: 0;
    bottom: 0;
}

.web-content figure.video a:hover img {
    opacity: 0.7;
}






.web-content figure.image figcaption {
    font-style: italic;
    color: #666;
    margin-top: 2pt;
}








>
>
>
>
>







256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
    right: 0;
    bottom: 0;
}

.web-content figure.video a:hover img {
    opacity: 0.7;
}

.web-content video {
    margin: .8em auto;
    display: block;
}

.web-content figure.image figcaption {
    font-style: italic;
    color: #666;
    margin-top: 2pt;
}

288
289
290
291
292
293
294






































































































295
296
297
298
299
300
301
.web-content a.internal-image {
    cursor: zoom-in;
}

.web-content img, .web-content object {
    max-width: 100%;
}







































































































.imageBrowser {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;







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







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
.web-content a.internal-image {
    cursor: zoom-in;
}

.web-content img, .web-content object {
    max-width: 100%;
}

.web-content .gallery, .web-content .slideshow {
    margin: 1em;
}

.web-content .gallery .images {
    display: flex;
    flex-wrap: wrap;
    grid-gap: 10px;
    justify-content: center;
}

.web-content .gallery figure {
    flex: 1 1 auto;
    max-height: 180px;
}

.web-content .gallery img {
    object-fit: cover;
    width: 100%;
    height: 100%;
    vertical-align: middle;
}

.web-content .gallery .images::after {
    content: "";
    flex-grow: 1;
    width: 1px;
    display: block;
}

.web-content .slideshow {
    position: relative;
    width: calc(500px + 5em);
    margin: 1em auto;
}

.web-content .slideshow .images {
    display: block;
    position: relative;
    overflow: hidden;
    margin: 0 auto;
    height: 400px;
    width: 500px;
    white-space: nowrap;
}

.web-content .slideshow .index {
    display: flex;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
}

.web-content .slideshow button {
    border: none;
    border-radius: 50%;
    background: rgba(0, 0, 0, .2);
    color: #000;
    font-weight: bold;
    font-size: 12pt;
    min-width: 3ch;
    height: 3ch;
    margin: .2em;
    cursor: pointer;
    text-align: center;
}

.web-content .slideshow button:hover {
    color: darkred;
    background: rgba(255, 255, 255, .2);
    box-shadow: 0px 0px 5px #000;
}

.web-content .slideshow button.current {
    background: rgba(255, 165, 0, .3);
    box-shadow: 0px 0px 5px #999;
}

.web-content .slideshow .nav {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 400px;
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.web-content .slideshow figure, .web-content .slideshow a {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
}

.web-content .slideshow img {
    max-width: 100%;
    max-height: 95%;
}

.imageBrowser {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;

Modified src/templates/_foot.tpl from [9203feaf66] to [fd3f94279f].

1
2


3

4
5
6
7
8
9
10
11

12
13
14
15


16

17

18
19
20
</main>



<script type="text/javascript" defer="defer">

var keep_session_url = "{$admin_url}login.php?keepSessionAlive&";
{literal}
(function () {
	function refreshSession()
	{
		var i = new Image(1, 1);
		var d = +new Date;
		i.src = keep_session_url + d;

	}

	window.setInterval(refreshSession, 10 * 60 * 1000);
} ());


{/literal}

</script>


</body>
</html>


>
>

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

>



1
2
3
4
5
6
7


8
9
10
11
12
13
14
15


16
17
18
19
20
21
22
23
24
</main>

{if $is_logged}
{* Keep session alive by requesting renewal every before it expires *}
<script type="text/javascript" defer="defer">
(function () {ldelim}
	var keep_session_url = "{$admin_url}login.php?keepSessionAlive&";


	var session_gc = <?=intval(ini_get('session.gc_maxlifetime'))?>;

	window.setInterval(
		() => fetch(g.admin_url + 'login.php?keepSessionAlive&' + (+new Date)),
		(session_gc - 5*60)*1000
	);

	{if !LOCAL_LOGIN && $config.auto_logout && !$session->hasRememberMeCookie()}


		g.auto_logout = {$config.auto_logout};
		g.script('scripts/auto_logout.js');
	{/if}
{rdelim})();
</script>
{/if}

</body>
</html>

Modified src/templates/acc/projects/_list.tpl from [e8e8a942d5] to [9793637ed8].

1
2
3
4
5
6
7
8

9
10
11
12
13
14
15
	<table class="list projects">
		{if !empty($caption)}<caption>{$caption}</caption>{/if}
		<thead>
			<tr>
				<td>Projet</td>
				<td></td>
				<td class="money">Charges</td>
				<td class="money">Produits</td>

				<td class="money">Débits</td>
				<td class="money">Crédits</td>
				<td class="money">Solde</td>
			</tr>
		</thead>
		{foreach from=$list item="parent"}
			<tbody{if $parent.archived} class="archived"{/if}>








>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
	<table class="list projects">
		{if !empty($caption)}<caption>{$caption}</caption>{/if}
		<thead>
			<tr>
				<td>Projet</td>
				<td></td>
				<td class="money">Charges</td>
				<td class="money">Produits</td>
				<td class="money">Résultat</td>
				<td class="money">Débits</td>
				<td class="money">Crédits</td>
				<td class="money">Solde</td>
			</tr>
		</thead>
		{foreach from=$list item="parent"}
			<tbody{if $parent.archived} class="archived"{/if}>
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
					<p class="actions">
						{linkbutton href="!acc/reports/ledger.php?projects_only=1&year=%d"|args:$parent.id_year label="Grand livre analytique"}
					</p>
					{/if}
					</th>
				</tr>
			{foreach from=$parent.items item="item"}

				<tr class="{if $item.label == 'Total'}total{/if} {if $item.archived}archived{/if}">
					<th>{$item.label}{if $item.archived} <em>(archivé)</em>{/if}</th>
					<td>
					{if !$table_export}
					<span class="noprint">
						<a href="{$admin_url}acc/reports/graphs.php?project={$item.id_project}&amp;year={$item.id_year}">Graphiques</a>
						| <a href="{$admin_url}acc/reports/trial_balance.php?project={$item.id_project}&amp;year={$item.id_year}">Balance générale</a>
						| <a href="{$admin_url}acc/reports/journal.php?project={$item.id_project}&amp;year={$item.id_year}">Journal général</a>
						| <a href="{$admin_url}acc/reports/ledger.php?project={$item.id_project}&amp;year={$item.id_year}">Grand livre</a>
						| <a href="{$admin_url}acc/reports/statement.php?project={$item.id_project}&amp;year={$item.id_year}">Compte de résultat</a>
						| <a href="{$admin_url}acc/reports/balance_sheet.php?project={$item.id_project}&amp;year={$item.id_year}">Bilan</a>
					</span>
					{/if}
					</td>
					<td class="money">{$item.sum_expense|raw|money}</td>
					<td class="money">{$item.sum_revenue|raw|money}</td>

					<td class="money">{$item.debit|raw|money:false}</td>
					<td class="money">{$item.credit|raw|money:false}</td>
					<td class="money">{$item.sum|raw|money:false}</td>
				</tr>
			{/foreach}
			</tbody>
		{/foreach}
	</table>







>
















>








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
					<p class="actions">
						{linkbutton href="!acc/reports/ledger.php?projects_only=1&year=%d"|args:$parent.id_year label="Grand livre analytique"}
					</p>
					{/if}
					</th>
				</tr>
			{foreach from=$parent.items item="item"}
				<?php $result = $item->sum_revenue - $item->sum_expense; ?>
				<tr class="{if $item.label == 'Total'}total{/if} {if $item.archived}archived{/if}">
					<th>{$item.label}{if $item.archived} <em>(archivé)</em>{/if}</th>
					<td>
					{if !$table_export}
					<span class="noprint">
						<a href="{$admin_url}acc/reports/graphs.php?project={$item.id_project}&amp;year={$item.id_year}">Graphiques</a>
						| <a href="{$admin_url}acc/reports/trial_balance.php?project={$item.id_project}&amp;year={$item.id_year}">Balance générale</a>
						| <a href="{$admin_url}acc/reports/journal.php?project={$item.id_project}&amp;year={$item.id_year}">Journal général</a>
						| <a href="{$admin_url}acc/reports/ledger.php?project={$item.id_project}&amp;year={$item.id_year}">Grand livre</a>
						| <a href="{$admin_url}acc/reports/statement.php?project={$item.id_project}&amp;year={$item.id_year}">Compte de résultat</a>
						| <a href="{$admin_url}acc/reports/balance_sheet.php?project={$item.id_project}&amp;year={$item.id_year}">Bilan</a>
					</span>
					{/if}
					</td>
					<td class="money">{$item.sum_expense|raw|money}</td>
					<td class="money">{$item.sum_revenue|raw|money}</td>
					<td class="money">{$result|raw|money:true:true}</td>
					<td class="money">{$item.debit|raw|money:false}</td>
					<td class="money">{$item.credit|raw|money:false}</td>
					<td class="money">{$item.sum|raw|money:false}</td>
				</tr>
			{/foreach}
			</tbody>
		{/foreach}
	</table>

Modified src/templates/acc/search.tpl from [aaa89dd2e8] to [b5d5fc0074].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{include file="_head.tpl" title="Recherche" current="acc" custom_js=['lib/query_builder.min.js']}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$self_url}">Recherche</a></li>
		<li><a href="saved_searches.php">Recherches enregistrées</a></li>
	</ul>
</nav>

<form method="post" action="{$self_url}" id="queryBuilderForm" data-disable-progress="1">

{include file="common/search/advanced.tpl"}

{if $list !== null}
	<p class="help">{$list->count()} écritures trouvées pour cette recherche.</p>

	{if $list->count() > 0}









|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{include file="_head.tpl" title="Recherche" current="acc" custom_js=['lib/query_builder.min.js']}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$self_url}">Recherche</a></li>
		<li><a href="saved_searches.php">Recherches enregistrées</a></li>
	</ul>
</nav>

<form method="post" action="{$self_url_no_qs}" id="queryBuilderForm" data-disable-progress="1">

{include file="common/search/advanced.tpl"}

{if $list !== null}
	<p class="help">{$list->count()} écritures trouvées pour cette recherche.</p>

	{if $list->count() > 0}

Modified src/templates/acc/transactions/creator.tpl from [7581f116ec] to [8d3d1b3556].

1




2
3
4
5
{include file="_head.tpl" title="Écritures crées par %s"|args:$transaction_creator.identite current="acc/accounts"}





{include file="acc/reports/_journal.tpl"}

{include file="_foot.tpl"}
|
>
>
>
>




1
2
3
4
5
6
7
8
9
{include file="_head.tpl" title="Écritures créées par %s"|args:$transaction_creator->name() current="acc/accounts"}

<p class="help">
	De la plus récente à la plus ancienne.
</p>

{include file="acc/reports/_journal.tpl"}

{include file="_foot.tpl"}

Modified src/templates/acc/transactions/details.tpl from [59fa00da2b] to [690984355c].

106
107
108
109
110
111
112
113
114
115
116
117
118
119
120

		{if $transaction.type != $transaction::TYPE_ADVANCED}
			<dt>Référence de paiement</dt>
			<dd>{if $ref = $transaction->getPaymentReference()}<mark><a href="{$admin_url}payments.php?id={$transaction->reference|intval}">{$ref}</a></mark>{else}—{/if}</dd>
			<dt>Projet</dt>
			<dd>
			{if $project = $transaction->getProject()}
				<mark class="variant-a">{link href="!acc/reports/statement.php?project=%d"|args:$project.id label=$project.name}</mark>
			{else}
				—
			{/if}
		{/if}

		<dt>Exercice</dt>
		<dd>







|







106
107
108
109
110
111
112
113
114
115
116
117
118
119
120

		{if $transaction.type != $transaction::TYPE_ADVANCED}
			<dt>Référence de paiement</dt>
			<dd>{if $ref = $transaction->getPaymentReference()}<mark><a href="{$admin_url}payments.php?id={$transaction->reference|intval}">{$ref}</a></mark>{else}—{/if}</dd>
			<dt>Projet</dt>
			<dd>
			{if $project = $transaction->getProject()}
				<mark class="variant-a">{link href="!acc/reports/statement.php?project=%d&year=%d"|args:$project.id:$transaction.id_year label=$project.name}</mark>
			{else}
				—
			{/if}
		{/if}

		<dt>Exercice</dt>
		<dd>
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
					<td>{$line.account_label}</td>
					<td class="money">{if $line.debit}{$line.debit|escape|money}{/if}</td>
					<td class="money">{if $line.credit}{$line.credit|escape|money}{/if}</td>
					<td>{$line.label}</td>
					<td>{$line.reference}</td>
					<td>
						{if $line.id_project}
							<a href="{$admin_url}acc/reports/statement.php?project={$line.id_project}">{$line.project_name}</a>
						{/if}
					</td>
				</tr>
				{/foreach}
			</tbody>
		</table>
	{/if}







|







206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
					<td>{$line.account_label}</td>
					<td class="money">{if $line.debit}{$line.debit|escape|money}{/if}</td>
					<td class="money">{if $line.credit}{$line.credit|escape|money}{/if}</td>
					<td>{$line.label}</td>
					<td>{$line.reference}</td>
					<td>
						{if $line.id_project}
							{link href="!acc/reports/statement.php?project=%d&year=%d"|args:$line.id_project:$transaction.id_year label=$line.project_name}
						{/if}
					</td>
				</tr>
				{/foreach}
			</tbody>
		</table>
	{/if}

Modified src/templates/acc/transactions/pending.tpl from [362b758a05] to [6fdc7ef42c].

1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Dettes et créances non réglées sur les exercices clos" current="acc/simple"}

<nav class="tabs">
	<aside>
		{exportmenu}
		{linkbutton shape="search" href="!acc/search.php" label="Recherche"}
	</aside>
</nav>
|







1
2
3
4
5
6
7
8
{include file="_head.tpl" title="Dettes et créances non réglées sur les exercices clos" current="acc/simple"}

<nav class="tabs">
	<aside>
		{exportmenu}
		{linkbutton shape="search" href="!acc/search.php" label="Recherche"}
	</aside>
</nav>
35
36
37
38
39
40
41
42
43
44
45
			</tr>
		{/foreach}
		</tbody>
	</table>

	</form>

	{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}
{/if}

{include file="admin/_foot.tpl"}







|


|
35
36
37
38
39
40
41
42
43
44
45
			</tr>
		{/foreach}
		</tbody>
	</table>

	</form>

	{$list->getHTMLPagination()|raw}
{/if}

{include file="_foot.tpl"}

Modified src/templates/acc/transactions/user.tpl from [9299a647a1] to [6a563c7bfe].

1
2
3
4
5
6
7




8
9
10
11
12
13
14
{include file="_head.tpl" title="Écritures liées à %s"|args:$transaction_user.identite current="acc/accounts"}

{if !$dialog}
<p>
	{linkbutton href="!users/details.php?id=%d"|args:$transaction_user.id label="Retour à la fiche membre" shape="user"}
</p>
{/if}





{include file="acc/reports/_journal.tpl"}

<h2 class="ruler">Solde des comptes</h2>

<form method="get" action="{$self_url_no_qs}">
	<fieldset>
|






>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{include file="_head.tpl" title="Écritures liées à %s"|args:$transaction_user->name() current="acc/accounts"}

{if !$dialog}
<p>
	{linkbutton href="!users/details.php?id=%d"|args:$transaction_user.id label="Retour à la fiche membre" shape="user"}
</p>
{/if}

<p class="help">
	De la plus récente à la plus ancienne.
</p>

{include file="acc/reports/_journal.tpl"}

<h2 class="ruler">Solde des comptes</h2>

<form method="get" action="{$self_url_no_qs}">
	<fieldset>

Modified src/templates/config/users/index.tpl from [0ebae744b8] to [3cc877ef94].

34
35
36
37
38
39
40











41
42
43
44
45
46
47
48
49
50
51
52
		<p class="help">
			Les actions de création, modification ou suppression dans la base de données peuvent être enregistrées pour chaque membre.
			Cela permet de garder une trace, pour savoir qui à fait quoi.
		</p>

		<dl>
			{input type="select" options=$log_retention_options source=$config name="log_retention" required=true label="Durée de conservation des journaux d'activité" help="Après ce délai, les journaux seront supprimés."}











		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>


{include file="_foot.tpl"}







>
>
>
>
>
>
>
>
>
>
>












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
		<p class="help">
			Les actions de création, modification ou suppression dans la base de données peuvent être enregistrées pour chaque membre.
			Cela permet de garder une trace, pour savoir qui à fait quoi.
		</p>

		<dl>
			{input type="select" options=$log_retention_options source=$config name="log_retention" required=true label="Durée de conservation des journaux d'activité" help="Après ce délai, les journaux seront supprimés."}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Sécurité</legend>
		<dl>
			{input type="select" name="auto_logout" source=$config required=true label="Déconnecter automatiquement les membres inactifs après…" options=$logout_delay_options}
			<dd class="help">
				Permet de déconnecter automatiquement un membre s'il garde la gestion de l'association ouverte, sans interagir.<br />
				Utile par exemple pour éviter de laisser une session ouverte sur un ordinateur partagé.<br />Ce réglage ne s'applique pas aux membres ayant coché la case "Rester connecté⋅e".
			</dd>
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>


{include file="_foot.tpl"}

Modified src/templates/docs/index.tpl from [7981801dbc] to [53e858fef5].

18
19
20
21
22
23
24

25
26
27
28

29
30
31
32
33
34
35

<nav class="tabs">
	<aside>
		<form method="post" action="search.php" target="_dialog" data-disable-progress="1">
			{input type="text" name="q" size=25 placeholder="Rechercher un document" title="Rechercher dans les documents"}
			{button shape="search" type="submit" title="Rechercher"}
		</form>

	{if $gallery}
		{linkbutton shape="menu" label="Afficher en liste" href="?path=%s&gallery=0"|args:$parent_path_uri}
	{else}
		{linkbutton shape="gallery" label="Afficher en galerie" href="?path=%s&gallery=1"|args:$parent_path_uri}

	{/if}
	{if $parent->canCreateDirHere() || $parent->canCreateHere()}
		{linkmenu label="Ajouter…" shape="plus" right=true}
			{if $parent->canCreateHere()}
				{linkbutton shape="upload" label="Depuis mon ordinateur" target="_dialog" href="!common/files/upload.php?p=%s"|args:$parent_path_uri}
			{if $parent->canCreateDirHere()}
				{linkbutton shape="folder" label="Répertoire" target="_dialog" href="!docs/new_dir.php?path=%s"|args:$parent_path_uri}







>
|
|
|
|
>







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

<nav class="tabs">
	<aside>
		<form method="post" action="search.php" target="_dialog" data-disable-progress="1">
			{input type="text" name="q" size=25 placeholder="Rechercher un document" title="Rechercher dans les documents"}
			{button shape="search" type="submit" title="Rechercher"}
		</form>
	{if !$list || !($list instanceof \Garradin\DynamicList)}
		{if $gallery}
			{linkbutton shape="menu" label="Afficher en liste" href="?path=%s&gallery=0"|args:$parent_path_uri}
		{else}
			{linkbutton shape="gallery" label="Afficher en galerie" href="?path=%s&gallery=1"|args:$parent_path_uri}
		{/if}
	{/if}
	{if $parent->canCreateDirHere() || $parent->canCreateHere()}
		{linkmenu label="Ajouter…" shape="plus" right=true}
			{if $parent->canCreateHere()}
				{linkbutton shape="upload" label="Depuis mon ordinateur" target="_dialog" href="!common/files/upload.php?p=%s"|args:$parent_path_uri}
			{if $parent->canCreateDirHere()}
				{linkbutton shape="folder" label="Répertoire" target="_dialog" href="!docs/new_dir.php?path=%s"|args:$parent_path_uri}

Modified src/templates/me/index.tpl from [78eaa6c657] to [be51edf18c].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

















16
17
18
19
20
21
22
23
24
{include file="_head.tpl" title="Mes informations personnelles" current="me"}

{include file="./_nav.tpl" current="me"}

{if $ok !== null}
<p class="confirm block">
	Les modifications ont bien été enregistrées.
</p>
{/if}


<dl class="describe">
	<dd>{linkbutton href="!me/edit.php" label="Modifier mes informations" shape="edit"}</dd>
</dl>


















{include file="users/_details.tpl" data=$user show_message_button=false mode="user"}

<dl class="describe">
	<dd>{linkbutton href="!me/export.php" label="Télécharger toutes les données détenues sur moi" shape="download"}</dd>
</dl>

{$snippets|raw}

{include file="_foot.tpl"}










<




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









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
{include file="_head.tpl" title="Mes informations personnelles" current="me"}

{include file="./_nav.tpl" current="me"}

{if $ok !== null}
<p class="confirm block">
	Les modifications ont bien été enregistrées.
</p>
{/if}


<dl class="describe">
	<dd>{linkbutton href="!me/edit.php" label="Modifier mes informations" shape="edit"}</dd>
</dl>


{if $user->isChild() || count($children)}
<aside class="describe">
	<dl class="describe">
		{if $user->isChild()}
			<dt>Membre responsable</dt>
			<dd>{$parent_name}</dd>
		{elseif count($children)}
			<dt>Membres rattachés</dt>
			{foreach from=$children item="child"}
				<dd>{$child.name}</dd>
			{/foreach}
		{/if}
	</dl>
</aside>
{/if}

{include file="users/_details.tpl" data=$user show_message_button=false mode="user"}

<dl class="describe">
	<dd>{linkbutton href="!me/export.php" label="Télécharger toutes les données détenues sur moi" shape="download"}</dd>
</dl>

{$snippets|raw}

{include file="_foot.tpl"}

Modified src/templates/me/services.tpl from [70f530026a] to [f15aece554].

72
73
74
75
76
77
78
79


80
		{/foreach}

		</tbody>
	</table>

	{$list->getHTMLPagination()|raw}
{/if}



{include file="_foot.tpl"}








>
>

72
73
74
75
76
77
78
79
80
81
82
		{/foreach}

		</tbody>
	</table>

	{$list->getHTMLPagination()|raw}
{/if}

{$snippets|raw}

{include file="_foot.tpl"}

Modified src/templates/users/index.tpl from [57040f9dcd] to [2c26d3ade1].

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
{if $_GET.msg == 'DELETE'}
	<p class="block confirm">Le membre a été supprimé.</p>
{elseif $_GET.msg == 'CATEGORY_CHANGED'}
	<p class="block confirm">Les membres sélectionnés ont bien été changés de catégorie.</p>
{/if}

{if !empty($categories)}
<form method="get" action="{$self_url}" class="shortFormRight">
	<fieldset>
		<legend>Filtrer par catégorie</legend>



		{input type="select" name="cat" onchange="this.form.submit();" options=$categories default=$current_cat required=true}
		<noscript>{button type="submit" name="" label="Filtrer" shape="right"}</noscript>









	</fieldset>
</form>
{/if}

<form method="get" action="search.php" class="shortFormLeft" data-focus="1">
	<fieldset>
		<legend>Rechercher un membre</legend>
		<input type="text" name="qt" value="" placeholder="Nom, numéro, ou adresse e-mail" />
		{button type="submit" name="" label="Chercher" shape="search"}
	</fieldset>
</form>

<form method="post" action="action.php" class="users-list" target="_dialog">

{if $list->count()}
	{$list->getHTMLPagination()|raw}







<
|

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

<






|







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
{if $_GET.msg == 'DELETE'}
	<p class="block confirm">Le membre a été supprimé.</p>
{elseif $_GET.msg == 'CATEGORY_CHANGED'}
	<p class="block confirm">Les membres sélectionnés ont bien été changés de catégorie.</p>
{/if}

{if !empty($categories)}

	<fieldset class="shortFormRight">
		<legend>Filtrer par catégorie</legend>
		<nav class="dropdown">
			<ul>
				<li><a></a></li>
			{foreach from=$categories key="c" item="category"}

			<li class="{if $c === $current_cat}selected{/if}">
				<a href="?cat={$c}">
					<strong>{$category.label}</strong>
					<small>{{%n membre}{%n membres} n=$category.count}</small>
				</a>
			</li>
			{/foreach}
			</ul>
		</nav>
	</fieldset>

{/if}

<form method="get" action="search.php" class="shortFormLeft" data-focus="1">
	<fieldset>
		<legend>Rechercher un membre</legend>
		<input type="text" name="qt" value="" placeholder="Nom, numéro, ou adresse e-mail" />
		{button type="submit" name="" title="Chercher" shape="search"}
	</fieldset>
</form>

<form method="post" action="action.php" class="users-list" target="_dialog">

{if $list->count()}
	{$list->getHTMLPagination()|raw}
46
47
48
49
50
51
52












53
54
55
56
57
58
59
				<?php $value = $row->$key; ?>
				{if $key == 'number'}
					<td class="num">
						{link href="details.php?id=%d"|args:$row._user_id label=$value}
					</td>
				{elseif $key == 'identity'}
					<th>{link href="details.php?id=%d"|args:$row._user_id label=$value}</th>












				{else}
					<td>
						{display_dynamic_field key=$key value=$value user_id=$row._user_id thumb_url="details.php?id=%d"|args:$row._user_id}
					</td>
				{/if}
			{/foreach}








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







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 $value = $row->$key; ?>
				{if $key == 'number'}
					<td class="num">
						{link href="details.php?id=%d"|args:$row._user_id label=$value}
					</td>
				{elseif $key == 'identity'}
					<th>{link href="details.php?id=%d"|args:$row._user_id label=$value}</th>
				{elseif $key == 'id_parent'}
					<td>
						{if $value}
							{link href="details.php?id=%d"|args:$value label=$row._parent_name}
						{/if}
					</td>
				{elseif $key == 'is_parent'}
					<td>
						{if $value}
							Oui
						{/if}
					</td>
				{else}
					<td>
						{display_dynamic_field key=$key value=$value user_id=$row._user_id thumb_url="details.php?id=%d"|args:$row._user_id}
					</td>
				{/if}
			{/foreach}

Modified src/templates/users/search.tpl from [4b65783723] to [a9b19a499c].

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
{include file="_head.tpl" title="Recherche de membre" current="users" custom_js=['lib/query_builder.min.js']}

{include file="users/_nav.tpl" current="search"}

<form method="post" action="{$self_url}" id="queryBuilderForm" data-disable-progress="1">

{include file="common/search/advanced.tpl"}

</form>

<form method="post" action="action.php" target="_dialog">

{if $list !== null}
	<p class="help">{$list->count()} membres trouvés pour cette recherche.</p>

	{if $list->count() > 0}
	<p class="actions">{exportmenu form=true name="_dl_export" class="menu-btn-right"}</p>
	{/if}




	{include file="common/dynamic_list_head.tpl" check=$is_admin use_buttons=true}

	{foreach from=$list->iterate() item="row"}
		<tr>
			{if $is_admin}
			<td class="check">{input type="checkbox" name="selected[]" value=$row.id}</td>
			{/if}




|



<
<
<
<







>
>
>







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
{include file="_head.tpl" title="Recherche de membre" current="users" custom_js=['lib/query_builder.min.js']}

{include file="users/_nav.tpl" current="search"}

<form method="post" action="{$self_url_no_qs}" id="queryBuilderForm" data-disable-progress="1">

{include file="common/search/advanced.tpl"}





{if $list !== null}
	<p class="help">{$list->count()} membres trouvés pour cette recherche.</p>

	{if $list->count() > 0}
	<p class="actions">{exportmenu form=true name="_dl_export" class="menu-btn-right"}</p>
	{/if}

	</form>
	<form method="post" action="action.php" target="_dialog">

	{include file="common/dynamic_list_head.tpl" check=$is_admin use_buttons=true}

	{foreach from=$list->iterate() item="row"}
		<tr>
			{if $is_admin}
			<td class="check">{input type="checkbox" name="selected[]" value=$row.id}</td>
			{/if}
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
		</tr>
	{/foreach}
		</tbody>
	{if $is_admin}
		{include file="users/_list_actions.tpl" colspan=$list->countHeaderColumns()+1}
	{/if}
	</table>



	{$list->getHTMLPagination(true)|raw}

{elseif $results}

	<p class="actions">{exportmenu form=true name="_export" class="menu-btn-right"}</p>

	<?php
	$id_column = array_search('_user_id', $header, true);

	if (false === $id_column) {
		$id_column = array_search('id', $header, true);
	}

	$header_count = count($header);
	?>




	<table class="list">
		<thead>
			<tr>
			{if $is_admin && $id_column !== false}
				<td class="check"><input type="checkbox" title="Tout cocher / décocher" id="f_all" /><label for="f_all"></label></td>
			{/if}
				{foreach from=$header item="column"}







>
>

















>
>
>







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
		</tr>
	{/foreach}
		</tbody>
	{if $is_admin}
		{include file="users/_list_actions.tpl" colspan=$list->countHeaderColumns()+1}
	{/if}
	</table>

	</form>

	{$list->getHTMLPagination(true)|raw}

{elseif $results}

	<p class="actions">{exportmenu form=true name="_export" class="menu-btn-right"}</p>

	<?php
	$id_column = array_search('_user_id', $header, true);

	if (false === $id_column) {
		$id_column = array_search('id', $header, true);
	}

	$header_count = count($header);
	?>

	</form>
	<form method="post" action="action.php" target="_dialog">

	<table class="list">
		<thead>
			<tr>
			{if $is_admin && $id_column !== false}
				<td class="check"><input type="checkbox" title="Tout cocher / décocher" id="f_all" /><label for="f_all"></label></td>
			{/if}
				{foreach from=$header item="column"}
87
88
89
90
91
92
93


94
95
96
97
98
99
			{/foreach}
		</tbody>

		{if $is_admin && $id_column !== false}
			{include file="users/_list_actions.tpl" colspan=$header_count+1}
		{/if}
	</table>



{/if}

</form>

{include file="_foot.tpl"}







>
>






91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
			{/foreach}
		</tbody>

		{if $is_admin && $id_column !== false}
			{include file="users/_list_actions.tpl" colspan=$header_count+1}
		{/if}
	</table>

	</form>

{/if}

</form>

{include file="_foot.tpl"}

Modified src/www/admin/_inc.php from [2059b7f42e] to [926355331c].

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
<?php

namespace Garradin;

use Garradin\Users\Session;

require_once __DIR__ . '/../../include/init.php';

function f($key)
{
	return \KD2\Form::get($key);
}

// Query-Validate: valider les éléments passés en GET
function qv(Array $rules)
{
	if (\KD2\Form::validate($rules, $errors, $_GET))
	{
		return true;
	}

	foreach ($errors as &$error)
	{
		$error = sprintf('%s: %s', $error['name'], $error['rule']);
	}

	throw new UserException(sprintf('Paramètres invalides (%s).', implode(', ',  $errors)));
}

function qg($key)
{
	return isset($_GET[$key]) ? $_GET[$key] : null;
}

$tpl = Template::getInstance();














<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







1
2
3
4
5
6
7
8
9
10
11
12
13
















14
15
16
17
18
19
20
<?php

namespace Garradin;

use Garradin\Users\Session;

require_once __DIR__ . '/../../include/init.php';

function f($key)
{
	return \KD2\Form::get($key);
}

















function qg($key)
{
	return isset($_GET[$key]) ? $_GET[$key] : null;
}

$tpl = Template::getInstance();

Modified src/www/admin/acc/reports/graph_plot_all.php from [cdc72313d9] to [42754fd13a].

1
2
3
4
5
6
7

8



9
10
11
12
13
14
15
16
17
<?php
namespace Garradin;

use Garradin\Accounting\Graph;

require_once __DIR__ . '/../_inc.php';


qv(['type' => 'string|required']);




header('Content-Type: image/svg+xml');

$expiry = time() - 30;
$hash = sha1('graph_plot_all');

if (!Utils::HTTPCache($hash, $expiry)) {
	echo Graph::bar(qg('type'), []);
}







>
|
>
>
>







|

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

use Garradin\Accounting\Graph;

require_once __DIR__ . '/../_inc.php';

$type = $_GET['type'] ?? null;

if (!$type) {
	throw new UserException('Missing type');
}

header('Content-Type: image/svg+xml');

$expiry = time() - 30;
$hash = sha1('graph_plot_all');

if (!Utils::HTTPCache($hash, $expiry)) {
	echo Graph::bar($type, []);
}

Modified src/www/admin/acc/transactions/creator.php from [3ffff6389f] to [e16800c5f0].

12
13
14
15
16
17
18
19
20
21
22

if (!$u) {
	throw new UserException('Ce membre n\'existe pas');
}

$criterias = ['creator' => $u->id];

$tpl->assign('journal', Reports::getJournal($criterias));
$tpl->assign('transaction_creator', $u);

$tpl->display('acc/transactions/creator.tpl');







|



12
13
14
15
16
17
18
19
20
21
22

if (!$u) {
	throw new UserException('Ce membre n\'existe pas');
}

$criterias = ['creator' => $u->id];

$tpl->assign('journal', Reports::getJournal($criterias, true));
$tpl->assign('transaction_creator', $u);

$tpl->display('acc/transactions/creator.tpl');

Modified src/www/admin/acc/transactions/user.php from [4e7ffb8f16] to [7e8decf9cc].

18
19
20
21
22
23
24
25
26
27
28
29
$years = Years::listAssoc();
end($years);
$year = (int)qg('year') ?: key($years);

$criterias = ['user' => $u->id];

$tpl->assign('balance', Reports::getAccountsBalances($criterias + ['year' => $year], null, false));
$tpl->assign('journal', Reports::getJournal($criterias));
$tpl->assign(compact('years', 'year'));
$tpl->assign('transaction_user', $u);

$tpl->display('acc/transactions/user.tpl');







|




18
19
20
21
22
23
24
25
26
27
28
29
$years = Years::listAssoc();
end($years);
$year = (int)qg('year') ?: key($years);

$criterias = ['user' => $u->id];

$tpl->assign('balance', Reports::getAccountsBalances($criterias + ['year' => $year], null, false));
$tpl->assign('journal', Reports::getJournal($criterias, true));
$tpl->assign(compact('years', 'year'));
$tpl->assign('transaction_user', $u);

$tpl->display('acc/transactions/user.tpl');

Modified src/www/admin/common/files/edit.php from [c26e5bfe74] to [5ab1c9475d].

30
31
32
33
34
35
36
37






38
39
40
41
42
43
44
	$file->setContent(f('content'));

	if (qg('js') !== null) {
		die('{"success":true}');
	}
}, $csrf_key, Utils::getSelfURI());

$tpl->assign('file', $file);







if (!$editor) {
	$tpl->display('common/files/upload.tpl');
}
elseif ($editor == 'wopi') {
	echo $file->editorHTML();
}







|
>
>
>
>
>
>







30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
	$file->setContent(f('content'));

	if (qg('js') !== null) {
		die('{"success":true}');
	}
}, $csrf_key, Utils::getSelfURI());

$tpl->assign(compact('csrf_key', 'file'));

$fallback = qg('fallback');

if (!$editor && $fallback) {
	$editor = $fallback;
}

if (!$editor) {
	$tpl->display('common/files/upload.tpl');
}
elseif ($editor == 'wopi') {
	echo $file->editorHTML();
}

Modified src/www/admin/common/saved_searches.php from [d2528299b7] to [95f81abb85].

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
	$s = Search::get(qg('edit') ?: qg('delete'));

	if (!$s) {
		throw new UserException('Recherche non trouvée');
	}

	if ($s->id_user !== null && $r->id_user != Session::getInstance()->getUser()->id) {
		throw new UserException('Recherche privée appartenant à un autre membre.');
	}

	$csrf_key = 'search_' . $s->id;

	$form->runIf('save', function () use ($s) {
		$s->importForm();







|







23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
	$s = Search::get(qg('edit') ?: qg('delete'));

	if (!$s) {
		throw new UserException('Recherche non trouvée');
	}

	if ($s->id_user !== null && $s->id_user != Session::getInstance()->getUser()->id) {
		throw new UserException('Recherche privée appartenant à un autre membre.');
	}

	$csrf_key = 'search_' . $s->id;

	$form->runIf('save', function () use ($s) {
		$s->importForm();

Modified src/www/admin/config/users/index.php from [ff4d06feca] to [b4de2ccc34].

36
37
38
39
40
41
42










43
44
45
46
47
48
		0 => 'Ne pas enregistrer de journaux',
		7 => 'Une semaine',
		30 => 'Un mois',
		90 => '3 mois',
		180 => '6 mois',
		365 => 'Un an',
		720 => 'Deux ans',










	],
]);

$tpl->assign(compact('csrf_key', 'config'));

$tpl->display('config/users/index.tpl');







>
>
>
>
>
>
>
>
>
>






36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
		0 => 'Ne pas enregistrer de journaux',
		7 => 'Une semaine',
		30 => 'Un mois',
		90 => '3 mois',
		180 => '6 mois',
		365 => 'Un an',
		720 => 'Deux ans',
	],
	'logout_delay_options' => [
		0 => 'Pas de déconnexion automatique',
		1 => '1 minute',
		15 => '15 minutes',
		30 => '30 minutes',
		60 => '1 heure',
		2*60 => '2 heures',
		3*60 => '3 heures',
		6*60 => '6 heures',
	],
]);

$tpl->assign(compact('csrf_key', 'config'));

$tpl->display('config/users/index.tpl');

Modified src/www/admin/docs/new_file.php from [2cd6b3a91e] to [7a1d38b8f4].

20
21
22
23
24
25
26
27
28
29
30
31
32

	if (!strpos($name, '.')) {
		$name .= '.md';
	}

	$file = Files::createFromString($parent . '/' . $name, '');

	Utils::redirect('!common/files/edit.php?p=' . rawurlencode($file->path));
}, $csrf_key);

$tpl->assign(compact('csrf_key'));

$tpl->display('docs/new_file.tpl');







|





20
21
22
23
24
25
26
27
28
29
30
31
32

	if (!strpos($name, '.')) {
		$name .= '.md';
	}

	$file = Files::createFromString($parent . '/' . $name, '');

	Utils::redirect('!common/files/edit.php?fallback=code&p=' . rawurlencode($file->path));
}, $csrf_key);

$tpl->assign(compact('csrf_key'));

$tpl->display('docs/new_file.tpl');

Modified src/www/admin/me/index.php from [59b3150bbd] to [1d5c7770f1].

1
2
3
4
5
6
7
8
9
10

11
12
13
14


15
<?php
namespace Garradin;

use Garradin\UserTemplate\Modules;

require_once __DIR__ . '/_inc.php';

$ok = qg('ok');

$tpl->assign(compact('user', 'ok'));


$variables = compact('user');
$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_USER, $variables));



$tpl->display('me/index.tpl');









|
>

|
|

>
>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
namespace Garradin;

use Garradin\UserTemplate\Modules;

require_once __DIR__ . '/_inc.php';

$ok = qg('ok');

$parent_name = $user->getParentName();
$children = $user->listChildren();

$variables = compact('user', 'parent_name', 'children', 'ok');
$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_MY_DETAILS, $variables));

$tpl->assign($variables);

$tpl->display('me/index.tpl');

Modified src/www/admin/me/services.php from [e9cf9078c4] to [7a18b9b262].

1
2
3
4
5
6

7
8
9
10
11
12
13
14
15
16
17
18
19




20
<?php
namespace Garradin;

use Garradin\Services\Services_User;
use Garradin\Accounting\Reports;
use Garradin\Entities\Accounting\Account;


require_once __DIR__ . '/_inc.php';

$tpl->assign('membre', $user);

$list = Services_User::perUserList($user->id);
$list->loadFromQueryString();

$tpl->assign(compact('list'));

$tpl->assign('services', Services_User::listDistinctForUser($user->id));
$tpl->assign('accounts', Reports::getAccountsBalances(['user' => $user->id, 'type' => Account::TYPE_THIRD_PARTY]));





$tpl->display('me/services.tpl');






>










|
|

>
>
>
>

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
<?php
namespace Garradin;

use Garradin\Services\Services_User;
use Garradin\Accounting\Reports;
use Garradin\Entities\Accounting\Account;
use Garradin\UserTemplate\Modules;

require_once __DIR__ . '/_inc.php';

$tpl->assign('membre', $user);

$list = Services_User::perUserList($user->id);
$list->loadFromQueryString();

$tpl->assign(compact('list'));

$services = Services_User::listDistinctForUser($user->id);
$accounts = Reports::getAccountsBalances(['user' => $user->id, 'type' => Account::TYPE_THIRD_PARTY]);

$variables = compact('list', 'services', 'accounts');
$tpl->assign($variables);
$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_MY_SERVICES, $variables));

$tpl->display('me/services.tpl');

Modified src/www/admin/static/doc/brindille_sections.html from [7038562b44] to [cab7dda856].

71
72
73
74
75
76
77

78
79
80
81
82
83
84
			<li><a href="#users">users</a></li>
			<li><a href="#subscriptions">subscriptions</a>
		</ol></li>
		<li><a href="#comptabilite">Comptabilité</a>
		<ol>
			<li><a href="#accounts">accounts</a></li>
			<li><a href="#balances">balances</a></li>

			<li><a href="#years">years</a>
		</ol></li>
		<li><a href="#pour-le-site-web">Pour le site web</a>
		<ol>
			<li><a href="#breadcrumbs">breadcrumbs</a></li>
			<li><a href="#pages-articles-categories-sup-sql-sup">pages, articles, categories <sup>(sql)</sup></a></li>
			<li><a href="#files-documents-images-sup-sql-sup">files, documents, images <sup>(sql)</sup></a>







>







71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
			<li><a href="#users">users</a></li>
			<li><a href="#subscriptions">subscriptions</a>
		</ol></li>
		<li><a href="#comptabilite">Comptabilité</a>
		<ol>
			<li><a href="#accounts">accounts</a></li>
			<li><a href="#balances">balances</a></li>
			<li><a href="#transactions">transactions</a></li>
			<li><a href="#years">years</a>
		</ol></li>
		<li><a href="#pour-le-site-web">Pour le site web</a>
		<ol>
			<li><a href="#breadcrumbs">breadcrumbs</a></li>
			<li><a href="#pages-articles-categories-sup-sql-sup">pages, articles, categories <sup>(sql)</sup></a></li>
			<li><a href="#files-documents-images-sup-sql-sup">files, documents, images <sup>(sql)</sup></a>
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
<p>Il est également possible de passer des arguments dans les paramètres à l'aides des arguments nommés qui commencent par deux points <code>:</code> :</p>
<pre><code>{{#articles where="title = :montitre" :montitre="Actualité"}}</code></pre>
<h1 id="membres">Membres</h1>
<h2 id="users">users</h2>
<p>Liste les membres.</p>
<p>Paramètres possibles :</p>
<p>| <code>id</code> | optionnel | Identifiant unique du membre, ou tableau contenant une liste d'identifiants. |<br />
| <code>search_name</code> | optionnel | Ne lister que les membres dont le nom correspond au texte passé en paramètre. |</p>

<p>Chaque itération renverra la fiche du membre, ainsi que ces variables :</p>
<p>| <code>$id</code> | Identifiant unique du membre |<br />
| <code>$_name</code> | Nom du membre, tel que défini dans la configuration |<br />
| <code>$_login</code> | Identifiant de connexion du membre, tel que défini dans la configuration |<br />
| <code>$_number</code> | Numéro du membre, tel que défini dans la configuration |</p>
<h2 id="subscriptions">subscriptions</h2>
<p>Liste les inscriptions à une ou des activités.</p>
<p>Paramètres possibles :</p>












<p>| <code>user</code> | optionnel | Identifiant unique du membre |<br />




| <code>active</code> | optionnel | Si <code>TRUE</code>, seules les inscriptions à jour sont listées |<br />




| <code>id_service</code> | optionnel | Ne renvoie que les inscriptions à l'activité correspondant à cet ID. |</p>



<h1 id="comptabilite">Comptabilité</h1>
<h2 id="accounts">accounts</h2>
<p>Liste les comptes d'un plan comptable.</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Paramètre</th>







|
>








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







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
<p>Il est également possible de passer des arguments dans les paramètres à l'aides des arguments nommés qui commencent par deux points <code>:</code> :</p>
<pre><code>{{#articles where="title = :montitre" :montitre="Actualité"}}</code></pre>
<h1 id="membres">Membres</h1>
<h2 id="users">users</h2>
<p>Liste les membres.</p>
<p>Paramètres possibles :</p>
<p>| <code>id</code> | optionnel | Identifiant unique du membre, ou tableau contenant une liste d'identifiants. |<br />
| <code>search_name</code> | optionnel | Ne lister que les membres dont le nom correspond au texte passé en paramètre. |<br />
| <code>id_parent</code> | optionnel | Ne lister que les membres rattachés à l'identifiant unique du membre responsable indiqué. |</p>
<p>Chaque itération renverra la fiche du membre, ainsi que ces variables :</p>
<p>| <code>$id</code> | Identifiant unique du membre |<br />
| <code>$_name</code> | Nom du membre, tel que défini dans la configuration |<br />
| <code>$_login</code> | Identifiant de connexion du membre, tel que défini dans la configuration |<br />
| <code>$_number</code> | Numéro du membre, tel que défini dans la configuration |</p>
<h2 id="subscriptions">subscriptions</h2>
<p>Liste les inscriptions à une ou des activités.</p>
<p>Paramètres possibles :</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Paramètre</th>
<th style="text-align: left;"></th>
<th style="text-align: left;">Fonction</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>user</code></td>
<td style="text-align: left;">optionnel</td>
<td style="text-align: left;">Identifiant unique du membre</td>
</tr>
<tr>
<td style="text-align: left;"><code>active</code></td>
<td style="text-align: left;">optionnel</td>
<td style="text-align: left;">Si <code>TRUE</code>, seules les inscriptions à jour sont listées</td>
</tr>
<tr>
<td style="text-align: left;"><code>id_service</code></td>
<td style="text-align: left;">optionnel</td>
<td style="text-align: left;">Ne renvoie que les inscriptions à l'activité correspondant à cet ID.</td>
</tr>
</tbody>
</table>
<h1 id="comptabilite">Comptabilité</h1>
<h2 id="accounts">accounts</h2>
<p>Liste les comptes d'un plan comptable.</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Paramètre</th>
372
373
374
375
376
377
378
379























380
381
382
383
384
385
386
<tbody>
<tr>
<td style="text-align: left;"><code>codes</code> (optionel)</td>
<td style="text-align: left;">Ne renvoyer que les balances des comptes ayant ces codes (séparer par des virgules).</td>
</tr>
<tr>
<td style="text-align: left;"><code>year</code> (optionel)</td>
<td style="text-align: left;">Ne renvoyer que les balances des comptes utilisés sur l'année (indiquer ici un ID de year)&lt;.</td>























</tr>
</tbody>
</table>
<h2 id="years">years</h2>
<p>Liste les exercices comptables</p>
<table>
<thead>







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







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
<tbody>
<tr>
<td style="text-align: left;"><code>codes</code> (optionel)</td>
<td style="text-align: left;">Ne renvoyer que les balances des comptes ayant ces codes (séparer par des virgules).</td>
</tr>
<tr>
<td style="text-align: left;"><code>year</code> (optionel)</td>
<td style="text-align: left;">Ne renvoyer que les balances des comptes utilisés sur l'année (indiquer ici un ID de year).</td>
</tr>
</tbody>
</table>
<h2 id="transactions">transactions</h2>
<p>Renvoie des écritures.</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Paramètre</th>
<th style="text-align: left;"></th>
<th style="text-align: left;">Fonction</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>id</code></td>
<td style="text-align: left;">optionnel</td>
<td style="text-align: left;">Indiquer un ID d'écriture pour récupérer ses informations.</td>
</tr>
<tr>
<td style="text-align: left;"><code>user</code></td>
<td style="text-align: left;">optionnel</td>
<td style="text-align: left;">Indiquer ici un ID utilisateur pour lister les écritures liées à un membre.</td>
</tr>
</tbody>
</table>
<h2 id="years">years</h2>
<p>Liste les exercices comptables</p>
<table>
<thead>

Modified src/www/admin/static/doc/markdown.html from [684b12e976] to [47747ea422].

84
85
86
87
88
89
90




91

92
93
94
95
96
97
98
			<li><a href="#identifiant-et-classe-css-sur-les-titres">Identifiant et classe CSS sur les titres</a></li>
			<li><a href="#classes-css">Classes CSS</a></li>
			<li><a href="#tags-html">Tags HTML</a>
		</ol></li>
		<li><a href="#extensions">Extensions</a>
		<ol>
			<li><a href="#images-jointes">Images jointes</a></li>




			<li><a href="#fichiers-joints">Fichiers joints</a></li>

			<li><a href="#sommaire-table-des-matieres-automatique">Sommaire / table des matières automatique</a>
			<ol>
				<li><a href="#exclure-un-sous-titre-du-sommaire">Exclure un sous-titre du sommaire</a>
			</ol></li>
			<li><a href="#grilles-et-colonnes">Grilles et colonnes</a></li>
			<li><a href="#alignement-du-texte">Alignement du texte</a></li>
			<li><a href="#couleurs">Couleurs</a>







>
>
>
>

>







84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
			<li><a href="#identifiant-et-classe-css-sur-les-titres">Identifiant et classe CSS sur les titres</a></li>
			<li><a href="#classes-css">Classes CSS</a></li>
			<li><a href="#tags-html">Tags HTML</a>
		</ol></li>
		<li><a href="#extensions">Extensions</a>
		<ol>
			<li><a href="#images-jointes">Images jointes</a></li>
			<li><a href="#galerie-d-images">Galerie d'images</a>
			<ol>
				<li><a href="#diaporama-d-images">Diaporama d'images</a>
			</ol></li>
			<li><a href="#fichiers-joints">Fichiers joints</a></li>
			<li><a href="#videos">Vidéos</a></li>
			<li><a href="#sommaire-table-des-matieres-automatique">Sommaire / table des matières automatique</a>
			<ol>
				<li><a href="#exclure-un-sous-titre-du-sommaire">Exclure un sous-titre du sommaire</a>
			</ol></li>
			<li><a href="#grilles-et-colonnes">Grilles et colonnes</a></li>
			<li><a href="#alignement-du-texte">Alignement du texte</a></li>
			<li><a href="#couleurs">Couleurs</a>
428
429
430
431
432
433
434















435
436
437
438
439
440
441













442
443
444
445
446
447
448
<li>Légende : indiquer ici une courte description de l'image.</li>
</ul>
<p>Exemple :</p>
<pre><code>&lt;&lt;image|mon_image.png|center|Ceci est une belle image&gt;&gt;</code></pre>
<p>Il est aussi possible d'utiliser la syntaxe avec des paramètres nommés :</p>
<pre><code>&lt;&lt;image file="Nom_fichier.jpg" align="center" caption="Légende"&gt;&gt;</code></pre>
<p>Les images qui ne sont pas mentionnées dans le texte seront affichées après le texte sous forme de galerie.</p>















<h2 id="fichiers-joints">Fichiers joints</h2>
<p>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 :</p>
<pre><code>&lt;&lt;file|Nom_fichier.ext|Libellé&gt;&gt;</code></pre>
<ul>
<li><code>Nom_fichier.ext</code> : remplacer par le nom du fichier  (parmi les fichiers joints à la page)</li>
<li><code>Libellé</code> : 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é</li>
</ul>













<h2 id="sommaire-table-des-matieres-automatique">Sommaire / table des matières automatique</h2>
<p>Il est possible de placer le code <code>&lt;&lt;toc&gt;&gt;</code> pour générer un sommaire automatiquement à partir des titres et sous-titres :</p>
<pre><code>&lt;&lt;toc&gt;&gt;</code></pre>
<p>Affichera un sommaire comme celui-ci :</p><div class="toc">
	<ol>
		<li><a href="#syntaxe-markdown">Syntaxe MarkDown</a>
		<ol>







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







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







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
<li>Légende : indiquer ici une courte description de l'image.</li>
</ul>
<p>Exemple :</p>
<pre><code>&lt;&lt;image|mon_image.png|center|Ceci est une belle image&gt;&gt;</code></pre>
<p>Il est aussi possible d'utiliser la syntaxe avec des paramètres nommés :</p>
<pre><code>&lt;&lt;image file="Nom_fichier.jpg" align="center" caption="Légende"&gt;&gt;</code></pre>
<p>Les images qui ne sont pas mentionnées dans le texte seront affichées après le texte sous forme de galerie.</p>
<h2 id="galerie-d-images">Galerie d'images</h2>
<p>Il est possible d'afficher une galerie d'images (sous forme d'images miniatures) avec la balise <code>&lt;&lt;gallery</code> qui contient la liste des images à mettre dans la galerie :</p>
<pre><code>&lt;&lt;gallery
Nom_fichier.jpg
Nom_fichier_2.jpg
&gt;&gt;</code></pre>
<p>Si aucun nom de fichier n'est indiqué, alors toutes les images jointes à la page seront affichées :</p>
<pre><code>&lt;&lt;gallery&gt;&gt;</code></pre>
<h3 id="diaporama-d-images">Diaporama d'images</h3>
<p>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.</p>
<p>La syntaxe est la même, mais on ajoute le mot <code>slideshow</code> après le mot <code>gallery</code> :</p>
<pre><code>&lt;&lt;gallery slideshow
Nom_fichier.jpg
Nom_fichier_2.jpg
&gt;&gt;</code></pre>
<h2 id="fichiers-joints">Fichiers joints</h2>
<p>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 :</p>
<pre><code>&lt;&lt;file|Nom_fichier.ext|Libellé&gt;&gt;</code></pre>
<ul>
<li><code>Nom_fichier.ext</code> : remplacer par le nom du fichier  (parmi les fichiers joints à la page)</li>
<li><code>Libellé</code> : 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é</li>
</ul>
<h2 id="videos">Vidéos</h2>
<p>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 :</p>
<pre><code>&lt;&lt;video|Nom_du_fichier.ext&gt;&gt;</code></pre>
<p>On peut aussi spécifier d'autres paramètres :</p>
<ul>
<li><code>file</code> : nom du fichier vidéo</li>
<li><code>poster</code> : nom de fichier d'une image utilisée pour remplacer la vidéo avant qu'elle ne soit lue</li>
<li><code>subtitles</code> : nom d'un fichier de sous-titres au format VTT (le format SRT n'est pas géré par les navigateurs)</li>
<li><code>width</code> : largeur de la vidéo (en pixels)</li>
<li><code>height</code> : hauteur de la vidéo (en pixels)</li>
</ul>
<p>Exemple :</p>
<pre><code>&lt;&lt;video file="Ma_video.webm" poster="Ma_video_poster.jpg" width="640" height="360" subtitles="Ma_video_sous_titres.vtt"&gt;&gt;</code></pre>
<h2 id="sommaire-table-des-matieres-automatique">Sommaire / table des matières automatique</h2>
<p>Il est possible de placer le code <code>&lt;&lt;toc&gt;&gt;</code> pour générer un sommaire automatiquement à partir des titres et sous-titres :</p>
<pre><code>&lt;&lt;toc&gt;&gt;</code></pre>
<p>Affichera un sommaire comme celui-ci :</p><div class="toc">
	<ol>
		<li><a href="#syntaxe-markdown">Syntaxe MarkDown</a>
		<ol>
473
474
475
476
477
478
479




480

481
482
483
484
485
486
487
			<li><a href="#identifiant-et-classe-css-sur-les-titres">Identifiant et classe CSS sur les titres</a></li>
			<li><a href="#classes-css">Classes CSS</a></li>
			<li><a href="#tags-html">Tags HTML</a>
		</ol></li>
		<li><a href="#extensions">Extensions</a>
		<ol>
			<li><a href="#images-jointes">Images jointes</a></li>




			<li><a href="#fichiers-joints">Fichiers joints</a></li>

			<li><a href="#sommaire-table-des-matieres-automatique">Sommaire / table des matières automatique</a>
			<ol>
				<li><a href="#exclure-un-sous-titre-du-sommaire">Exclure un sous-titre du sommaire</a>
			</ol></li>
			<li><a href="#grilles-et-colonnes">Grilles et colonnes</a></li>
			<li><a href="#alignement-du-texte">Alignement du texte</a></li>
			<li><a href="#couleurs">Couleurs</a>







>
>
>
>

>







506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
			<li><a href="#identifiant-et-classe-css-sur-les-titres">Identifiant et classe CSS sur les titres</a></li>
			<li><a href="#classes-css">Classes CSS</a></li>
			<li><a href="#tags-html">Tags HTML</a>
		</ol></li>
		<li><a href="#extensions">Extensions</a>
		<ol>
			<li><a href="#images-jointes">Images jointes</a></li>
			<li><a href="#galerie-d-images">Galerie d'images</a>
			<ol>
				<li><a href="#diaporama-d-images">Diaporama d'images</a>
			</ol></li>
			<li><a href="#fichiers-joints">Fichiers joints</a></li>
			<li><a href="#videos">Vidéos</a></li>
			<li><a href="#sommaire-table-des-matieres-automatique">Sommaire / table des matières automatique</a>
			<ol>
				<li><a href="#exclure-un-sous-titre-du-sommaire">Exclure un sous-titre du sommaire</a>
			</ol></li>
			<li><a href="#grilles-et-colonnes">Grilles et colonnes</a></li>
			<li><a href="#alignement-du-texte">Alignement du texte</a></li>
			<li><a href="#couleurs">Couleurs</a>

Modified src/www/admin/static/scripts/accounting_setup.js from [008faf6f1e] to [11af0b9e26].

9
10
11
12
13
14
15

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

			return false;
		}

		row.parentNode.removeChild(row);
	};
}


$('tbody tr').forEach(initLine);

// Add row "plus" button
$('tfoot button')[0].onclick = () => {
	let lines = $('tbody tr');
	var line = lines[lines.length - 1];
	var n = line.cloneNode(true);

	// Reset label and reference
	n.querySelectorAll('input').forEach((i) => {
		i.value = '';
	})

	line.parentNode.appendChild(n);
	initLine(n);
};








>
|

|
|
|
|
|

|
|
|
|

|
|
|
>
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
			return false;
		}

		row.parentNode.removeChild(row);
	};
}

if ($('table').length) {
	$('tbody tr').forEach(initLine);

	// Add row "plus" button
	$('tfoot button')[0].onclick = () => {
		let lines = $('tbody tr');
		var line = lines[lines.length - 1];
		var n = line.cloneNode(true);

		// Reset label and reference
		n.querySelectorAll('input').forEach((i) => {
			i.value = '';
		})

		line.parentNode.appendChild(n);
		initLine(n);
	};
}

Modified src/www/admin/static/scripts/global.js from [d9a85db429] to [ae35c2f06e].

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
		link.href = this.static_url + file + '?' + g.version;
		return document.head.appendChild(link);
	};

	g.dialog = null;
	g.focus_before_dialog = null;

	g.openDialog = function (content, callback, classname) {










		if (null !== g.dialog) {
			g.closeDialog();
		}

		g.focus_before_dialog = document.activeElement;

		g.dialog = document.createElement('dialog');
		g.dialog.id = 'dialog';
		g.dialog.open = true;
		g.dialog.className = classname || '';


		var btn = document.createElement('button');
		btn.className = 'icn-btn closeBtn';
		btn.setAttribute('data-icon', '✘');
		btn.type = 'button';
		btn.innerHTML = 'Fermer';
		btn.onclick = g.closeDialog;
		g.dialog.appendChild(btn);





		if (typeof content == 'string') {
			var container = document.createElement('div');
			container.innerHTML = content;
			content = container;
		}
		else if (content instanceof DocumentFragment) {
			var container = document.createElement('div');
			container.appendChild(content.cloneNode(true));
			content = container;
		}

		g.dialog.appendChild(content);

		g.dialog.style.opacity = 0;
		g.dialog.onclick = (e) => { if (e.target == g.dialog) g.closeDialog(); };
		window.onkeyup = (e) => { if (e.key == 'Escape') g.closeDialog(); };

		let tag = content.tagName.toLowerCase();

		if (tag == 'img' || tag == 'iframe') {
			event = 'load';
		}
		else if (tag == 'audio' || tag == 'video') {







|
>
>
>
>
>
>
>
>
>
>











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















<
<







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
		link.href = this.static_url + file + '?' + g.version;
		return document.head.appendChild(link);
	};

	g.dialog = null;
	g.focus_before_dialog = null;

	g.openDialog = function (content, options) {
		var close = true,
			callback = null,
			classname = null;

		if (typeof options === "object" && options !== null) {
			callback = options.callback ?? null;
			classname = options.classname ?? null;
			close = options.close ?? true;
		}

		if (null !== g.dialog) {
			g.closeDialog();
		}

		g.focus_before_dialog = document.activeElement;

		g.dialog = document.createElement('dialog');
		g.dialog.id = 'dialog';
		g.dialog.open = true;
		g.dialog.className = classname || '';

		if (close) {
			var btn = document.createElement('button');
			btn.className = 'icn-btn closeBtn';
			btn.setAttribute('data-icon', '✘');
			btn.type = 'button';
			btn.innerHTML = 'Fermer';
			btn.onclick = g.closeDialog;
			g.dialog.appendChild(btn);

			g.dialog.onclick = (e) => { if (e.target == g.dialog) g.closeDialog(); };
			window.onkeyup = (e) => { if (e.key == 'Escape') g.closeDialog(); };
		}

		if (typeof content == 'string') {
			var container = document.createElement('div');
			container.innerHTML = content;
			content = container;
		}
		else if (content instanceof DocumentFragment) {
			var container = document.createElement('div');
			container.appendChild(content.cloneNode(true));
			content = container;
		}

		g.dialog.appendChild(content);

		g.dialog.style.opacity = 0;



		let tag = content.tagName.toLowerCase();

		if (tag == 'img' || tag == 'iframe') {
			event = 'load';
		}
		else if (tag == 'audio' || tag == 'video') {
160
161
162
163
164
165
166
167





168
169
170
171
172
173
174

		// Restore CSS defaults
		window.setTimeout(() => { g.dialog.style.opacity = ''; }, 50);

		return content;
	}

	g.openFrameDialog = function (url, height = '90%', callback, classname) {





		var iframe = document.createElement('iframe');
		iframe.src = url;
		iframe.name = 'dialog';
		iframe.id = 'frameDialog';
		iframe.frameborder = '0';
		iframe.scrolling = 'yes';
		iframe.width = iframe.height = 0;







|
>
>
>
>
>







173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192

		// Restore CSS defaults
		window.setTimeout(() => { g.dialog.style.opacity = ''; }, 50);

		return content;
	}

	g.openFrameDialog = function (url, options) {
		options ??= {};
		var height = options.height || '90%';
		var callback = options.callback || null;
		var classname = options.classname || null;

		var iframe = document.createElement('iframe');
		iframe.src = url;
		iframe.name = 'dialog';
		iframe.id = 'frameDialog';
		iframe.frameborder = '0';
		iframe.scrolling = 'yes';
		iframe.width = iframe.height = 0;
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197

			// We need to wait a bit for the height to be correct, not sure why
			window.setTimeout(() => {
				iframe.style.height = iframe.dataset.height == 'auto' ? iframe.contentWindow.document.body.offsetHeight + 'px' : iframe.dataset.height;
			}, 200);
		});

		g.openDialog(iframe, callback, classname);
		return iframe;
	};

	g.reloadParentDialog = () => {
		if (!window.parent.g.dialog) {
			return;
		}







|







201
202
203
204
205
206
207
208
209
210
211
212
213
214
215

			// We need to wait a bit for the height to be correct, not sure why
			window.setTimeout(() => {
				iframe.style.height = iframe.dataset.height == 'auto' ? iframe.contentWindow.document.body.offsetHeight + 'px' : iframe.dataset.height;
			}, 200);
		});

		g.openDialog(iframe, {callback, classname});
		return iframe;
	};

	g.reloadParentDialog = () => {
		if (!window.parent.g.dialog) {
			return;
		}
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
					}

					if (location.href.match(/_dialog/)) {
						location.href = url;
						return false;
					}

					g.openFrameDialog(url, e.getAttribute('data-dialog-height') || 'auto', null, e.getAttribute('data-dialog-class'));
					return false;
				}

				if (type.match(/^image\//)) {
					var i = document.createElement('img');
					i.src = e.href;
				}







|







533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
					}

					if (location.href.match(/_dialog/)) {
						location.href = url;
						return false;
					}

					g.openFrameDialog(url, {'height': e.getAttribute('data-dialog-height') || 'auto', 'classname': e.getAttribute('data-dialog-class')});
					return false;
				}

				if (type.match(/^image\//)) {
					var i = document.createElement('img');
					i.src = e.href;
				}
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
					var i = document.createElement('video');
					i.autoplay = true;
					i.controls = true;
					i.src = e.href;
				}
				else {
					let url = e.href + (e.href.indexOf('?') > 0 ? '&' : '?') + '_dialog';
					g.openFrameDialog(url, '90%');
					return false;
				}

				g.openDialog(i);

				return false;
			};
		});

		$('form[target="_dialog"]').forEach((e) => {
			e.addEventListener('submit', () => {
				if (e.target != '_dialog' && e.target != 'dialog') return;

				let url = e.getAttribute('action');
				url = url + (url.indexOf('?') > 0 ? '&' : '?') + '_dialog';
				e.setAttribute('action', url);
				e.target = 'dialog';

				g.openFrameDialog('about:blank', e.getAttribute('data-dialog-height') ? 90 : 'auto');
				e.submit();
				return false;
			});
		});
	});

	g.onload(() => {







|


















|







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
					var i = document.createElement('video');
					i.autoplay = true;
					i.controls = true;
					i.src = e.href;
				}
				else {
					let url = e.href + (e.href.indexOf('?') > 0 ? '&' : '?') + '_dialog';
					g.openFrameDialog(url, {height: '90%'});
					return false;
				}

				g.openDialog(i);

				return false;
			};
		});

		$('form[target="_dialog"]').forEach((e) => {
			e.addEventListener('submit', () => {
				if (e.target != '_dialog' && e.target != 'dialog') return;

				let url = e.getAttribute('action');
				url = url + (url.indexOf('?') > 0 ? '&' : '?') + '_dialog';
				e.setAttribute('action', url);
				e.target = 'dialog';

				g.openFrameDialog('about:blank', {height: e.getAttribute('data-dialog-height') ? 90 : 'auto'});
				e.submit();
				return false;
			});
		});
	});

	g.onload(() => {

Modified src/www/admin/static/scripts/service_form.js from [d3a768cf39] to [272a6811a7].

46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
	}

	// Fill the amount paid by the user
	if (amount && create) {
		$('#f_amount').value = g.formatMoney(amount);
	}

	if (elm.dataset.project) {
		$('#f_id_project').value = elm.dataset.project;
	}
}

function initForm() {
	$('input[name=id_service]').forEach((e) => {
		e.onchange = () => { selectService(e); };







|







46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
	}

	// Fill the amount paid by the user
	if (amount && create) {
		$('#f_amount').value = g.formatMoney(amount);
	}

	if ('project' in elm.dataset) {
		$('#f_id_project').value = elm.dataset.project;
	}
}

function initForm() {
	$('input[name=id_service]').forEach((e) => {
		e.onchange = () => { selectService(e); };

Modified src/www/admin/static/scripts/web_editor.js from [7b56af47a3] to [a168ced166].

51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
		};

		// Warn before closing window if content was changed
		window.addEventListener('beforeunload', preventClose, { capture: true });

		t.textarea.form.addEventListener('submit', () => {
			window.removeEventListener('beforeunload', preventClose, {capture: true});
			save((data) => { location.href = data.redirect; });
			return false;
		});

		// Cancel Escape to close.value
		if (window.parent && window.parent.g.dialog) {
			// Always fullscreen in dialogs
			config.fullscreen = true;







|







51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
		};

		// Warn before closing window if content was changed
		window.addEventListener('beforeunload', preventClose, { capture: true });

		t.textarea.form.addEventListener('submit', () => {
			window.removeEventListener('beforeunload', preventClose, {capture: true});
			save((data) => { localStorage.removeItem(backup_key); location.href = data.redirect; });
			return false;
		});

		// Cancel Escape to close.value
		if (window.parent && window.parent.g.dialog) {
			// Always fullscreen in dialogs
			config.fullscreen = true;
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
			return true;
		};

		var openFileInsert = function (callback)
		{
			let args = new URLSearchParams(window.location.search);
			var uri = args.get('p');
			g.openFrameDialog(g.admin_url + 'web/_attach.php?files&_dialog&p=' + uri, null, callback);
			return true;
		};

		var openImageInsert = function (callback)
		{
			let args = new URLSearchParams(window.location.search);
			var uri = args.get('p');
			g.openFrameDialog(g.admin_url + 'web/_attach.php?images&_dialog&p=' + uri, null, callback);
			return true;
		};

		window.te_insertFile = function (file)
		{
			var tag = '<<file|'+file+'>>';








|







|







133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
			return true;
		};

		var openFileInsert = function (callback)
		{
			let args = new URLSearchParams(window.location.search);
			var uri = args.get('p');
			g.openFrameDialog(g.admin_url + 'web/_attach.php?files&_dialog&p=' + uri, {callback});
			return true;
		};

		var openImageInsert = function (callback)
		{
			let args = new URLSearchParams(window.location.search);
			var uri = args.get('p');
			g.openFrameDialog(g.admin_url + 'web/_attach.php?images&_dialog&p=' + uri, {callback});
			return true;
		};

		window.te_insertFile = function (file)
		{
			var tag = '<<file|'+file+'>>';

485
486
487
488
489
490
491



492
493
494
495
496
497
498






499

500
501
502
503
504
505
506
507
			});
		}

		window.setTimeout(() => {
			if ((v = localStorage.getItem(backup_key)) && v.trim() !== t.textarea.value.trim() && window.confirm(msg_restore)) {
				t.textarea.value = v;
			}



		}, 50);

		window.setInterval(() => {
			if (t.textarea.value.trim() === t.textarea.defaultValue.trim()) {
				return;
			}







			localStorage.setItem(backup_key, t.textarea.value);

		}, 10000);

	}

	g.onload(() => {
		g.script('scripts/lib/text_editor.min.js', init);
	});
}());







>
>
>







>
>
>
>
>
>

>








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
			});
		}

		window.setTimeout(() => {
			if ((v = localStorage.getItem(backup_key)) && v.trim() !== t.textarea.value.trim() && window.confirm(msg_restore)) {
				t.textarea.value = v;
			}
			else {
				localStorage.removeItem(backup_key);
			}
		}, 50);

		window.setInterval(() => {
			if (t.textarea.value.trim() === t.textarea.defaultValue.trim()) {
				return;
			}

			var v = localStorage.getItem(backup_key);

			if (v && v.trim() === t.textarea.value.trim()) {
				return;
			}

			localStorage.setItem(backup_key, t.textarea.value);
			console.log('Saved');
		}, 10000);

	}

	g.onload(() => {
		g.script('scripts/lib/text_editor.min.js', init);
	});
}());

Modified src/www/admin/static/scripts/web_gallery.js from [98ae149376] to [bc4b4561f0].

17
18
19
20
21
22
23


24
25
26
27
28
29
30
			a.setAttribute('data-pos', i);
			a.onclick = function (e) {
				e.preventDefault();
				openImageBrowser(items, this.getAttribute('data-pos'));
				return false;
			};
		}


	};

	window.enableImageGallery = enableGallery;

	function openImageBrowser(items, pos)
	{
		div = document.createElement('div');







>
>







17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
			a.setAttribute('data-pos', i);
			a.onclick = function (e) {
				e.preventDefault();
				openImageBrowser(items, this.getAttribute('data-pos'));
				return false;
			};
		}

		document.querySelectorAll('div.slideshow').forEach(e => enableSlideshow(e));
	};

	window.enableImageGallery = enableGallery;

	function openImageBrowser(items, pos)
	{
		div = document.createElement('div');
78
79
80
81
82
83
84



































































85

		img.style.width = 0;
		img.style.height = 0;
		img.src = items[pos].href;
		img.pos = pos;
	}




































































}());







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

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

		img.style.width = 0;
		img.style.height = 0;
		img.src = items[pos].href;
		img.pos = pos;
	}

	function enableSlideshow(gallery)
	{
		var images = gallery.getElementsByTagName('figure');
		var count = images.length;

		var div = document.createElement('div');
		div.className = 'index';

		for (var i = 0; i < count; i++) {
			var btn = document.createElement('button');

			if (i == 0) {
				btn.className = 'current';
			}

			btn.onclick = (e) => {
				var btn = e.target;
				var i = parseInt(btn.innerText, 10)-1;
				gallery.firstChild.scrollTop = i*400;
				gallery.querySelector('.current').classList.remove('current');
				btn.classList.add('current');
			};

			btn.innerText = i + 1;

			div.appendChild(btn);
		}

		gallery.appendChild(div);

		var nav = document.createElement('div');
		nav.className = 'nav';

		var get_current_idx = () => parseInt(gallery.querySelector('.current').innerText, 10)-1;

		var btn = document.createElement('button');
		btn.className = 'prev';
		btn.onclick = () => {
			var i = get_current_idx() - 1;
			var buttons = gallery.querySelectorAll('.index button');

			if (i < 0) {
				i = buttons.length - 1;
			}

			buttons[i].click();
		};
		btn.innerHTML = '◀';
		nav.appendChild(btn);

		var btn = document.createElement('button');
		btn.className = 'prev';
		btn.onclick = () => {
			var i = get_current_idx()+1;
			var buttons = gallery.querySelectorAll('.index button');

			if (i >= buttons.length) {
				i = 0;
			}

			buttons[i].click();
		};
		btn.innerHTML = '▶'
		nav.appendChild(btn);

		gallery.appendChild(nav);
	}
}());

Modified src/www/admin/static/styles/01-layout.css from [c3e802ffb9] to [830e9e0404].

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
    --gBorderColor: #999;
    --gLightBorderColor: #333;
    --gLightBackgroundColor: #222;
    --gLinkColor: #99f;
    --gHoverLinkColor: 250, 127, 127;
}

html.dark .header .menu, html.dark .header .menu a, html.dark .header .menu li.current h3 span[data-icon]::before {
    color: rgb(var(--gTextColor)) !important;
    text-shadow: 0px 0px 5px rgb(var(--gBgColor)) !important;
}

html {
    width: 100%;
    height: 100%;







|







25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
    --gBorderColor: #999;
    --gLightBorderColor: #333;
    --gLightBackgroundColor: #222;
    --gLinkColor: #99f;
    --gHoverLinkColor: 250, 127, 127;
}

html.dark .header .menu, html.dark .header .menu a, html.dark .header .menu li.current h3 span[data-icon]::before, html.dark nav.tabs .current a {
    color: rgb(var(--gTextColor)) !important;
    text-shadow: 0px 0px 5px rgb(var(--gBgColor)) !important;
}

html {
    width: 100%;
    height: 100%;

Modified src/www/admin/static/styles/03-forms.css from [4b6d641eb1] to [21f88f170c].

89
90
91
92
93
94
95

96
97
98
99
100
101
102
103
104
105
106
input[type=password], input[type=range], input[type=search], input[type=tel],
textarea, select, .input-list, .file-selector {
    padding: .4rem .6rem;
    font-family: inherit;
    min-width: 20em;
    max-width: 100%;
    border: 1px solid rgb(var(--gMainColor));

    font-size: inherit;
    background: rgb(var(--gBgColor));
    color: rgb(var(--gTextColor));
    border-radius: .25rem;
    transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}

textarea.full-width, input.full-width {
    width: calc(100% - 1.2rem);
}








>



<







89
90
91
92
93
94
95
96
97
98
99

100
101
102
103
104
105
106
input[type=password], input[type=range], input[type=search], input[type=tel],
textarea, select, .input-list, .file-selector {
    padding: .4rem .6rem;
    font-family: inherit;
    min-width: 20em;
    max-width: 100%;
    border: 1px solid rgb(var(--gMainColor));
    border-radius: .25rem;
    font-size: inherit;
    background: rgb(var(--gBgColor));
    color: rgb(var(--gTextColor));

    transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}

textarea.full-width, input.full-width {
    width: calc(100% - 1.2rem);
}

Modified src/www/admin/static/styles/04-dialogs.css from [e22f8ea0ea] to [51685f7e90].

52
53
54
55
56
57
58
59
60
61





62
63
64
65
66
67
68
}

#dialog.loaded > img, #dialog.loaded > audio, #dialog.loaded > video {
    opacity: 1;
    height: initial;
}

#dialog > iframe {
    border-radius: .5em;
    box-shadow: 0px 0px 5px #000;





}

#dialog.loaded > iframe {
    width: 90%;
    opacity: 1;
}








|


>
>
>
>
>







52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
}

#dialog.loaded > img, #dialog.loaded > audio, #dialog.loaded > video {
    opacity: 1;
    height: initial;
}

#dialog > iframe, #dialog > div {
    border-radius: .5em;
    box-shadow: 0px 0px 5px #000;
    background: #fff;
}

#dialog > div {
    padding: 1em;
}

#dialog.loaded > iframe {
    width: 90%;
    opacity: 1;
}

Modified src/www/admin/static/styles/05-navigation.css from [088a5c1c37] to [c15fe949c0].

181
182
183
184
185
186
187
































































































    margin: 0 auto 0 auto;
    padding: .1em;
    background: rgba(var(--gSecondColor), .5);
    margin-bottom: 5px;
    text-shadow: none;
}








































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
    margin: 0 auto 0 auto;
    padding: .1em;
    background: rgba(var(--gSecondColor), .5);
    margin-bottom: 5px;
    text-shadow: none;
}

/**
 * Dropdown, eg. interactive <select>
 */
nav.dropdown {
    position: relative;
    height: 2.3rem;
    border: 1px solid rgb(var(--gMainColor));
    border-radius: .25rem;
}

nav.dropdown ul {
    background-color: rgb(var(--gBgColor));
    top: 0;
    left: 0;
    right: 0;
    position: absolute;
    border-radius: .5em;
}

nav.dropdown ul::after {
    position: absolute;
    right: 0;
    top: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 1.5em;
    height: 1.5em;
    font-size: 1.5em;
    content: "↓";
    font-family: "gicon";
}

nav.dropdown li {
    display: none;
}

nav.dropdown li.selected, nav.dropdown:hover li {
    display: block;
}

nav.dropdown li a {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: .4rem .6rem;
    text-decoration: none;
    height: 1.5em;
    color: unset;
}

nav.dropdown li a strong {
    font-weight: normal;
}

nav.dropdown li a small {
    color: var(--gBorderColor);
    margin-right: 2em;
}

nav.dropdown li:nth-child(1) {
    display: none;
}

nav.dropdown:hover {
    border-color: transparent;
}

nav.dropdown:hover li:nth-child(even) a {
    background: rgba(var(--gSecondColor), 0.2);
}

nav.dropdown:hover ul {
    box-shadow: 0 0 5px .2rem rgba(var(--gMainColor), 0.5);
    border-radius: .25rem;
}

nav.dropdown:hover .selected a {
    box-shadow: 0 0 5px .2rem rgba(var(--gMainColor), 0.5);
    color: rgb(var(--gHoverLinkColor));
}

nav.dropdown:hover li a strong {
    font-weight: bold;
}

nav.dropdown:hover li a:hover {
    background: rgba(var(--gMainColor), 0.2);
    color: rgb(var(--gHoverLinkColor));
}

@media handheld, screen and (max-width:981px) {
    nav.dropdown:hover li:nth-child(1) {
        display: block;
    }
}

Modified src/www/admin/static/styles/07-tables.css from [f6bdd9860c] to [f51df0a09c].

Modified src/www/admin/users/index.php from [1e8cb64c32] to [f68afbfad0].

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

if ($format = qg('export')) {
	Session::getInstance()->requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
	Users::exportCategory($format, $current_cat);
	return;
}

$categories = [0 => '— Toutes (sauf cachées) —'];

// Remove hidden categories
if (!$session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)) {
	$categories = $categories + Categories::listAssoc(Categories::WITHOUT_HIDDEN);
}
else {
	$categories[-1] = '— Toutes (même cachées) —';
	$categories = $categories + Categories::listAssoc();
}

// Deny access to hidden categories to users that are not admins
if (!array_key_exists($current_cat, $categories)) {
	$current_cat = null;
}

$can_edit = $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);

$list = Users::listByCategory($current_cat);
$list->loadFromQueryString();

if (!$current_cat) {
	$title = 'Liste des membres';
}
elseif ($current_cat == -1) {
	$title = 'Tous les membres';
}
else {
	$title = sprintf('Liste des membres — %s', $categories[$current_cat] ?? '');
}

$tpl->assign(compact('can_edit', 'list', 'current_cat', 'categories', 'title'));

$tpl->display('users/index.tpl');







<
<
<
|
<
<
<
<
|
<


















|





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

if ($format = qg('export')) {
	Session::getInstance()->requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);
	Users::exportCategory($format, $current_cat);
	return;
}




$is_manager = $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE);




$categories = Categories::listAssocWithStats($is_manager ? null : Categories::WITHOUT_HIDDEN);


// Deny access to hidden categories to users that are not admins
if (!array_key_exists($current_cat, $categories)) {
	$current_cat = null;
}

$can_edit = $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);

$list = Users::listByCategory($current_cat);
$list->loadFromQueryString();

if (!$current_cat) {
	$title = 'Liste des membres';
}
elseif ($current_cat == -1) {
	$title = 'Tous les membres';
}
else {
	$title = sprintf('Liste des membres — %s', $categories[$current_cat]->label ?? '');
}

$tpl->assign(compact('can_edit', 'list', 'current_cat', 'categories', 'title'));

$tpl->display('users/index.tpl');