Overview
Comment:Merge trunk/stable changes
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | dev
Files: files | file ages | folders
SHA3-256: b7a5f89a8c6cbdb7ff16284ed12db151003228b08b0339e880ef98e9b6fa16d3
User & Date: bohwaz on 2021-06-07 19:23:46
Other Links: branch diff | manifest | tags
Context
2021-06-07
19:23
Merge trunk/stable changes Leaf check-in: b7a5f89a8c user: bohwaz tags: dev
17:01
Fix issues with out of sync files and web pages, and duplicate URIs in web pages check-in: cc5a0e1c1d user: bohwaz tags: trunk
2021-05-07
01:00
Progress on web editor check-in: 879ddf7e69 user: bohwaz tags: dev
Changes

Modified debian/makedeb.sh from [d81d35ac7e] to [0cffd3a1a4].

141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
 autorisations est possible. Il est également possible de faire des
 envois de mails en groupe.
 .
 Un module de comptabilité à double entrée assure une gestion financière
 complète digne d'un vrai logiciel de comptabilité : suivi des opérations,
 graphiques, bilan annuel, compte de résultat, exercices, etc.
 .
 Un module wiki permet de prendre des notes de réunion, tenir à jour
 les informations internes à l'association (possibilité de chiffrer le
 contenu des pages) ou de publier des pages sur le site public intégré.
 L'aspect du site public peut être géré simplement avec ses squelettes
 SPIP.

EOF

}


true && {







|
|
|
<
<







141
142
143
144
145
146
147
148
149
150


151
152
153
154
155
156
157
 autorisations est possible. Il est également possible de faire des
 envois de mails en groupe.
 .
 Un module de comptabilité à double entrée assure une gestion financière
 complète digne d'un vrai logiciel de comptabilité : suivi des opérations,
 graphiques, bilan annuel, compte de résultat, exercices, etc.
 .
 Il y a également la possibilité de publier un site web simple,
 et un gestionnaire de documents permettant de gérer les fichiers de
 l'association.



EOF

}


true && {

Modified doc/index.md from [d17dd5a726] to [e4968f9379].

157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174

Garradin signifie *argent* en *Wagiman*, un dialecte aborigène du nord de l'Australie.

## Documentation et entraide

*  D'abord lire la [documentation](/wiki/?name=Documentation) et notamment la [foire aux questions](/wiki/?name=FAQ)
*  La [liste de discussion d'entraide entre utilisateurs](https://admin.kd2.org/lists/aide@garradin.eu) est le meilleur moyen de vous faire aider :)
*  [Chat d'entraide en direct](https://kiwiirc.com/nextclient/#irc://irc.freenode.net/#garradin?nick=garradin%7C?), ou via IRC : salon `#garradin` sur `irc.freenode.net`

## Participer

Tout coup de main est le bienvenu, pas besoin d'avoir des connaissances techniques ! Nous avons un [guide de contribution](/wiki/?name=Contribuer) pour vous aider à voir comment vous pouvez participer à Garradin :)

### Développement

Garradin est un logiciel libre, développé en PHP, utilisant la base de données SQLite, et avec une interface utilisant HTML, CSS et un peu de Javascript.

Nous acceptons les contributions (plugins, patch, code, tickets, etc.) avec plaisir, consultez la [documentation développeur⋅euse](/wiki/?name=Documentation développeur) pour découvrir comment vous pouvez contribuer.







|










157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174

Garradin signifie *argent* en *Wagiman*, un dialecte aborigène du nord de l'Australie.

## Documentation et entraide

*  D'abord lire la [documentation](/wiki/?name=Documentation) et notamment la [foire aux questions](/wiki/?name=FAQ)
*  La [liste de discussion d'entraide entre utilisateurs](https://admin.kd2.org/lists/aide@garradin.eu) est le meilleur moyen de vous faire aider :)
*  [Chat d'entraide en direct](https://kiwiirc.com/nextclient/#irc://irc.libera.chat/#garradin?nick=garradin%7C?), ou via IRC : salon `#garradin` sur `irc.libera.chat` (port `6697` avec TLS)

## Participer

Tout coup de main est le bienvenu, pas besoin d'avoir des connaissances techniques ! Nous avons un [guide de contribution](/wiki/?name=Contribuer) pour vous aider à voir comment vous pouvez participer à Garradin :)

### Développement

Garradin est un logiciel libre, développé en PHP, utilisant la base de données SQLite, et avec une interface utilisant HTML, CSS et un peu de Javascript.

Nous acceptons les contributions (plugins, patch, code, tickets, etc.) avec plaisir, consultez la [documentation développeur⋅euse](/wiki/?name=Documentation développeur) pour découvrir comment vous pouvez contribuer.

Modified src/Makefile from [c52b2382ef] to [7364cd9885].

7
8
9
10
11
12
13


14
15
16
17
18
19
20

	wget ${KD2_FILE} -O ${TMP_KD2}/kd2.zip

	rm -rf "include/lib/KD2"
	unzip "${TMP_KD2}/kd2.zip" -d include/lib

	rm -rf ${TMP_KD2}



	wget -O "include/lib/Parsedown.php" "https://raw.githubusercontent.com/erusev/parsedown/1.7.x/Parsedown.php"
	wget -O "include/lib/ParsedownExtra.php" "https://raw.githubusercontent.com/erusev/parsedown-extra/0.8.x/ParsedownExtra.php"

dev-server:
	php -S localhost:8082 -t www www/_route.php








>
>







7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

	wget ${KD2_FILE} -O ${TMP_KD2}/kd2.zip

	rm -rf "include/lib/KD2"
	unzip "${TMP_KD2}/kd2.zip" -d include/lib

	rm -rf ${TMP_KD2}

	wget -O "include/lib/Parsedown.php" "https://raw.githubusercontent.com/erusev/parsedown/1.7.x/Parsedown.php"

	wget -O "include/lib/Parsedown.php" "https://raw.githubusercontent.com/erusev/parsedown/1.7.x/Parsedown.php"
	wget -O "include/lib/ParsedownExtra.php" "https://raw.githubusercontent.com/erusev/parsedown-extra/0.8.x/ParsedownExtra.php"

dev-server:
	php -S localhost:8082 -t www www/_route.php

Modified src/VERSION from [97156291a5] to [07c940882d].

1
1.1.4
|
1
1.1.8

Modified src/include/data/1.1.0_schema.sql from [26acbef5df] to [ed688bac27].

18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
...
106
107
108
109
110
111
112
113

114
115
116
117
118
119
120
121
122
123
...
320
321
322
323
324
325
326
327

328
329
330
331
332
333
334
335
336
337
338
    perm_subscribe INTEGER NOT NULL DEFAULT 0,
    perm_connect INTEGER NOT NULL DEFAULT 1,
    perm_config INTEGER NOT NULL DEFAULT 0,

    hidden INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX users_categories_hidden ON users_categories (hidden);

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
................................................................................
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)

);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

--
-- COMPTA
--
................................................................................
    format TEXT NOT NULL,
    published TEXT NOT NULL CHECK (datetime(published) = published),
    modified TEXT NOT NULL CHECK (datetime(modified) = modified),
    title TEXT NOT NULL,
    content TEXT NOT NULL
);

CREATE UNIQUE INDEX web_pages_path ON web_pages (path);

CREATE UNIQUE INDEX web_pages_file_path ON web_pages (file_path);
CREATE INDEX web_pages_parent ON web_pages (parent);
CREATE INDEX web_pages_published ON web_pages (published);
CREATE INDEX web_pages_title ON web_pages (title);

-- FIXME: rename to english
CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé







|







 







|
>


|







 







|
>
|
|
|
|







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
...
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
...
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
    perm_subscribe INTEGER NOT NULL DEFAULT 0,
    perm_connect INTEGER NOT NULL DEFAULT 1,
    perm_config INTEGER NOT NULL DEFAULT 0,

    hidden INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden);

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
................................................................................
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

    sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
    due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

--
-- COMPTA
--
................................................................................
    format TEXT NOT NULL,
    published TEXT NOT NULL CHECK (datetime(published) = published),
    modified TEXT NOT NULL CHECK (datetime(modified) = modified),
    title TEXT NOT NULL,
    content TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (parent);
CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);

-- FIXME: rename to english
CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé

Added src/include/data/1.1.7_migration.sql version [40a52d8865].



















>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
ALTER TABLE services_reminders_sent RENAME TO srs_old;

-- Missing acc_years_delete trigger, again, because of missing symlink in previous release
-- Also add new column in services_reminders_sent

.read schema.sql

INSERT INTO services_reminders_sent SELECT id, id_user, id_service, id_reminder, date, date FROM srs_old;
DROP TABLE srs_old;

Added src/include/data/1.1.8_migration.sql version [cc434f178d].











>
>
>
>
>
1
2
3
4
5
-- Remove any leftover duplicates
DELETE FROM web_pages WHERE id IN (SELECT id FROM web_pages GROUP BY uri HAVING COUNT(*) > 1);

-- Add unique index
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);

Modified src/include/data/schema.sql from [3456f2416e] to [ed688bac27].

18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
..
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
...
106
107
108
109
110
111
112
113

114
115
116
117
118
119
120
121
122
123
...
164
165
166
167
168
169
170





171
172
173
174
175
176
177
...
315
316
317
318
319
320
321
322

323
324
325
326
327
328
329
330
331
332
333
    perm_subscribe INTEGER NOT NULL DEFAULT 0,
    perm_connect INTEGER NOT NULL DEFAULT 1,
    perm_config INTEGER NOT NULL DEFAULT 0,

    hidden INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX users_categories_hidden ON users_categories (hidden);

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
................................................................................
    label TEXT NOT NULL,
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL si le type n'est pas associé automatiquement à la compta
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL si le type n'est pas associé automatiquement à la compta
);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
................................................................................
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)

);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

--
-- COMPTA
--
................................................................................

    closed INTEGER NOT NULL DEFAULT 0,

    id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
);

CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);






CREATE TABLE IF NOT EXISTS acc_transactions
-- Opérations comptables
(
    id INTEGER PRIMARY KEY NOT NULL,

    type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
................................................................................
    format TEXT NOT NULL,
    published TEXT NOT NULL CHECK (datetime(published) = published),
    modified TEXT NOT NULL CHECK (datetime(modified) = modified),
    title TEXT NOT NULL,
    content TEXT NOT NULL
);

CREATE UNIQUE INDEX web_pages_path ON web_pages (path);

CREATE UNIQUE INDEX web_pages_file_path ON web_pages (file_path);
CREATE INDEX web_pages_parent ON web_pages (parent);
CREATE INDEX web_pages_published ON web_pages (published);
CREATE INDEX web_pages_title ON web_pages (title);

-- FIXME: rename to english
CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé







|







 







|
|







 







|
>


|







 







>
>
>
>
>







 







|
>
|
|
|
|







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
..
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
...
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
...
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
...
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
    perm_subscribe INTEGER NOT NULL DEFAULT 0,
    perm_connect INTEGER NOT NULL DEFAULT 1,
    perm_config INTEGER NOT NULL DEFAULT 0,

    hidden INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden);

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
................................................................................
    label TEXT NOT NULL,
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL if fee is not linked to accounting
);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
................................................................................
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

    sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
    due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

--
-- COMPTA
--
................................................................................

    closed INTEGER NOT NULL DEFAULT 0,

    id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
);

CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);

-- Make sure id_account is reset when a year is deleted
CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
    UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;

CREATE TABLE IF NOT EXISTS acc_transactions
-- Opérations comptables
(
    id INTEGER PRIMARY KEY NOT NULL,

    type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
................................................................................
    format TEXT NOT NULL,
    published TEXT NOT NULL CHECK (datetime(published) = published),
    modified TEXT NOT NULL CHECK (datetime(modified) = modified),
    title TEXT NOT NULL,
    content TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (parent);
CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);

-- FIXME: rename to english
CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé

Modified src/include/lib/Garradin/API.php from [fca20dc380] to [808f82b79d].

12
13
14
15
16
17
18





19
20
21
22
23
24
25
..
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
..
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
	{
		if (null == $this->body) {
			$this->body = trim(file_get_contents('php://input'));
		}

		return $this->body;
	}






	protected function download()
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

................................................................................
			return ['results' => Recherche::rawSQL($body)];
		}
		catch (\Exception $e) {
			http_response_code(400);
			return ['error' => 'Error in SQL statement', 'sql_error' => $e->getMessage()];
		}
	}
















































	public function checkAuth(): void
	{
		if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
			throw new APIException('No username or password supplied', 401);
		}

		if ($_SERVER['PHP_AUTH_USER'] !== API_USER || $_SERVER['PHP_AUTH_PW'] !== API_PASSWORD) {
			throw new APIException('Invalid username or password', 403);
		}
	}

	public function dispatch(string $fn)
	{
		$this->checkAuth();

		switch ($fn) {
			case 'sql':
				return $this->sql();
			case 'download':
				return $this->download();


			default:
				throw new APIException('Unknown path', 404);
		}
	}

	static public function dispatchURI(string $uri)
	{
................................................................................
		$api = new self;

		$api->method = $_SERVER['REQUEST_METHOD'] ?? null;

		http_response_code(200);

		try {
			$return = $api->dispatch($fn);

			if (null !== $return) {
				echo json_encode($return);
			}
		}
		catch (\Exception $e) {
			if ($e instanceof APIException) {







>
>
>
>
>







 







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












|








>
>







 







|







12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
..
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
...
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
	{
		if (null == $this->body) {
			$this->body = trim(file_get_contents('php://input'));
		}

		return $this->body;
	}

	protected function hasParam(string $param): bool
	{
		return array_key_exists($param, $_GET);
	}

	protected function download()
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

................................................................................
			return ['results' => Recherche::rawSQL($body)];
		}
		catch (\Exception $e) {
			http_response_code(400);
			return ['error' => 'Error in SQL statement', 'sql_error' => $e->getMessage()];
		}
	}

	protected function web(string $uri): ?array
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

		$fn = strtok($uri, '/');
		$param = strtok('');

		switch ($fn) {
			case 'list':
				return ['categories' => Web::listCategories($param), 'pages' => Web::listPages($param)];
			case 'attachment':
				$attachment = Web::getAttachmentFromURI($param);

				if (!$attachment) {
					throw new APIException('Page not found', 404);
				}

				$attachment->serve();
				return null;
			case 'html':
			case 'page':
				$page = Web::getByURI($param);

				if (!$page) {
					throw new APIException('Page not found', 404);
				}

				if ($fn == 'page') {
					$out = compact('page');

					if ($this->hasParam('html')) {
						$out['html'] = $page->render();
					}

					return $out;
				}

				// HTML render
				echo $page->render();
				return null;
			default:
				throw new APIException('Unknown web action', 404);
		}
	}

	public function checkAuth(): void
	{
		if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
			throw new APIException('No username or password supplied', 401);
		}

		if ($_SERVER['PHP_AUTH_USER'] !== API_USER || $_SERVER['PHP_AUTH_PW'] !== API_PASSWORD) {
			throw new APIException('Invalid username or password', 403);
		}
	}

	public function dispatch(string $fn, string $uri)
	{
		$this->checkAuth();

		switch ($fn) {
			case 'sql':
				return $this->sql();
			case 'download':
				return $this->download();
			case 'web':
				return $this->web($uri);
			default:
				throw new APIException('Unknown path', 404);
		}
	}

	static public function dispatchURI(string $uri)
	{
................................................................................
		$api = new self;

		$api->method = $_SERVER['REQUEST_METHOD'] ?? null;

		http_response_code(200);

		try {
			$return = $api->dispatch($fn, strtok(''));

			if (null !== $return) {
				echo json_encode($return);
			}
		}
		catch (\Exception $e) {
			if ($e instanceof APIException) {

Modified src/include/lib/Garradin/Accounting/Reports.php from [5ea34ddcf7] to [0f2d41f8c0].

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
		$db = DB::getInstance();
		$a = $db->firstColumn($sql, Account::REVENUE);
		$b = $db->firstColumn($sql, Account::EXPENSE);

		return (int)$a - (int)$b * -1;
	}

	static public function getClosingSumsWithAccounts(array $criterias, ?string $order = null, bool $reverse = false): array
	{
		$where = self::getWhereClause($criterias);

		$order = $order ?: 'a.code COLLATE NOCASE';
		$reverse = $reverse ? '* -1' : '';


		// Find sums, link them to accounts
		$sql = sprintf('SELECT a.id, a.code, a.label, a.position, SUM(l.credit) AS credit, SUM(l.debit) AS debit,
			SUM(l.credit - l.debit) %s AS sum
			FROM %s l
			INNER JOIN %s t ON t.id = l.id_transaction
			INNER JOIN %s a ON a.id = l.id_account
			WHERE %s
			GROUP BY l.id_account
			HAVING sum != 0

			ORDER BY %s;',
			$reverse, Line::TABLE, Transaction::TABLE, Account::TABLE, $where, $order);
		return DB::getInstance()->getGrouped($sql);
	}






	static public function getBalanceSheet(array $criterias): array
	{
		$out = [
			Account::ASSET => [],
			Account::LIABILITY => [],
			'sums' => [







|





>









<
>

|


>
>
>
>
>







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
		$db = DB::getInstance();
		$a = $db->firstColumn($sql, Account::REVENUE);
		$b = $db->firstColumn($sql, Account::EXPENSE);

		return (int)$a - (int)$b * -1;
	}

	static public function getClosingSumsWithAccounts(array $criterias, ?string $order = null, bool $reverse = false, bool $remove_zero = true): array
	{
		$where = self::getWhereClause($criterias);

		$order = $order ?: 'a.code COLLATE NOCASE';
		$reverse = $reverse ? '* -1' : '';
		$remove_zero = $remove_zero ? 'HAVING sum != 0' : '';

		// Find sums, link them to accounts
		$sql = sprintf('SELECT a.id, a.code, a.label, a.position, SUM(l.credit) AS credit, SUM(l.debit) AS debit,
			SUM(l.credit - l.debit) %s AS sum
			FROM %s l
			INNER JOIN %s t ON t.id = l.id_transaction
			INNER JOIN %s a ON a.id = l.id_account
			WHERE %s
			GROUP BY l.id_account

			%s
			ORDER BY %s;',
			$reverse, Line::TABLE, Transaction::TABLE, Account::TABLE, $where, $remove_zero, $order);
		return DB::getInstance()->getGrouped($sql);
	}

	static public function getTrialBalance(array $criterias): array
	{
		return self::getClosingSumsWithAccounts($criterias, null, false, false);
	}

	static public function getBalanceSheet(array $criterias): array
	{
		$out = [
			Account::ASSET => [],
			Account::LIABILITY => [],
			'sums' => [

Modified src/include/lib/Garradin/CSV.php from [abd4bedd11] to [1989ff97c6].

152
153
154
155
156
157
158


159
160

161




162
163
164
165
166
167
168
169
170
171
172
173

		if ($header) {
			fputs($fp, self::row($header));
		}

		if (!($iterator instanceof \Iterator) || $iterator->valid()) {
			foreach ($iterator as $row) {


				foreach ($row as $key => &$v) {
					if (is_object($v) && $v instanceof \DateTimeInterface) {

						$v = $v->format('d/m/Y');




					}
				}

				$row = self::rowToArray($row, $row_map_callback);

				if (!$header)
				{
					fputs($fp, self::row(array_keys($row)));
					$header = true;
				}

				fputs($fp, self::row($row));







>
>


>
|
>
>
>
>



<
<







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

		if ($header) {
			fputs($fp, self::row($header));
		}

		if (!($iterator instanceof \Iterator) || $iterator->valid()) {
			foreach ($iterator as $row) {
				$row = self::rowToArray($row, $row_map_callback);

				foreach ($row as $key => &$v) {
					if (is_object($v) && $v instanceof \DateTimeInterface) {
						if ($v->format('His') == '000000') {
							$v = $v->format('d/m/Y');
						}
						else {
							$v = $v->format('d/m/Y H:i:s');
						}
					}
				}



				if (!$header)
				{
					fputs($fp, self::row(array_keys($row)));
					$header = true;
				}

				fputs($fp, self::row($row));

Modified src/include/lib/Garradin/DB.php from [8dd3841b1b] to [7ce0e3f7d3].

11
12
13
14
15
16
17


18
19
20
21
22
23
24
..
45
46
47
48
49
50
51





52
53

54
55
56
57
58
59
60
61
...
184
185
186
187
188
189
190
191





























     * @link https://www.sqlite.org/pragma.html#pragma_application_id
     */
    const APPID = 0x5da2d811;

    static protected $_instance = null;

    protected $_version = -1;



    static public function getInstance($create = false, $readonly = false)
    {
        if (null === self::$_instance) {
            self::$_instance = new DB('sqlite', ['file' => DB_FILE]);
        }

................................................................................
        $this->db->busyTimeout(10 * 1000);

        // Performance enhancement
        // see https://www.cs.utexas.edu/~jaya/slides/apsys17-sqlite-slides.pdf
        // https://ericdraken.com/sqlite-performance-testing/
        $this->exec(sprintf('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA journal_size_limit = %d;', 32 * 1024 * 1024));






        $this->db->createFunction('dirname', [Utils::class, 'dirname']);
        $this->db->createFunction('basename', [Utils::class, 'basename']);

        $this->db->createCollation('NOCASE', [Utils::class, 'unicodeCaseComparison']);
    }

    public function version(): ?string
    {
        if (-1 === $this->_version) {
            $this->connect();
            $this->_version = self::getVersion($this->db);
................................................................................
            $this->db->exec('PRAGMA foreign_keys = OFF;');
        }
        else {
            $this->db->exec('PRAGMA legacy_alter_table = OFF;');
            $this->db->exec('PRAGMA foreign_keys = ON;');
        }
    }
}




































>
>







 







>
>
>
>
>
|
|
>
|







 







|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
..
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
...
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
     * @link https://www.sqlite.org/pragma.html#pragma_application_id
     */
    const APPID = 0x5da2d811;

    static protected $_instance = null;

    protected $_version = -1;

    static protected $unicode_patterns_cache = [];

    static public function getInstance($create = false, $readonly = false)
    {
        if (null === self::$_instance) {
            self::$_instance = new DB('sqlite', ['file' => DB_FILE]);
        }

................................................................................
        $this->db->busyTimeout(10 * 1000);

        // Performance enhancement
        // see https://www.cs.utexas.edu/~jaya/slides/apsys17-sqlite-slides.pdf
        // https://ericdraken.com/sqlite-performance-testing/
        $this->exec(sprintf('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA journal_size_limit = %d;', 32 * 1024 * 1024));

        self::registerCustomFunctions($this->db);
    }

    static public function registerCustomFunctions($db)
    {
        $db->createFunction('dirname', [Utils::class, 'dirname']);
        $db->createFunction('basename', [Utils::class, 'basename']);
        $db->createFunction('like', [self::class, 'unicodeLike']);
        $db->createCollation('NOCASE', [Utils::class, 'unicodeCaseComparison']);
    }

    public function version(): ?string
    {
        if (-1 === $this->_version) {
            $this->connect();
            $this->_version = self::getVersion($this->db);
................................................................................
            $this->db->exec('PRAGMA foreign_keys = OFF;');
        }
        else {
            $this->db->exec('PRAGMA legacy_alter_table = OFF;');
            $this->db->exec('PRAGMA foreign_keys = ON;');
        }
    }

    /**
     * This is a rewrite of SQLite LIKE function that is transforming
     * the pattern and the value to lowercase ascii, so that we can match
     * "émilie" with "emilie".
     *
     * This is probably not the best way to do that, but we have to resort to that
     * as ICU extension is rarely available.
     *
     * @see https://www.sqlite.org/c3ref/strlike.html
     * @see https://sqlite.org/src/file?name=ext/icu/icu.c&ci=trunk
     */
    static public function unicodeLike($pattern, $value, $escape = null) {
        $id = md5($pattern . $escape);

        if (!array_key_exists($id, self::$unicode_patterns_cache)) {
            $pattern = Utils::unicodeCaseFold($pattern);
            $escape = $escape ? '(?!' . preg_quote($escape, '/') . ')' : '';
            $pattern = preg_quote($pattern, '/');
            $pattern = preg_replace('/' . $escape . '%/', '.*', $pattern);
            $pattern = preg_replace('/' . $escape . '_/', '.', $pattern);
            $pattern = '/' . $pattern . '/';
            self::$unicode_patterns_cache[$id] = $pattern;
        }

        $value = Utils::unicodeCaseFold($value);

        return (bool) preg_match(self::$unicode_patterns_cache[$id], $value);
    }
}

Modified src/include/lib/Garradin/DynamicList.php from [2a8411f96b] to [4a558ce327].

103
104
105
106
107
108
109











110
111
112
113
114
115
116
		else if ('ods' == $format) {
			CSV::toODS($name, $this->iterate(false), $this->getHeaderColumns(true), $this->export_callback);
		}
		else {
			throw new UserException('Invalid export format');
		}
	}












	public function paginationURL()
	{
		return Utils::getModifiedURL('?p=[ID]');
	}

	public function orderURL(string $order, bool $desc)







>
>
>
>
>
>
>
>
>
>
>







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
		else if ('ods' == $format) {
			CSV::toODS($name, $this->iterate(false), $this->getHeaderColumns(true), $this->export_callback);
		}
		else {
			throw new UserException('Invalid export format');
		}
	}

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

		foreach ($this->iterate(true) as $row) {
			$out[] = $row;
		}

		return $out;
	}

	public function paginationURL()
	{
		return Utils::getModifiedURL('?p=[ID]');
	}

	public function orderURL(string $order, bool $desc)

Modified src/include/lib/Garradin/Entities/Files/File.php from [cfc7cdd122] to [f33e63dac6].

10
11
12
13
14
15
16

17
18
19
20
21
22
23
..
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
...
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
...
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
...
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715

716
717
718

719




720
721
722
723
724
725
726
...
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
...
902
903
904
905
906
907
908
909
910
911

912
913
914









915
916










917
918
919
920
921
use Garradin\Plugin;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Membres\Session;
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;


use Garradin\Files\Files;

use const Garradin\{WWW_URL, ENABLE_XSENDFILE};

/**
 * This is a virtual entity, it cannot be saved to a SQL table
................................................................................
	const ALLOWED_THUMB_SIZES = [200, 500];

	const THUMB_CACHE_ID = 'file.thumb.%s.%d';

	const THUMB_SIZE_TINY = 200;
	const THUMB_SIZE_SMALL = 500;

	const FILE_EXT_ENCRYPTED = '.skriv.enc';
	const FILE_EXT_SKRIV = '.skriv';

	const EDITOR_WEB = 'web';
	const EDITOR_ENCRYPTED = 'encrypted';
	const EDITOR_CODE = 'code';

	const CONTEXT_DOCUMENTS = 'documents';
	const CONTEXT_USER = 'user';
	const CONTEXT_TRANSACTION = 'transaction';
	const CONTEXT_CONFIG = 'config';
	const CONTEXT_WEB = 'web';
	const CONTEXT_SKELETON = 'skel';

................................................................................
		'image/webp',
		'image/svg+xml',
		'text/plain',
		'text/html',
	];

	// https://book.hacktricks.xyz/pentesting-web/file-upload
	const FORBIDDEN_EXTENSIONS = '!cgi|exe|sh|bash|com|pif|jspx?|js[wxv]|action|do|php(?:s|\d+)?|pht|phtml?|shtml|phar|htaccess|inc|cfml?|cfc|dbm|swf|pl|perl|py|pyc|asp|so!i';

	static public function getColumns(): array
	{
		return array_keys((new self)->_types);
	}

	public function selfCheck(): void
................................................................................

	public function indexForSearch(?string $source_path, ?string $source_content, ?string $title = null): void
	{
		// Store content in search table
		if (substr($this->mime, 0, 5) == 'text/') {
			$content = $source_content !== null ? $source_content : Files::callStorage('fetch', $this);

			if ($this->customType() == self::FILE_EXT_ENCRYPTED) {
				$content = null;
			}
			else if ($this->mime === 'text/html' || $this->mime == 'text/xml') {
				$content = strip_tags($content);
			}
		}
		else {
			$content = null;
		}

................................................................................
	public function fetch()
	{
		return Files::callStorage('fetch', $this);
	}

	public function render(array $options = [])
	{
		$type = $this->customType();
		/*
		if (substr($this->name, -strlen(self::FILE_EXT_HTML)) == self::FILE_EXT_HTML) {
			return \Garradin\Web\Render\HTML::render($this, null, $options);
		}*/

		if ($type == self::FILE_EXT_SKRIV) {
			return \Garradin\Web\Render\Skriv::render($this, null, $options);
		}
		else if ($type == self::FILE_EXT_ENCRYPTED) {
			return \Garradin\Web\Render\EncryptedSkriv::render($this, null);
		}
		else if (substr($this->mime, 0, 5) == 'text/') {

			return sprintf('<pre>%s</pre>', htmlspecialchars($this->fetch()));
		}


		throw new \LogicException('Cannot render file of this type');




	}

	public function checkReadAccess(?Session $session): bool
	{
		// Web pages and config files are always public
		if ($this->isPublic()) {
			return true;
................................................................................
		if ($context == self::CONTEXT_SKELETON || $context == self::CONTEXT_CONFIG || $context == self::CONTEXT_WEB) {
			return true;
		}

		return false;
	}

	public function getEditor(): ?string
	{
		if ($this->customType() == self::FILE_EXT_SKRIV) {
			return self::EDITOR_WEB;
		}
		elseif ($this->customType() == self::FILE_EXT_ENCRYPTED) {
			return self::EDITOR_ENCRYPTED;
		}
		elseif (substr($this->mime, 0, 5) == 'text/') {
			return self::EDITOR_CODE;
		}

		return null;
	}

	static public function filterName(string $name): string
	{
		return preg_replace('/[^\w\d\p{L}_. -]+/iu', '-', $name);
	}

	static public function validateFileName(string $name): void
	{
................................................................................
		}

		$name = array_pop($path);
		$ref = implode('/', $path);
		return [$context, $ref ?: null, $name];
	}

	public function customType(): ?string
	{
		static $extensions = [self::FILE_EXT_ENCRYPTED, self::FILE_EXT_SKRIV];


		foreach ($extensions as $ext) {
			if (substr($this->name, -strlen($ext)) == $ext) {









				return $ext;
			}










		}

		return null;
	}
}







>







 







<
<
<
<
<
<
<







 







|







 







<
<
<
|







 







|
<
<
<
<

<
<
<
<
<
<
<
>


<
>
|
>
>
>
>







 







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







 







|

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





10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
..
69
70
71
72
73
74
75







76
77
78
79
80
81
82
...
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
...
286
287
288
289
290
291
292



293
294
295
296
297
298
299
300
...
687
688
689
690
691
692
693
694




695







696
697
698

699
700
701
702
703
704
705
706
707
708
709
710
711
...
825
826
827
828
829
830
831















832
833
834
835
836
837
838
...
872
873
874
875
876
877
878
879
880
881
882
883

884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
use Garradin\Plugin;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Membres\Session;
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;
use Garradin\Web\Render\Render;

use Garradin\Files\Files;

use const Garradin\{WWW_URL, ENABLE_XSENDFILE};

/**
 * This is a virtual entity, it cannot be saved to a SQL table
................................................................................
	const ALLOWED_THUMB_SIZES = [200, 500];

	const THUMB_CACHE_ID = 'file.thumb.%s.%d';

	const THUMB_SIZE_TINY = 200;
	const THUMB_SIZE_SMALL = 500;








	const CONTEXT_DOCUMENTS = 'documents';
	const CONTEXT_USER = 'user';
	const CONTEXT_TRANSACTION = 'transaction';
	const CONTEXT_CONFIG = 'config';
	const CONTEXT_WEB = 'web';
	const CONTEXT_SKELETON = 'skel';

................................................................................
		'image/webp',
		'image/svg+xml',
		'text/plain',
		'text/html',
	];

	// https://book.hacktricks.xyz/pentesting-web/file-upload
	const FORBIDDEN_EXTENSIONS = '!^(?:cgi|exe|sh|bash|com|pif|jspx?|js[wxv]|action|do|php(?:s|\d+)?|pht|phtml?|shtml|phar|htaccess|inc|cfml?|cfc|dbm|swf|pl|perl|py|pyc|asp|so)$!i';

	static public function getColumns(): array
	{
		return array_keys((new self)->_types);
	}

	public function selfCheck(): void
................................................................................

	public function indexForSearch(?string $source_path, ?string $source_content, ?string $title = null): void
	{
		// Store content in search table
		if (substr($this->mime, 0, 5) == 'text/') {
			$content = $source_content !== null ? $source_content : Files::callStorage('fetch', $this);




			if ($this->mime === 'text/html' || $this->mime == 'text/xml') {
				$content = strip_tags($content);
			}
		}
		else {
			$content = null;
		}

................................................................................
	public function fetch()
	{
		return Files::callStorage('fetch', $this);
	}

	public function render(array $options = [])
	{
		$editor_type = $this->renderFormat();












		if ($editor_type == 'text') {
			return sprintf('<pre>%s</pre>', htmlspecialchars($this->fetch()));
		}

		elseif (!$editor_type) {
			throw new \LogicException('Cannot render file of this type');
		}
		else {
			return Render::render($editor_type, $this, $this->fetch(), $options);
		}
	}

	public function checkReadAccess(?Session $session): bool
	{
		// Web pages and config files are always public
		if ($this->isPublic()) {
			return true;
................................................................................
		if ($context == self::CONTEXT_SKELETON || $context == self::CONTEXT_CONFIG || $context == self::CONTEXT_WEB) {
			return true;
		}

		return false;
	}
















	static public function filterName(string $name): string
	{
		return preg_replace('/[^\w\d\p{L}_. -]+/iu', '-', $name);
	}

	static public function validateFileName(string $name): void
	{
................................................................................
		}

		$name = array_pop($path);
		$ref = implode('/', $path);
		return [$context, $ref ?: null, $name];
	}

	public function renderFormat(): ?string
	{
		if (substr($this->name, -6) == '.skriv') {
			$format = Render::FORMAT_SKRIV;
		}

		elseif (substr($this->name, -3) == '.md') {
			$format = Render::FORMAT_MARKDOWN;
		}
		else if (substr($this->mime, 0, 5) == 'text/') {
			$format = 'text';
		}
		else {
			$format = null;
		}

		return $format;
	}

	public function editorType(): ?string
	{
		$format = $this->renderFormat();

		if ($format == 'text') {
			return 'code';
		}
		elseif ($format == Render::FORMAT_SKRIV || $format == Render::FORMAT_MARKDOWN) {
			return 'web';
		}

		return null;
	}
}

Modified src/include/lib/Garradin/Entities/Services/Reminder.php from [4702329012] to [52650ee9cf].

76
77
78
79
80
81
82
83
84

85
86
87
88
89
90
91
92
93
94
95
96
97
				'select' => 'm.' . $identity,
			],
			'email' => [
				'label' => 'Adresse e-mail',
				'select' => 'm.email',
			],
			'date' => [
				'label' => 'Date',
				'select' => 'srs.date',

			],
		];

		$tables = 'services_reminders_sent srs
			INNER JOIN membres m ON m.id = srs.id_user';
		$conditions = sprintf('srs.id_reminder = %d', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		return $list;
	}

}







|
|
>













76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
				'select' => 'm.' . $identity,
			],
			'email' => [
				'label' => 'Adresse e-mail',
				'select' => 'm.email',
			],
			'date' => [
				'label' => 'Date d\'envoi',
				'select' => 'srs.sent_date',
				'order' => 'srs.sent_date %s, srs.id %1$s',
			],
		];

		$tables = 'services_reminders_sent srs
			INNER JOIN membres m ON m.id = srs.id_user';
		$conditions = sprintf('srs.id_reminder = %d', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		return $list;
	}

}

Modified src/include/lib/Garradin/Entities/Web/Page.php from [7794458169] to [7dc41152d2].

4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
..
49
50
51
52
53
54
55

56
57
58
59
60
61
62
..
68
69
70
71
72
73
74


75
76
77
78
79
80
81
...
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
...
199
200
201
202
203
204
205


206
207

208
209
210
211
212










213
214
215
216
217
218
219
...
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
...
437
438
439
440
441
442
443
444





445
446
447
448
449
450











451




452
453
454
455
456
457
458

use Garradin\DB;
use Garradin\Entity;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Entities\Files\File;
use Garradin\Files\Files;
use Garradin\Web\Render\Skriv;

use KD2\DB\EntityManager as EM;

use const Garradin\WWW_URL;

class Page extends Entity
{
................................................................................

	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';

	const FORMATS_LIST = [
		self::FORMAT_SKRIV => 'SkrivML',

		self::FORMAT_ENCRYPTED => 'Chiffré',
		self::FORMAT_MARKDOWN => 'Markdown',
	];

	const STATUS_ONLINE = 'online';
	const STATUS_DRAFT = 'draft';

................................................................................
	const TYPE_CATEGORY = 1;
	const TYPE_PAGE = 2;

	const TEMPLATES = [
		self::TYPE_PAGE => 'article.html',
		self::TYPE_CATEGORY => 'category.html',
	];



	protected $_file;
	protected $_attachments;

	static public function create(int $type, ?string $parent, string $title, string $status = self::STATUS_ONLINE): self
	{
		$page = new self;
................................................................................
	}

	public function render(array $options = []): string
	{
		if (!$this->file()) {
			throw new \LogicException('File does not exist: '  . $this->file_path);
		}
		if ($this->format == self::FORMAT_SKRIV) {
			return \Garradin\Web\Render\Skriv::render($this->file(), $this->content, $options);
		}
		else if ($this->format == self::FORMAT_ENCRYPTED) {
			return \Garradin\Web\Render\EncryptedSkriv::render($this->file(), $this->content);
		}

		throw new \LogicException('Invalid format: ' . $this->format);

	}

	public function preview(string $content): string
	{
		return Skriv::render($this->file(), $content, ['prefix' => '#']);
	}

	public function filepath(bool $stored = true): string
	{
		return $stored && isset($this->file_path) ? $this->file_path : File::CONTEXT_WEB . '/' . $this->path . '/' . $this->_name;
	}

................................................................................
	{
		$content = $this->format == self::FORMAT_ENCRYPTED ? null : strip_tags($this->render());
		$this->file()->indexForSearch(null, $content, $this->title);
	}

	public function save(): bool
	{


		if (isset($this->_modified['uri']) || isset($this->_modified['path'])) {
			$this->set('file_path', $this->filepath(false));

		}

		$current_path = $this->_modified['file_path'] ?? $this->file_path;
		parent::save();
		$this->syncFile($current_path);











		return true;
	}

	public function delete(): bool
	{
		Files::get(Utils::dirname($this->file_path))->delete();
................................................................................
		$this->assert(trim($this->title) !== '', 'Le titre ne peut rester vide');
		$this->assert(trim($this->file_path) !== '', 'Le chemin de fichier ne peut rester vide');
		$this->assert(trim($this->path) !== '', 'Le chemin ne peut rester vide');
		$this->assert(trim($this->uri) !== '', 'L\'URI ne peut rester vide');
		$this->assert($this->path !== $this->parent, 'Invalid parent page');
		$this->assert($this->parent === '' || $db->test(self::TABLE, 'path = ?', $this->parent), 'Page parent inexistante');

		$this->assert(!$this->exists() || !$db->test(self::TABLE, 'path = ? AND id != ?', $this->path, $this->id()), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->uri);
		$this->assert($this->exists() || !$db->test(self::TABLE, 'path = ?', $this->path), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->path);
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}
................................................................................
	{
		if (!$this->importFromRaw($file->fetch())) {
			throw new \LogicException('Invalid page content: ' . $file->parent);
		}

		$this->set('modified', $file->modified);

		foreach (Files::list($file->parent) as $subfile) {





			if ($subfile->type == File::TYPE_DIRECTORY) {
				$this->set('type', self::TYPE_CATEGORY);
				return;
			}
		}












		$this->set('type', self::TYPE_PAGE); // Default




	}

	static public function fromFile(File $file): self
	{
		$page = new self;

		// Path is relative to web root







|







 







>







 







>
>







 







<
<
|
<
<
<
<
<
>




|







 







>
>


>





>
>
>
>
>
>
>
>
>
>







 







|
|







 







|
>
>
>
>
>

|
<



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







4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
..
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
..
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
...
138
139
140
141
142
143
144


145





146
147
148
149
150
151
152
153
154
155
156
157
158
...
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
...
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
...
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

use Garradin\DB;
use Garradin\Entity;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Entities\Files\File;
use Garradin\Files\Files;
use Garradin\Web\Render\Render;

use KD2\DB\EntityManager as EM;

use const Garradin\WWW_URL;

class Page extends Entity
{
................................................................................

	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';

	const FORMATS_LIST = [
		self::FORMAT_SKRIV => 'SkrivML',
		self::FORMAT_MARKDOWN => 'MarkDown',
		self::FORMAT_ENCRYPTED => 'Chiffré',
		self::FORMAT_MARKDOWN => 'Markdown',
	];

	const STATUS_ONLINE = 'online';
	const STATUS_DRAFT = 'draft';

................................................................................
	const TYPE_CATEGORY = 1;
	const TYPE_PAGE = 2;

	const TEMPLATES = [
		self::TYPE_PAGE => 'article.html',
		self::TYPE_CATEGORY => 'category.html',
	];

	const DUPLICATE_URI_ERROR = 42;

	protected $_file;
	protected $_attachments;

	static public function create(int $type, ?string $parent, string $title, string $status = self::STATUS_ONLINE): self
	{
		$page = new self;
................................................................................
	}

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








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

	public function preview(string $content): string
	{
		return Render::render($this->format, $this->file(), $content, ['prefix' => '#']);
	}

	public function filepath(bool $stored = true): string
	{
		return $stored && isset($this->file_path) ? $this->file_path : File::CONTEXT_WEB . '/' . $this->path . '/' . $this->_name;
	}

................................................................................
	{
		$content = $this->format == self::FORMAT_ENCRYPTED ? null : strip_tags($this->render());
		$this->file()->indexForSearch(null, $content, $this->title);
	}

	public function save(): bool
	{
		$change_parent = null;

		if (isset($this->_modified['uri']) || isset($this->_modified['path'])) {
			$this->set('file_path', $this->filepath(false));
			$change_parent = $this->_modified['path'];
		}

		$current_path = $this->_modified['file_path'] ?? $this->file_path;
		parent::save();
		$this->syncFile($current_path);

		// Rename/move children
		if ($change_parent) {
			$db = DB::getInstance();
			$sql = sprintf('UPDATE web_pages
				SET path = %s || substr(path, %d), parent = %1$s || substr(parent, %2$d)
				WHERE parent LIKE %s;',
				$db->quote($this->path), strlen($change_parent) + 1, $db->quote($change_parent . '/%'));
			$db->exec($sql);
		}

		return true;
	}

	public function delete(): bool
	{
		Files::get(Utils::dirname($this->file_path))->delete();
................................................................................
		$this->assert(trim($this->title) !== '', 'Le titre ne peut rester vide');
		$this->assert(trim($this->file_path) !== '', 'Le chemin de fichier ne peut rester vide');
		$this->assert(trim($this->path) !== '', 'Le chemin ne peut rester vide');
		$this->assert(trim($this->uri) !== '', 'L\'URI ne peut rester vide');
		$this->assert($this->path !== $this->parent, 'Invalid parent page');
		$this->assert($this->parent === '' || $db->test(self::TABLE, 'path = ?', $this->parent), 'Page parent inexistante');

		$this->assert(!$this->exists() || !$db->test(self::TABLE, 'uri = ? AND id != ?', $this->uri, $this->id()), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->uri, self::DUPLICATE_URI_ERROR);
		$this->assert($this->exists() || !$db->test(self::TABLE, 'uri = ?', $this->uri), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->uri, self::DUPLICATE_URI_ERROR);
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}
................................................................................
	{
		if (!$this->importFromRaw($file->fetch())) {
			throw new \LogicException('Invalid page content: ' . $file->parent);
		}

		$this->set('modified', $file->modified);

		$this->set('type', $this->checkRealType());
	}

	public function checkRealType(): int
	{
		foreach (Files::list(Utils::dirname($this->filepath())) as $subfile) {
			if ($subfile->type == File::TYPE_DIRECTORY) {
				return self::TYPE_CATEGORY;

			}
		}

		return self::TYPE_PAGE;
	}

	public function toggleType(): void
	{
		$real_type = $this->checkRealType();

		if ($real_type == self::TYPE_CATEGORY) {
			$this->set('type', $real_type);
		}
		elseif ($this->type == self::TYPE_CATEGORY) {
			$this->set('type', self::TYPE_PAGE);
		}
		else {
			$this->set('type', self::TYPE_CATEGORY);
		}
	}

	static public function fromFile(File $file): self
	{
		$page = new self;

		// Path is relative to web root

Modified src/include/lib/Garradin/Entity.php from [e19cdc4d7b] to [722d6cf126].

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
				return \DateTime::createFromFormat('d/m/Y H:i', $value);
			}
		}

		return parent::filterUserValue($type, $value, $key);
	}

	protected function assert(?bool $test, string $message = null): void
	{
		if ($test) {
			return;
		}

		if (null === $message) {
			$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
			$caller_class = array_pop($backtrace);
			$caller = array_pop($backtrace);
			$message = sprintf('Entity assertion fail from class %s on line %d', $caller_class['class'], $caller['line']);
			throw new \UnexpectedValueException($message);
		}
		else {
			throw new ValidationException($message);
		}
	}

	// Add plugin signals to save/delete
	public function save(): bool
	{
		$name = get_class($this);







|













|







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
				return \DateTime::createFromFormat('d/m/Y H:i', $value);
			}
		}

		return parent::filterUserValue($type, $value, $key);
	}

	protected function assert(?bool $test, string $message = null, int $code = 0): void
	{
		if ($test) {
			return;
		}

		if (null === $message) {
			$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
			$caller_class = array_pop($backtrace);
			$caller = array_pop($backtrace);
			$message = sprintf('Entity assertion fail from class %s on line %d', $caller_class['class'], $caller['line']);
			throw new \UnexpectedValueException($message);
		}
		else {
			throw new ValidationException($message, $code);
		}
	}

	// Add plugin signals to save/delete
	public function save(): bool
	{
		$name = get_class($this);

Modified src/include/lib/Garradin/Files/Files.php from [c7cc1da4fa] to [893a79714f].

7
8
9
10
11
12
13

14
15
16
17
18
19
20
..
53
54
55
56
57
58
59











































60
61
62
63
64
65
66
...
287
288
289
290
291
292
293
294
295
296
297
298
299
300
use Garradin\Utils;
use Garradin\ValidationException;
use Garradin\Membres\Session;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;

use KD2\DB\EntityManager as EM;


use const Garradin\{FILE_STORAGE_BACKEND, FILE_STORAGE_QUOTA, FILE_STORAGE_CONFIG};

class Files
{
	/**
	 * To enable or disable quota check
................................................................................
		if ($parent !== '') {
			File::validatePath($parent);
		}

		// Update this path
		return self::callStorage('list', $parent);
	}












































	static public function delete(string $path): void
	{
		$file = self::get($path);

		if (!$file) {
			return;
................................................................................
			return;
		}

		$db = DB::getInstance();
		$db->begin();
		$db->exec('CREATE TEMP TABLE IF NOT EXISTS tmp_files AS SELECT * FROM files WHERE 0;');

		foreach (Files::list(File::CONTEXT_TRANSACTION) as $file) {
			$db->insert('tmp_files', $file->asArray(true));
		}

		$db->commit();
	}
}







>







 







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







 







|






7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
..
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
...
331
332
333
334
335
336
337
338
339
340
341
342
343
344
use Garradin\Utils;
use Garradin\ValidationException;
use Garradin\Membres\Session;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;

use KD2\DB\EntityManager as EM;
use KD2\ZipWriter;

use const Garradin\{FILE_STORAGE_BACKEND, FILE_STORAGE_QUOTA, FILE_STORAGE_CONFIG};

class Files
{
	/**
	 * To enable or disable quota check
................................................................................
		if ($parent !== '') {
			File::validatePath($parent);
		}

		// Update this path
		return self::callStorage('list', $parent);
	}

	static public function zip(string $parent, ?Session $session)
	{
		$file = Files::get($parent);

		if (!$file) {
			throw new UserException('Ce répertoire n\'existe pas.');
		}

		if ($session && !$file->checkReadAccess($session)) {
			throw new UserException('Vous n\'avez pas accès à ce répertoire');
		}

		$zip = new ZipWriter('php://output');
		$zip->setCompression(0);

		$add_file = function ($subpath) use ($zip, $parent, &$add_file) {
			foreach (self::list($subpath) as $file) {
				if ($file->type == $file::TYPE_DIRECTORY) {
					$add_file($file->path);
					continue;
				}

				$dest_path = substr($file->path, strlen($parent . '/'));
				$zip->add($dest_path, null, $file->fullpath());
			}
		};

		$add_file($parent);

		$zip->close();
	}

	static public function listForContext(string $context, ?string $ref = null)
	{
		$path = $context;

		if ($ref) {
			$path .= '/' . $ref;
		}

		return self::list($path);
	}

	static public function delete(string $path): void
	{
		$file = self::get($path);

		if (!$file) {
			return;
................................................................................
			return;
		}

		$db = DB::getInstance();
		$db->begin();
		$db->exec('CREATE TEMP TABLE IF NOT EXISTS tmp_files AS SELECT * FROM files WHERE 0;');

		foreach (Files::list($parent) as $file) {
			$db->insert('tmp_files', $file->asArray(true));
		}

		$db->commit();
	}
}

Modified src/include/lib/Garradin/Files/Storage/FileSystem.php from [c060f854cf] to [33ec3a8fb7].

207
208
209
210
211
212
213






































214
215
216
217
218
219
220
			// directory_blabla
			// file_image.jpeg
			$files[$file->getType() . '_' .$file->getFilename()] = self::_SplToFile($file);
		}

		return Utils::knatcasesort($files);
	}







































	static public function getTotalSize(): float
	{
		if (null !== self::$_size) {
			return self::$_size;
		}








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







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
			// directory_blabla
			// file_image.jpeg
			$files[$file->getType() . '_' .$file->getFilename()] = self::_SplToFile($file);
		}

		return Utils::knatcasesort($files);
	}

	static public function listDirectoriesRecursively(string $path): array
	{
		$fullpath = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path);
		$fullpath = rtrim($fullpath, DIRECTORY_SEPARATOR);

		if (!file_exists($fullpath)) {
			return [];
		}

		return self::_recurseGlob($fullpath, '*', \GLOB_ONLYDIR);
	}

	static protected function _recurseGlob(string $path, string $pattern = '*', int $flags = 0): array
	{
		$target = $path . DIRECTORY_SEPARATOR . $pattern;
		$list = [];

		// glob is the fastest way to recursely list directories and files apparently
		// after comparing with opendir(), dir() and filesystem recursive iterators
		foreach(glob($target, $flags) as $file) {
			$file = basename($file);

			if ($file[0] == '.') {
				continue;
			}

			$list[] = $file;

			if (is_dir($path . DIRECTORY_SEPARATOR . $file)) {
				foreach (self::_recurseGlob($path . DIRECTORY_SEPARATOR . $file, $pattern, $flags) as $subfile) {
					$list[] = $file . DIRECTORY_SEPARATOR . $subfile;
				}
			}
		}

		return $list;
	}

	static public function getTotalSize(): float
	{
		if (null !== self::$_size) {
			return self::$_size;
		}

Modified src/include/lib/Garradin/Files/Storage/SQLite.php from [95f7674e72] to [ce000d8bf2].

120
121
122
123
124
125
126












127
128
129
130
131
132
133
		return EM::findOne(File::class, $sql, $path);
	}

	static public function list(string $path): array
	{
		return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE parent = ? ORDER BY type DESC, name COLLATE NOCASE ASC;', $path);
	}













	static public function exists(string $path): bool
	{
		return DB::getInstance()->test('files', 'path = ?', $path);
	}

	static public function delete(File $file): bool







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







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
		return EM::findOne(File::class, $sql, $path);
	}

	static public function list(string $path): array
	{
		return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE parent = ? ORDER BY type DESC, name COLLATE NOCASE ASC;', $path);
	}

	static public function listDirectoriesRecursively(string $path): array
	{
		$files = [];
		$it = DB::getInstance()->iterate('SELECT path FROM files WHERE parent LIKE ? ORDER BY path;', $path . '/%');

		foreach ($it as $file) {
			$files[] = $file->path;
		}

		return $files;
	}

	static public function exists(string $path): bool
	{
		return DB::getInstance()->test('files', 'path = ?', $path);
	}

	static public function delete(File $file): bool

Modified src/include/lib/Garradin/Files/Storage/StorageInterface.php from [dea0474d61] to [67b35b24ca].

64
65
66
67
68
69
70






71
72
73
74
75
76
77
	static public function get(string $path): ?File;

	/**
	 * Return an array of File objects for a given path
	 */
	static public function list(string $path): array;







	/**
	 * Moves a file to a new path, when its name or path has changed
	 */
	static public function move(File $file, string $new_path): bool;

	/**
	 * Return total size of used space by files stored in this backed







>
>
>
>
>
>







64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
	static public function get(string $path): ?File;

	/**
	 * Return an array of File objects for a given path
	 */
	static public function list(string $path): array;

	/**
	 * Return an array of (string) paths of all subdirectories inside a path
	 * @param  string $path Parent path
	 */
	static public function listDirectoriesRecursively(string $path): array;

	/**
	 * Moves a file to a new path, when its name or path has changed
	 */
	static public function move(File $file, string $new_path): bool;

	/**
	 * Return total size of used space by files stored in this backed

Modified src/include/lib/Garradin/Files/Users.php from [2aef448cd6] to [55c1feeac2].

15
16
17
18
19
20
21




22
23
24
25
26
27
28
		],
		'identity' => [
			'select' => '',
			'label' => '',
		],
		'path' => [
		],




	];

	static public function list()
	{
		Files::syncVirtualTable(File::CONTEXT_USER);

		$config = Config::getInstance();







>
>
>
>







15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
		],
		'identity' => [
			'select' => '',
			'label' => '',
		],
		'path' => [
		],
		'id' => [
			'label' => null,
			'select' => 'm.id',
		],
	];

	static public function list()
	{
		Files::syncVirtualTable(File::CONTEXT_USER);

		$config = Config::getInstance();

Modified src/include/lib/Garradin/Membres/Import.php from [2afb2583d8] to [9fc2a43ba6].

8
9
10
11
12
13
14


15
16
17
18
19
20
21
...
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
...
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
use Garradin\Utils;
use Garradin\CSV;
use Garradin\CSV_Custom;
use Garradin\UserException;

class Import
{


	/**
	 * Importer un CSV générique
	 * @return boolean                   TRUE en cas de succès
	 */
	public function fromCustomCSV(CSV_Custom $csv, int $current_user_id)
	{
		$db = DB::getInstance();
................................................................................
					$values = explode(';', $data[$name]);
					$data[$name] = 0;

					foreach ($values as $v) {
						$v = trim($v);
						$found = array_search($v, $champs_multiples[$name]->options);

						if ($found) {
							$data[$name] |= 0x01 << $found;
						}
					}
				}
			}

			if (!empty($data['numero']) && $data['numero'] > 0)
................................................................................
	public function toODS(array $list = null): void
	{
		list($champs, $result, $name) = $this->export($list);
		CSV::toODS($name, $result, $champs, [$this, 'exportRow']);
	}

	public function exportRow(\stdClass $row) {
		// Pas hyper efficace, il faudrait ne pas récupérer la liste pour chaque ligne... FIXME
		$champs_multiples = Config::getInstance()->get('champs_membres')->getMultiples();









		// convertir les champs à choix multiple de binaire vers liste séparée par des points virgules
		foreach ($champs_multiples as $id=>$config) {

			$out = [];

			foreach ($config->options as $b => $name)
			{
				if ($row->$id & (0x01 << $b)) {
					$out[] = $name;
				}
			}

			$row->$id = implode(';', $out);

		}

		return $row;
	}
}







>
>







 







|







 







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

|
|
|
|
|
|

|
|





8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
...
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
use Garradin\Utils;
use Garradin\CSV;
use Garradin\CSV_Custom;
use Garradin\UserException;

class Import
{
	protected $champs;

	/**
	 * Importer un CSV générique
	 * @return boolean                   TRUE en cas de succès
	 */
	public function fromCustomCSV(CSV_Custom $csv, int $current_user_id)
	{
		$db = DB::getInstance();
................................................................................
					$values = explode(';', $data[$name]);
					$data[$name] = 0;

					foreach ($values as $v) {
						$v = trim($v);
						$found = array_search($v, $champs_multiples[$name]->options);

						if ($found !== false) {
							$data[$name] |= 0x01 << $found;
						}
					}
				}
			}

			if (!empty($data['numero']) && $data['numero'] > 0)
................................................................................
	public function toODS(array $list = null): void
	{
		list($champs, $result, $name) = $this->export($list);
		CSV::toODS($name, $result, $champs, [$this, 'exportRow']);
	}

	public function exportRow(\stdClass $row) {
		if (null === $this->champs) {
			$this->champs = Config::getInstance()->get('champs_membres')->getAll();
		}

		foreach ($this->champs as $id => $config) {
			if ($config->type == 'date') {
				$row->$id = \DateTime::createFromFormat('!Y-m-d', $row->$id);
			}
			elseif ($config->type == 'datetime') {
				$row->$id = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->$id);
			}
			// convertir les champs à choix multiple de binaire vers liste séparée par des points virgules

			elseif ($config->type == 'multiple') {
				$out = [];

				foreach ($config->options as $b => $name)
				{
					if ($row->$id & (0x01 << $b)) {
						$out[] = $name;
					}
				}

				$row->$id = implode(';', $out);
			}
		}

		return $row;
	}
}

Modified src/include/lib/Garradin/Plugin.php from [1f8c21a59a] to [2c3e05e869].

486
487
488
489
490
491
492

493








494
495
496
497
498
499
500
				$db->update('plugins', ['menu_condition' => $new_condition], 'id = :id', ['id' => $id]);
				$row->menu_condition = $new_condition;
			}

			$condition = strtr($row->menu_condition, $permissions);

			$condition = preg_replace_callback('/\{\$user\.(\w+)\}/', function ($m) use ($user, $db) {

				return property_exists($user, $m[1]) ? $db->quote($user->{$m[1]}) : 'NULL';








			}, $condition);

			$query = 'SELECT 1 WHERE ' . $condition . ';';

			$res = $db->protectSelect(['membres' => []], $query);

			if (!$db->firstColumn($query))







>
|
>
>
>
>
>
>
>
>







486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
				$db->update('plugins', ['menu_condition' => $new_condition], 'id = :id', ['id' => $id]);
				$row->menu_condition = $new_condition;
			}

			$condition = strtr($row->menu_condition, $permissions);

			$condition = preg_replace_callback('/\{\$user\.(\w+)\}/', function ($m) use ($user, $db) {
				$prop = $m[1];
				if (!property_exists($user, $prop)) {
					return 'NULL';
				}

				if (substr($prop, 0, 5) == 'perm_') {
					return (int) $user->$prop;
				}

				return $db->quote($user->$prop);
			}, $condition);

			$query = 'SELECT 1 WHERE ' . $condition . ';';

			$res = $db->protectSelect(['membres' => []], $query);

			if (!$db->firstColumn($query))

Modified src/include/lib/Garradin/Sauvegarde.php from [b14c3f16f0] to [c99705a80b].

413
414
415
416
417
418
419


420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
		}
		catch (\Exception $e)
		{
			throw new UserException('Le fichier fourni n\'est pas une base de données valide. ' .
				'Message d\'erreur de SQLite : ' . $e->getMessage(), self::NOT_A_DB);
		}



		try {
			// Regardons ensuite si la base de données n'est pas corrompue
			$check = $db->querySingle('PRAGMA integrity_check;', false);
		}
		catch (\Exception $e)
		{
			// Ici SQLite peut rejeter un message type "file is encrypted or is not a db"
			throw new UserException('Le fichier fourni n\'est pas une base de données valide. ' .
				'Message d\'erreur de SQLite : ' . $e->getMessage(), self::NOT_A_DB);
		}

		if (strtolower(trim($check)) != 'ok')
		{
			throw new UserException('Le fichier fourni est corrompu. SQLite a trouvé ' . $check . ' erreurs.');
		}

		if ($check_foreign_keys)
		{
			$check = $db->querySingle('PRAGMA foreign_key_check;');

			if ($check)







>
>













|







413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
		}
		catch (\Exception $e)
		{
			throw new UserException('Le fichier fourni n\'est pas une base de données valide. ' .
				'Message d\'erreur de SQLite : ' . $e->getMessage(), self::NOT_A_DB);
		}

		DB::registerCustomFunctions($db);

		try {
			// Regardons ensuite si la base de données n'est pas corrompue
			$check = $db->querySingle('PRAGMA integrity_check;', false);
		}
		catch (\Exception $e)
		{
			// Ici SQLite peut rejeter un message type "file is encrypted or is not a db"
			throw new UserException('Le fichier fourni n\'est pas une base de données valide. ' .
				'Message d\'erreur de SQLite : ' . $e->getMessage(), self::NOT_A_DB);
		}

		if (strtolower(trim($check)) != 'ok')
		{
			throw new UserException('Le fichier fourni est corrompu. Erreur SQLite : ' . $check);
		}

		if ($check_foreign_keys)
		{
			$check = $db->querySingle('PRAGMA foreign_key_check;');

			if ($check)

Modified src/include/lib/Garradin/Services/Reminders.php from [a76c257944] to [c3c5d5e6f2].

1
2
3
4
5
6

7
8
9
10
11
12
13
..
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
..
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
..
99
100
101
102
103
104
105

106
107
108
109
110
111
112
...
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
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;

use Garradin\Plugin;
use Garradin\Utils;
use Garradin\Entities\Services\Reminder;
use KD2\DB\EntityManager;

use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;
................................................................................
	static public function get(int $id)
	{
		return EntityManager::findOneById(Reminder::class, $id);
	}

	static public function listSentForUser(int $user_id)
	{
		return DB::getInstance()->get('SELECT rs.date AS sent_date, r.delay, s.label, rs.id AS sent_id, s.id AS service_id














			FROM services_reminders_sent rs
			INNER JOIN services_reminders r ON r.id = rs.id_reminder
			INNER JOIN services s ON s.id = rs.id_service
			WHERE rs.id_user = ?;', $user_id);

	}





	static public function listSentForReminder(int $reminder_id)
	{
		return DB::getInstance()->get('SELECT rs.date AS sent_date, r.delay, s.label, rs.id AS sent_id, s.id AS service_id
			FROM services_reminders_sent rs
			INNER JOIN services_reminders r ON r.id = rs.id_reminder
			INNER JOIN services s ON s.id = rs.id_service
			WHERE rs.id_reminder = ?;', $reminder_id);
	}

	static public function listForService(int $service_id)
	{
		return DB::getInstance()->get('SELECT * FROM services_reminders WHERE id_service = ? ORDER BY delay, subject;', $service_id);
	}
................................................................................
	/**
	 * Envoi de mail pour rappel automatisé
	 */
	static public function sendAuto(\stdClass $reminder)
	{
		$replace = [
			'identite'        => $reminder->identity,
			'date_rappel'     => Utils::date_fr($reminder->reminder_date),
			'date_expiration' => Utils::date_fr($reminder->expiry_date),
			'nb_jours'        => $reminder->nb_days,
			'delai'           => $reminder->delay,
		];

		$subject = self::replaceTagsInContent($reminder->subject, $replace);
		$text = self::replaceTagsInContent($reminder->body, $replace);

................................................................................
		Utils::sendEmail(Utils::EMAIL_CONTEXT_PRIVATE, $reminder->email, $subject, $text, $reminder->id_user);

		$db = DB::getInstance();
		$db->insert('services_reminders_sent', [
			'id_service'  => $reminder->id_service,
			'id_user'     => $reminder->id_user,
			'id_reminder' => $reminder->id_reminder,

		]);

		Plugin::fireSignal('rappels.auto', $reminder);

		return true;
	}

................................................................................

		$sql = 'SELECT
			date(su.expiry_date, sr.delay || \' days\') AS reminder_date,
			ABS(julianday(date()) - julianday(expiry_date)) AS nb_days,
			MAX(sr.delay) AS delay, sr.subject, sr.body, s.label, s.description,
			su.expiry_date, sr.id AS id_reminder, su.id_service, su.id_user,
			m.email, m.%s AS identity
			FROM services_users su
			INNER JOIN services s ON s.id = su.id_service
			INNER JOIN services_reminders sr ON sr.id_service = su.id_service

			-- Join with users, but not ones part of a hidden category
			INNER JOIN membres m ON su.id_user = m.id
				AND m.email IS NOT NULL
				AND (m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1))
			-- Join with sent reminders to exclude users that already have received this reminder
			LEFT JOIN services_reminders_sent srs ON srs.id_reminder = sr.id AND srs.id_user = su.id_user
			WHERE
				date() > date(su.expiry_date, sr.delay || \' days\')
				AND srs.id IS NULL
			GROUP BY su.id_user, s.id
			ORDER BY su.id_user;';

		$sql = sprintf($sql, $config->get('champ_identite'));

		foreach ($db->iterate($sql) as $row)
		{
			self::sendAuto($row);
		}

		return true;
	}
}






>







 







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


|
|
|
|







 







|
|







 







>







 







|
|
|
>





|


|
|












1
2
3
4
5
6
7
8
9
10
11
12
13
14
..
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
...
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
...
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
...
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\Entities\Services\Reminder;
use KD2\DB\EntityManager;

use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;
................................................................................
	static public function get(int $id)
	{
		return EntityManager::findOneById(Reminder::class, $id);
	}

	static public function listSentForUser(int $user_id)
	{
		$columns = [
			'label' => [
				'label' => 'Activité',
				'select' => 's.label',
			],
			'delay' => [
				'label' => 'Délai du rappel',
				'select' => 'r.delay',
			],
			'date' => [
				'label' => 'Date d\'envoi du message',
				'select' => 'srs.sent_date',
			],
		];

		$tables = 'services_reminders_sent srs
			INNER JOIN services_reminders r ON r.id = srs.id_reminder
			INNER JOIN services s ON s.id = srs.id_service';

		$conditions = sprintf('srs.id_user = %d', $user_id);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		return $list;
	}

	static public function listSentForReminder(int $reminder_id)
	{
		return DB::getInstance()->get('SELECT srs.sent_date, r.delay, s.label, rs.id AS sent_id, s.id AS service_id
			FROM services_reminders_sent srs
			INNER JOIN services_reminders r ON r.id = srs.id_reminder
			INNER JOIN services s ON s.id = srs.id_service
			WHERE rs.id_reminder = ?;', $reminder_id);
	}

	static public function listForService(int $service_id)
	{
		return DB::getInstance()->get('SELECT * FROM services_reminders WHERE id_service = ? ORDER BY delay, subject;', $service_id);
	}
................................................................................
	/**
	 * Envoi de mail pour rappel automatisé
	 */
	static public function sendAuto(\stdClass $reminder)
	{
		$replace = [
			'identite'        => $reminder->identity,
			'date_rappel'     => Utils::date_fr($reminder->reminder_date, 'd/m/Y'),
			'date_expiration' => Utils::date_fr($reminder->expiry_date, 'd/m/Y'),
			'nb_jours'        => $reminder->nb_days,
			'delai'           => $reminder->delay,
		];

		$subject = self::replaceTagsInContent($reminder->subject, $replace);
		$text = self::replaceTagsInContent($reminder->body, $replace);

................................................................................
		Utils::sendEmail(Utils::EMAIL_CONTEXT_PRIVATE, $reminder->email, $subject, $text, $reminder->id_user);

		$db = DB::getInstance();
		$db->insert('services_reminders_sent', [
			'id_service'  => $reminder->id_service,
			'id_user'     => $reminder->id_user,
			'id_reminder' => $reminder->id_reminder,
			'due_date'    => $reminder->reminder_date,
		]);

		Plugin::fireSignal('rappels.auto', $reminder);

		return true;
	}

................................................................................

		$sql = 'SELECT
			date(su.expiry_date, sr.delay || \' days\') AS reminder_date,
			ABS(julianday(date()) - julianday(expiry_date)) AS nb_days,
			MAX(sr.delay) AS delay, sr.subject, sr.body, s.label, s.description,
			su.expiry_date, sr.id AS id_reminder, su.id_service, su.id_user,
			m.email, m.%s AS identity
			FROM services_reminders sr
			INNER JOIN services s ON s.id = sr.id_service
			-- Select latest subscription to a service (MAX) only
			INNER JOIN (SELECT MAX(expiry_date) AS expiry_date, id_user, id_service FROM services_users GROUP BY id_user, id_service) AS su ON s.id = su.id_service
			-- Join with users, but not ones part of a hidden category
			INNER JOIN membres m ON su.id_user = m.id
				AND m.email IS NOT NULL
				AND (m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1))
			-- Join with sent reminders to exclude users that already have received this reminder
			LEFT JOIN (SELECT id, MAX(due_date) AS due_date, id_user, id_reminder FROM services_reminders_sent GROUP BY id_user, id_reminder) AS srs ON su.id_user = srs.id_user AND srs.id_reminder = sr.id
			WHERE
				date() > date(su.expiry_date, sr.delay || \' days\')
				AND (srs.id IS NULL OR srs.due_date < date(su.expiry_date, (sr.delay - 1) || \' days\'))
			GROUP BY su.id_user, sr.id_service
			ORDER BY su.id_user;';

		$sql = sprintf($sql, $config->get('champ_identite'));

		foreach ($db->iterate($sql) as $row)
		{
			self::sendAuto($row);
		}

		return true;
	}
}

Modified src/include/lib/Garradin/Template.php from [fbe9a25cda] to [7374ce76d8].

94
95
96
97
98
99
100
101

102
103
104
105
106
107
108
		$this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
		$this->register_modifier('get_country_name', ['Garradin\Utils', 'getCountryName']);
		$this->register_modifier('format_tel', [$this, 'formatPhoneNumber']);
		$this->register_modifier('abs', 'abs');
		$this->register_modifier('display_champ_membre', [$this, 'displayChampMembre']);

		$this->register_modifier('format_skriv', function ($str) {
			return Skriv::render(null, (string) $str);

		});

		foreach (CommonModifiers::MODIFIERS_LIST as $key => $name) {
			$this->register_modifier(is_int($key) ? $name : $key, is_int($key) ? [CommonModifiers::class, $name] : $name);
		}

		foreach (CommonModifiers::FUNCTIONS_LIST as $key => $name) {







|
>







94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
		$this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
		$this->register_modifier('get_country_name', ['Garradin\Utils', 'getCountryName']);
		$this->register_modifier('format_tel', [$this, 'formatPhoneNumber']);
		$this->register_modifier('abs', 'abs');
		$this->register_modifier('display_champ_membre', [$this, 'displayChampMembre']);

		$this->register_modifier('format_skriv', function ($str) {
			$skriv = new Skriv(null);
			return $skriv->render((string) $str);
		});

		foreach (CommonModifiers::MODIFIERS_LIST as $key => $name) {
			$this->register_modifier(is_int($key) ? $name : $key, is_int($key) ? [CommonModifiers::class, $name] : $name);
		}

		foreach (CommonModifiers::FUNCTIONS_LIST as $key => $name) {

Modified src/include/lib/Garradin/Upgrade.php from [5bb005e6e9] to [0b2351ebf8].

276
277
278
279
280
281
282










































283
284
285
286
287
288
289
				$config->set('admin_background', $file ? Config::DEFAULT_FILES['admin_background'] : null);

				$file = Files::get(Config::DEFAULT_FILES['admin_homepage']);
				$config->set('admin_homepage', $file ? Config::DEFAULT_FILES['admin_homepage'] : null);

				$config->save();
			}











































			// Vérification de la cohérence des clés étrangères
			$db->foreignKeyCheck();

			// Delete local cached files
			Utils::resetCache(USER_TEMPLATES_CACHE_ROOT);
			Utils::resetCache(STATIC_CACHE_ROOT);







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







276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
				$config->set('admin_background', $file ? Config::DEFAULT_FILES['admin_background'] : null);

				$file = Files::get(Config::DEFAULT_FILES['admin_homepage']);
				$config->set('admin_homepage', $file ? Config::DEFAULT_FILES['admin_homepage'] : null);

				$config->save();
			}

			if (version_compare($v, '1.1.7', '<')) {
				$db->begin();
				$db->import(ROOT . '/include/data/1.1.7_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.8', '<')) {
				$db->begin();
				// Force sync to remove pages that don't exist anymore
				\Garradin\Web\Web::sync();

				$uris = [];
				$i = 1;

				$treat_duplicate_uris = function ($path) use (&$i, &$uris, &$treat_duplicate_uris) {
					// Rename duplicate URIs
					foreach (Files::callStorage('list', $path) as $f) {
						if ($f->type != $f::TYPE_DIRECTORY) {
							continue;
						}

						if (array_key_exists($f->name, $uris)) {
							$f->changeFileName($f->name . '_' . $i++);
						}

						$uris[$f->name] = $f->path;

						$treat_duplicate_uris($f->path);
					}
				};

				$treat_duplicate_uris(\Garradin\Entities\Files\File::CONTEXT_WEB);

				// Force sync to add renamed pages
				\Garradin\Web\Web::sync();

				// Add UNIQUE index
				$db->import(ROOT . '/include/data/1.1.8_migration.sql');

				$db->commit();
			}

			// Vérification de la cohérence des clés étrangères
			$db->foreignKeyCheck();

			// Delete local cached files
			Utils::resetCache(USER_TEMPLATES_CACHE_ROOT);
			Utils::resetCache(STATIC_CACHE_ROOT);

Modified src/include/lib/Garradin/UserTemplate/Sections.php from [edc615ff84] to [9a0667f343].

53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
		$db = DB::getInstance();
		$sql = sprintf('SELECT path, title FROM web_pages WHERE %s ORDER BY path ASC;', $db->where('path', array_keys($paths)));

		$result = $db->preparedQuery($sql);

		while ($row = $result->fetchArray(\SQLITE3_ASSOC))
		{
			$row['url'] = WWW_URL . $row['path'];
			yield $row;
		}
	}

	static public function categories(array $params, UserTemplate $tpl, int $line): \Generator
	{
		if (!array_key_exists('where', $params)) {







|







53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
		$db = DB::getInstance();
		$sql = sprintf('SELECT path, title FROM web_pages WHERE %s ORDER BY path ASC;', $db->where('path', array_keys($paths)));

		$result = $db->preparedQuery($sql);

		while ($row = $result->fetchArray(\SQLITE3_ASSOC))
		{
			$row['url'] = WWW_URL . Utils::basename($row['path']);
			yield $row;
		}
	}

	static public function categories(array $params, UserTemplate $tpl, int $line): \Generator
	{
		if (!array_key_exists('where', $params)) {

Modified src/include/lib/Garradin/Utils.php from [7094cca199] to [87963c7e33].

11
12
13
14
15
16
17

18
19
20
21
22
23
24
...
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915

916









917
918











919
920
921
922
923
924
925
class Utils
{
    const EMAIL_CONTEXT_BULK = 'bulk';
    const EMAIL_CONTEXT_PRIVATE = 'private';
    const EMAIL_CONTEXT_SYSTEM = 'system';

    static protected $collator;


    const FRENCH_DATE_NAMES = [
        'January'=>'Janvier', 'February'=>'Février', 'March'=>'Mars', 'April'=>'Avril', 'May'=>'Mai',
        'June'=>'Juin', 'July'=>'Juillet', 'August'=>'Août', 'September'=>'Septembre', 'October'=>'Octobre',
        'November'=>'Novembre', 'December'=>'Décembre', 'Monday'=>'Lundi', 'Tuesday'=>'Mardi', 'Wednesday'=>'Mercredi',
        'Thursday'=>'Jeudi','Friday'=>'Vendredi','Saturday'=>'Samedi','Sunday'=>'Dimanche',
        'Feb'=>'Fév','Apr'=>'Avr','Jun'=>'Juin', 'Jul'=>'Juil','Aug'=>'Aout','Dec'=>'Déc',
................................................................................

            // Don't use \Collator::NUMERIC_COLLATION here as it goes against what would feel logic
            // with NUMERIC_COLLATION: 1, 2, 10, 11, 101
            // without: 1, 10, 101, 11, 2
        }

        if (isset(self::$collator)) {
            return self::$collator->compare($a, $b);
        }

        if (function_exists('\mb_convert_case')) {
            $a = \mb_convert_case($a, \MB_CASE_LOWER);
            $b = \mb_convert_case($b, \MB_CASE_LOWER);
        }
        else {
            $a = strtoupper(self::transliterateToAscii($a));
            $b = strtoupper(self::transliterateToAscii($b));
        }











        return strcmp($a, $b);
    }












    static public function knatcasesort(array $array)
    {
        uksort($array, [self::class, 'unicodeCaseComparison']);
        return $array;
    }
}







>







 







|


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







11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
...
899
900
901
902
903
904
905
906
907
908





909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
class Utils
{
    const EMAIL_CONTEXT_BULK = 'bulk';
    const EMAIL_CONTEXT_PRIVATE = 'private';
    const EMAIL_CONTEXT_SYSTEM = 'system';

    static protected $collator;
    static protected $transliterator;

    const FRENCH_DATE_NAMES = [
        'January'=>'Janvier', 'February'=>'Février', 'March'=>'Mars', 'April'=>'Avril', 'May'=>'Mai',
        'June'=>'Juin', 'July'=>'Juillet', 'August'=>'Août', 'September'=>'Septembre', 'October'=>'Octobre',
        'November'=>'Novembre', 'December'=>'Décembre', 'Monday'=>'Lundi', 'Tuesday'=>'Mardi', 'Wednesday'=>'Mercredi',
        'Thursday'=>'Jeudi','Friday'=>'Vendredi','Saturday'=>'Samedi','Sunday'=>'Dimanche',
        'Feb'=>'Fév','Apr'=>'Avr','Jun'=>'Juin', 'Jul'=>'Juil','Aug'=>'Aout','Dec'=>'Déc',
................................................................................

            // Don't use \Collator::NUMERIC_COLLATION here as it goes against what would feel logic
            // with NUMERIC_COLLATION: 1, 2, 10, 11, 101
            // without: 1, 10, 101, 11, 2
        }

        if (isset(self::$collator)) {
            return (int) self::$collator->compare($a, $b);
        }






        $a = strtoupper(self::transliterateToAscii($a));
        $b = strtoupper(self::transliterateToAscii($b));

        return strcmp($a, $b);
    }

    /**
     * Transforms a unicode string to lowercase AND removes all diacritics
     *
     * @see https://www.matthecat.com/supprimer-les-accents-d-une-chaine-avec-php.html
     */
    static public function unicodeCaseFold(?string $str): string
    {
        if (null === $str || trim($str) === '') {
            return '';
        }

        if (!isset(self::$transliterator) && function_exists('transliterator_create')) {
            self::$transliterator = \Transliterator::create('Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; [:Punctuation:] Remove; Lower();');
        }

        if (isset(self::$transliterator)) {
            return self::$transliterator->transliterate($str);
        }

        return strtoupper(self::transliterateToAscii($str));
    }

    static public function knatcasesort(array $array)
    {
        uksort($array, [self::class, 'unicodeCaseComparison']);
        return $array;
    }
}

Added src/include/lib/Garradin/Web/Render/AbstractRender.php version [c4980b1d31].























































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?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;
	protected $context;
	protected $link_prefix;
	protected $link_suffix;

	protected $file;

	public function __construct(?File $file)
	{
		$this->file = $file;

		if ($file) {
			$this->isRelativeTo($file);
		}
	}

	abstract public function render(?string $content = null, array $options = []): string;

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

		// "Image.jpg"
		if ($pos === false) {
			return WWW_URL . $prefix . '/' . $uri;
		}
		// "bla/Image.jpg" outside of web context
		elseif ($this->context !== File::CONTEXT_WEB && $pos !== 0) {
			return WWW_URL . $this->context . '/' . $uri;
		}
		// "bla/Image.jpg" in web context or absolute link, eg. "/transactions/2442/42.jpg"
		else {
			return WWW_URL . ltrim($uri, '/');
		}
	}

	protected function resolveLink(string $uri) {
		$first = substr($uri, 0, 1);
		if ($first == '/' || $first == '!') {
			return Utils::getLocalURL($uri);
		}

		if (strpos(Utils::basename($uri), '.') === false) {
			$uri .= $this->link_suffix;
		}

		return $this->link_prefix . $uri;
	}

	public function isRelativeTo(File $file) {
		$this->current_path = Utils::dirname($file->path);
		$this->context = strtok($this->current_path, '/');
		$this->link_suffix = '';

		if ($this->context === File::CONTEXT_WEB) {
			$this->link_prefix = WWW_URL;
			$this->current_path = Utils::basename(Utils::dirname($file->path));
		}
		else {
			$this->link_prefix = $options['prefix'] ?? sprintf(ADMIN_URL . 'common/files/preview.php?p=%s/', $this->context);
			$this->link_suffix = '.skriv';
		}
	}
}

Added src/include/lib/Garradin/Web/Render/Parsedown.php version [af58530681].











































































































































































































































































































































































































































































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

namespace Garradin\Web\Render;

use Parsedown as Parent_Parsedown;

use Garradin\Entities\Files\File;

use Garradin\Utils;

/**
 * Custom Parsedown extension to enable the use of Skriv extensions inside Markdown markup
 *
 * Also adds support for footnotes and Table of Contents
 *
 * @see https://github.com/erusev/parsedown/wiki/Tutorial:-Create-Extensions
 */
class Parsedown extends Parent_Parsedown
{
	protected $skriv;
	protected $toc = [];

	function __construct(?File $file)
	{
		$this->BlockTypes['<'][] = 'SkrivExtension';
		$this->BlockTypes['['][]= 'TOC';

		# identify footnote definitions before reference definitions
		array_unshift($this->BlockTypes['['], 'Footnote');

		# identify footnote markers before before links
		array_unshift($this->InlineTypes['['], 'FootnoteMarker');

		$this->skriv = new Skriv($file);
	}

	protected function blockSkrivExtension(array $line): ?array
	{
		$line = $line['text'];

		if (strpos($line, '<<') === 0 && preg_match('/^<<<?([a-z_]+)((?:(?!>>>?).)*?)(>>>?$|$)/i', trim($line), $match)) {
			$text = $this->skriv->callExtension($match);

			return [
				'char'    => $line[0],
				'element' => [
					'name'                   => 'div',
					'rawHtml'                => $text,
					'allowRawHtmlInSafeMode' => true,
				],
				'complete' => true,
			];
		}

		return null;
	}

	protected function blockHeader($line)
	{
		$block = parent::blockHeader($line);

		if (is_array($block)) {
			if (!isset($block['element']['attributes']['id'])) {
				$block['element']['attributes']['id'] = Utils::transformTitleToURI($block['element']['text']);
			}

			$level = substr($block['element']['name'], 1); // h1, h2... -> 1, 2...
			$id = $block['element']['attributes']['id'];
			$label = $block['element']['text'];

			$this->toc[] = compact('level', 'id', 'label');
		}

		return $block;
	}

	protected function blockTOC(array $line): ?array
	{
		if (!preg_match('/^\[(?:toc|sommaire)\]$/', trim($line['text']))) {
			return null;
		}

		return [
			'char'     => $line['text'][0],
			'complete' => true,
			'element'  => [
				'name'                   => 'div',
				'rawHtml'                => '<toc></toc>',
				'allowRawHtmlInSafeMode' => true,
			],
		];
	}

	public function buildTOC(): string
	{
		if (!count($this->toc)) {
			return '';
		}

		$out = '<div class="toc">';

		$level = 0;

		foreach ($this->toc as $h) {
			if ($h['level'] > $level) {
				$out .= str_repeat('<ol>', $h['level'] - $level);
				$level = $h['level'];
			}
			elseif ($h['level'] < $level) {
				$out .= str_repeat('</ol>', $level - $h['level']);
				$level = $h['level'];
			}

			$out .= sprintf('<li><a href="#%s">%s</a></li>', $h['id'], $h['label']);
		}

		if ($level > 0) {
			$out .= str_repeat('</ol>', $level);
		}

		$out .= '</div>';

		return $out;
	}

	/**
	 * Footnotes implementation, inspired by ParsedownExtra
	 * We're not using ParsedownExtra as it's buggy and unmaintained
	 */
	protected function blockFootnote(array $line): ?array
	{
		if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $line['text'], $matches))
		{
			$block = array(
				'footnotes' => [$matches[1] => $matches[2]],
			);

			return $block;
		}

		return null;
	}

	protected function blockFootnoteContinue(array $line, array $block): ?array
	{
		if ($line['text'][0] === '[' && preg_match('/^\[\^(.+?)\]: ?(.*)$/', $line['text'], $matches))
		{
			$block['footnotes'][$matches[1]] = $matches[2];
			return $block;
		}

		end($block['footnotes']);
		$last = key($block['footnotes']);

		if (isset($block['interrupted']))
		{
			if ($line['indent'] >= 4)
			{
				$block['footnotes'][$last] .= "\n\n" . $line['text'];

				return $block;
			}
		}
		else
		{
			$block['footnotes'][$last] .= "\n" . $line['text'];

			return $block;
		}
	}

	protected function blockFootnoteComplete(array $in)
	{
		$html = '';

		foreach ($in['footnotes'] as $name => $value) {
			$html .= sprintf('<dt id="fn-%s"><a href="#fn-ref-%1$s">%1$s</a></dt><dd>%s</dd>', htmlspecialchars($name), $this->text($value));
		}

		$out = [
			'element' => [
				'name'                   => 'dl',
				'attributes'             => ['class' => 'footnotes'],
				'rawHtml'                => $html,
				'allowRawHtmlInSafeMode' => true,
			],
		];

		return $out;
	}


	protected function inlineFootnoteMarker($Excerpt)
	{
		if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches))
		{
			$name = htmlspecialchars($matches[1]);

			$Element = array(
				'name' => 'sup',
				'attributes' => ['id' => 'fn-ref-'.$name],
				'handler' => 'element',
				'text' => array(
					'name' => 'a',
					'attributes' => array('href' => '#fn-'.$name, 'class' => 'footnote-ref'),
					'text' => $name,
				),
			);

			return [
				'extent' => strlen($matches[0]),
				'element' => $Element,
			];
		}
	}


	public function text($text)
	{
		$out = parent::text($text);

		if (false !== strpos($out, '<toc></toc>')) {
			$toc = $this->buildTOC();
			$out = str_replace('<toc></toc>', $toc, $out);
		}

		return $out;
	}
}

Added src/include/lib/Garradin/Web/Render/Render.php version [a31683eb85].































































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

namespace Garradin\Web\Render;

use Garradin\Entities\Files\File;

class Render
{

	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';

	static public function render(string $format, File $file, string $content = null, array $options = [])
	{
		if ($format == self::FORMAT_SKRIV) {
			$r = new Skriv($file);
		}
		else if ($format == self::FORMAT_ENCRYPTED) {
			$r = new EncryptedSkriv($file);
		}
		else if ($format == self::FORMAT_MARKDOWN) {
			$r = new Markdown($file);
		}
		else {
			throw new \LogicException('Invalid format: ' . $format);
		}

		return $r->render($content, $options);
	}
}

Modified src/include/lib/dependencies.list from [9fc3eee5ea] to [9aeb838738].

18
19
20
21
22
23
24

KD2/SimpleDiff.php
KD2/SkrivLite.php
KD2/Smartyer.php
KD2/SMTP.php
KD2/Translate.php
KD2/UserSession.php
KD2/ZipWriter.php








>
18
19
20
21
22
23
24
25
KD2/SimpleDiff.php
KD2/SkrivLite.php
KD2/Smartyer.php
KD2/SMTP.php
KD2/Translate.php
KD2/UserSession.php
KD2/ZipWriter.php
Parsedown.php

Modified src/templates/admin/_head.tpl from [35b3bf84ba] to [cd5caa4701].

100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
            <li class="main config{if $current == 'config'} current{elseif $current_parent == 'config'} current_parent{/if}"><a href="{$admin_url}config/"><b class="icn">☸</b><i> Configuration</i></a>
        {/if}

        <li class="{if $current == 'mes_infos'} current{elseif $current_parent == 'mes_infos'} current_parent{/if}">
            <a href="{$admin_url}mes_infos.php"><b class="icn">👤</b><i> Mes infos personnelles</i></a>
            <ul>
                <li{if $current == 'my_services'}  class="current"{/if}><a href="{$admin_url}my_services.php">Mes activités &amp; cotisations</a></li>
            </ul>
        </li>

        {if !defined('Garradin\LOCAL_LOGIN') || !LOCAL_LOGIN}
            <li class="logout"><a href="{$admin_url}logout.php"><b class="icn">⤝</b><i> Déconnexion</i></a></li>
        {/if}
    {/if}







|
|

|







100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
            <li class="main config{if $current == 'config'} current{elseif $current_parent == 'config'} current_parent{/if}"><a href="{$admin_url}config/"><b class="icn">☸</b><i> Configuration</i></a>
        {/if}

        <li class="{if $current == 'me'} current{elseif $current_parent == 'me'} current_parent{/if}">
            <a href="{$admin_url}me/"><b class="icn">👤</b><i> Mes infos personnelles</i></a>
            <ul>
                <li{if $current == 'me/services'}  class="current"{/if}><a href="{$admin_url}me/services.php">Mes activités &amp; cotisations</a></li>
            </ul>
        </li>

        {if !defined('Garradin\LOCAL_LOGIN') || !LOCAL_LOGIN}
            <li class="logout"><a href="{$admin_url}logout.php"><b class="icn">⤝</b><i> Déconnexion</i></a></li>
        {/if}
    {/if}

Modified src/templates/admin/config/membres.tpl from [d986be9230] to [e15825a3fa].

127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
                <dd><input type="hidden" name="champs[{$nom}][type]" value="{$champ.type}" />{$champ.type|get_type}</dd>
                <dt><label for="f_{$nom}_title">Titre</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
                <dd><input type="text" name="champs[{$nom}][title]" id="f_{$nom}_title" value="{form_field data=$champ name=title}" required="required" /></dd>
                <dt><label for="f_{$nom}_help">Aide</label></dt>
                <dd><input type="text" name="champs[{$nom}][help]" id="f_{$nom}_help" value="{form_field data=$champ name=help}" /></dd>

                <dt><input type="checkbox" name="champs[{$nom}][private]" value="1" {form_field data=$champ name=private checked="1"} id="f_{$nom}_private"/> <label for="f_{$nom}_private">Caché pour les membres</label></dt>
                <dd class="help">Si coché, ce champ ne sera pas visible par les membres dans leur espace personnel.</dd>
                <dt><input type="checkbox" name="champs[{$nom}][editable]" value="1" {form_field data=$champ name=editable checked="1"} id="f_{$nom}_editable" /> <label for="f_{$nom}_editable">Modifiable par les membres</label></dt>
                <dd class="help">Si coché, les membres pourront changer cette information depuis leur espace personnel.</dd>
                <dt><label><input type="checkbox" name="champs[{$nom}][mandatory]" value="1" {form_field data=$champ name=mandatory checked="1"} id="f_{$nom}_mandatory" /> <label for="f_{$nom}_mandatory">Champ obligatoire</label></dt>
                <dd class="help">Si coché, ce champ ne pourra rester vide.</dd>

                {if $champ.type == 'select' || $champ.type == 'multiple'}
                    <dt><label>Options disponibles</label></dt>







|







127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
                <dd><input type="hidden" name="champs[{$nom}][type]" value="{$champ.type}" />{$champ.type|get_type}</dd>
                <dt><label for="f_{$nom}_title">Titre</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
                <dd><input type="text" name="champs[{$nom}][title]" id="f_{$nom}_title" value="{form_field data=$champ name=title}" required="required" /></dd>
                <dt><label for="f_{$nom}_help">Aide</label></dt>
                <dd><input type="text" name="champs[{$nom}][help]" id="f_{$nom}_help" value="{form_field data=$champ name=help}" /></dd>

                <dt><input type="checkbox" name="champs[{$nom}][private]" value="1" {form_field data=$champ name=private checked="1"} id="f_{$nom}_private"/> <label for="f_{$nom}_private">Caché pour les membres</label></dt>
                <dd class="help">Si coché, ce champ ne sera pas visible par les membres dans leur espace personnel. Attention, il apparaîtra quand même sur l'export de données RGPD que le membre peut télécharger, et qui contiendra toutes les données concernant ce membre.</dd>
                <dt><input type="checkbox" name="champs[{$nom}][editable]" value="1" {form_field data=$champ name=editable checked="1"} id="f_{$nom}_editable" /> <label for="f_{$nom}_editable">Modifiable par les membres</label></dt>
                <dd class="help">Si coché, les membres pourront changer cette information depuis leur espace personnel.</dd>
                <dt><label><input type="checkbox" name="champs[{$nom}][mandatory]" value="1" {form_field data=$champ name=mandatory checked="1"} id="f_{$nom}_mandatory" /> <label for="f_{$nom}_mandatory">Champ obligatoire</label></dt>
                <dd class="help">Si coché, ce champ ne pourra rester vide.</dd>

                {if $champ.type == 'select' || $champ.type == 'multiple'}
                    <dt><label>Options disponibles</label></dt>

Modified src/templates/admin/index.tpl from [8fc4b88474] to [39b0508d91].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{include file="admin/_head.tpl" title="Bonjour %s !"|args:$user.identite current="home"}

{$banner|raw}

<nav class="tabs">
	<ul>
		<li><a href="{$admin_url}mes_infos.php">Modifier mes informations personnelles</a></li>
		<li><a href="{$admin_url}my_services.php">Suivi de mes activités et cotisations</a></li>
	</ul>
</nav>

<aside class="describe">
	<h3>{$config.nom_asso}</h3>
	{if !empty($config.adresse_asso)}
	<p>






|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{include file="admin/_head.tpl" title="Bonjour %s !"|args:$user.identite current="home"}

{$banner|raw}

<nav class="tabs">
	<ul>
		<li><a href="{$admin_url}me/">Modifier mes informations personnelles</a></li>
		<li><a href="{$admin_url}me/services.php">Suivi de mes activités et cotisations</a></li>
	</ul>
</nav>

<aside class="describe">
	<h3>{$config.nom_asso}</h3>
	{if !empty($config.adresse_asso)}
	<p>

Modified src/templates/admin/membres/_details.tpl from [824eeb6d7f] to [059439d1b5].

2
3
4
5
6
7
8










9
10
11
12
13
14
15
assert(isset($data, $champs, $show_message_button));
$user_files_path = (new Membres)->getAttachementsDirectory($data->id);
?>

<dl class="describe">
	{foreach from=$champs key="c" item="c_config"}
	<?php










	$value = $data->$c ?? null;
	?>
	<dt>{$c_config.title}</dt>
	<dd>
		{if $c_config.type == 'checkbox'}
			{if $value}Oui{else}Non{/if}
		{elseif $c_config.type == 'file'}







>
>
>
>
>
>
>
>
>
>







2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
assert(isset($data, $champs, $show_message_button));
$user_files_path = (new Membres)->getAttachementsDirectory($data->id);
?>

<dl class="describe">
	{foreach from=$champs key="c" item="c_config"}
	<?php
	// Skip private fields from "my info" page
	if ($mode == 'user' && $c_config->private) {
		continue;
	}

	// Skip files from export
	if ($mode == 'export' && $c_config->type == 'file') {
		continue;
	}

	$value = $data->$c ?? null;
	?>
	<dt>{$c_config.title}</dt>
	<dd>
		{if $c_config.type == 'checkbox'}
			{if $value}Oui{else}Non{/if}
		{elseif $c_config.type == 'file'}

Modified src/templates/admin/membres/fiche.tpl from [9b1d3c68ff] to [d66ad55e9b].

31
32
33
34
35
36
37





38
39
40
41
42
43
44
        {if count($services)}
            {linkbutton href="!services/user.php?id=%d"|args:$membre.id label="Liste des inscriptions aux activités" shape="menu"}
        {/if}
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
            {linkbutton href="!services/save.php?user=%d"|args:$membre.id label="Inscrire à une activité" shape="plus"}
        {/if}
    </dd>





    {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)}
        {if !empty($transactions_linked)}
            <dt>Écritures comptables liées</dt>
            <dd><a href="{$admin_url}acc/transactions/user.php?id={$membre.id}">{$transactions_linked} écritures comptables liées à ce membre</a></dd>
        {/if}
        {if !empty($transactions_created)}
            <dt>Écritures comptables créées</dt>







>
>
>
>
>







31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
        {if count($services)}
            {linkbutton href="!services/user.php?id=%d"|args:$membre.id label="Liste des inscriptions aux activités" shape="menu"}
        {/if}
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
            {linkbutton href="!services/save.php?user=%d"|args:$membre.id label="Inscrire à une activité" shape="plus"}
        {/if}
    </dd>
    {if count($services)}
    <dd>
        {linkbutton shape="alert" label="Liste des rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$membre.id}
    </dd>
    {/if}
    {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)}
        {if !empty($transactions_linked)}
            <dt>Écritures comptables liées</dt>
            <dd><a href="{$admin_url}acc/transactions/user.php?id={$membre.id}">{$transactions_linked} écritures comptables liées à ce membre</a></dd>
        {/if}
        {if !empty($transactions_created)}
            <dt>Écritures comptables créées</dt>

Modified src/templates/common/files/_file_render_encrypted.tpl from [b283e35b3f] to [8602ee5447].

2
3
4
5
6
7
8
9
10
11
12
13
14
	<div class="error">
		Vous dever activer javascript pour pouvoir déchiffrer cette page.
	</div>
</noscript>
<script type="text/javascript" src="{$admin_url}static/scripts/wiki-encryption.js"></script>
<div id="wikiEncryptedMessage">
	<p class="block alert">Cette page est chiffrée.
		<input type="button" onclick="return wikiDecrypt(false);" value="Entrer le mot de passe" />
	</p>
</div>
<div class="web-content" style="display: none;" id="wikiEncryptedContent">
	{$content}
</div>







|





2
3
4
5
6
7
8
9
10
11
12
13
14
	<div class="error">
		Vous dever activer javascript pour pouvoir déchiffrer cette page.
	</div>
</noscript>
<script type="text/javascript" src="{$admin_url}static/scripts/wiki-encryption.js"></script>
<div id="wikiEncryptedMessage">
	<p class="block alert">Cette page est chiffrée.
		<input type="button" onclick="return pleaseDecrypt();" value="Entrer le mot de passe" />
	</p>
</div>
<div class="web-content" style="display: none;" id="wikiEncryptedContent">
	{$content}
</div>

Modified src/templates/common/files/edit_web.tpl from [1178627620] to [5e930c6a6c].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{include file="admin/_head.tpl" title="Édition de fichier" custom_js=['wiki_editor.js']}

<form method="post" action="{$self_url}">
	<p class="textEditor">
		{input type="textarea" name="content" cols="70" rows="30" default=$content data-preview-url="!common/files/_preview.php?f=%s"|local_url|args:$file.parent data-fullscreen="1" data-attachments="0" data-savebtn="1"}
	</p>

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

</form>

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




|










1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{include file="admin/_head.tpl" title="Édition de fichier" custom_js=['wiki_editor.js']}

<form method="post" action="{$self_url}">
	<p class="textEditor">
		{input type="textarea" name="content" cols="70" rows="30" default=$content data-preview-url="!common/files/_preview.php?f=%s"|local_url|args:$file.parent data-fullscreen="1" data-attachments="0" data-savebtn="1" data-format=$file->renderFormat()}
	</p>

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

</form>

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

Modified src/templates/docs/index.tpl from [a651c91f9d] to [586034f95e].

54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
...
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
...
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
...
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205



206
207
208
209
210
211
212

	<aside class="quota">
		<h4><b>{$quota_left|size_in_bytes}</b> libres sur <i>{$quota_max|size_in_bytes}</i></h4>
		<span class="meter"><span style="width: {$quota_percent}%"></span></span>
	</aside>
</nav>

{if !$can_mkdir && !$context_ref}
<p class="block alert">
	Il n'est pas possible de créer de répertoire ici.
	{if $context == File::CONTEXT_USER}
		Utiliser le <a href="{"!membres/ajouter.php"|local_url}">formulaire de création</a> pour enregistrer un membre.
	{else}
		Utiliser le <a href="{"!acc/transactions/new.php"|local_url}">formulaire de saisie</a> pour créer une nouvelle écriture.
	{/if}
................................................................................
							<a href="{$file->url(true)}" target="_blank">{$file.name}</a>
						{/if}
					</td>
					<td>{$file.modified|date}</td>
					<td>{$file.mime}</td>
					<td>{$file.size|size_in_bytes}</td>
					<td class="actions">
						{if $can_write && $file->getEditor()}
							{linkbutton href="!common/files/edit.php?p=%s"|args:$file.path label="Modifier" shape="edit" target="_dialog" data-dialog-height="90%"}
						{/if}
						{if $file->canPreview()}
							{linkbutton href="!common/files/preview.php?p=%s"|args:$file.path label="Voir" shape="eye" target="_dialog" data-mime=$file.mime}
						{/if}
						{linkbutton href=$file->url(true) label="Télécharger" shape="download"}
						{if $can_write}
................................................................................
			</tbody>

			{if $can_delete}
			<tfoot>
				<tr>
					<td class="check"><input type="checkbox" value="Tout cocher / décocher" id="f_all2" /><label for="f_all2"></label></td>
					<td class="actions" colspan="5">
						<em>Pour les fichiers cochés :</em>
							<input type="hidden" name="parent" value="{$path}" />
							<select name="action">
								<option value="">— Choisir une action à effectuer —</option>
								{if $context == File::CONTEXT_DOCUMENTS}
								<option value="move">Déplacer</option>
								{/if}
								<option value="delete">Supprimer</option>
................................................................................
						{linkbutton href="!acc/transactions/details.php?id=%d"|args:$item.id label="Écriture" shape="search"}
					</td>
				{else}
					<td class="num"><a href="{$admin_url}membres/fiche.php?id={$item.id}">#{$item.number}</a></td>
					<th><a href="?p={$item.path}">{$item.identity}</a></th>
					<td class="actions">
						{linkbutton href="!docs/?p=%s"|args:$item.path label="Fichiers" shape="menu"}
						{linkbutton href="!membres/fiche.php?id=%d"|args:$item.number label="Fiche membre" shape="user"}
					</td>
				{/if}
			</tr>
		{/foreach}
		</tbody>
		</table>

	{/if}





</form>
{else}
	<p class="alert block">Il n'y a aucun fichier dans ce répertoire.</p>
{/if}

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







|







 







|







 







|







 







|









>
>
>







54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
...
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
...
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
...
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

	<aside class="quota">
		<h4><b>{$quota_left|size_in_bytes}</b> libres sur <i>{$quota_max|size_in_bytes}</i></h4>
		<span class="meter"><span style="width: {$quota_percent}%"></span></span>
	</aside>
</nav>

{if !$can_mkdir && !$context_ref && $can_upload}
<p class="block alert">
	Il n'est pas possible de créer de répertoire ici.
	{if $context == File::CONTEXT_USER}
		Utiliser le <a href="{"!membres/ajouter.php"|local_url}">formulaire de création</a> pour enregistrer un membre.
	{else}
		Utiliser le <a href="{"!acc/transactions/new.php"|local_url}">formulaire de saisie</a> pour créer une nouvelle écriture.
	{/if}
................................................................................
							<a href="{$file->url(true)}" target="_blank">{$file.name}</a>
						{/if}
					</td>
					<td>{$file.modified|date}</td>
					<td>{$file.mime}</td>
					<td>{$file.size|size_in_bytes}</td>
					<td class="actions">
						{if $can_write && $file->editorType()}
							{linkbutton href="!common/files/edit.php?p=%s"|args:$file.path label="Modifier" shape="edit" target="_dialog" data-dialog-height="90%"}
						{/if}
						{if $file->canPreview()}
							{linkbutton href="!common/files/preview.php?p=%s"|args:$file.path label="Voir" shape="eye" target="_dialog" data-mime=$file.mime}
						{/if}
						{linkbutton href=$file->url(true) label="Télécharger" shape="download"}
						{if $can_write}
................................................................................
			</tbody>

			{if $can_delete}
			<tfoot>
				<tr>
					<td class="check"><input type="checkbox" value="Tout cocher / décocher" id="f_all2" /><label for="f_all2"></label></td>
					<td class="actions" colspan="5">
						<em>Pour les fichiers sélectionnés&nbsp;:</em>
							<input type="hidden" name="parent" value="{$path}" />
							<select name="action">
								<option value="">— Choisir une action à effectuer —</option>
								{if $context == File::CONTEXT_DOCUMENTS}
								<option value="move">Déplacer</option>
								{/if}
								<option value="delete">Supprimer</option>
................................................................................
						{linkbutton href="!acc/transactions/details.php?id=%d"|args:$item.id label="Écriture" shape="search"}
					</td>
				{else}
					<td class="num"><a href="{$admin_url}membres/fiche.php?id={$item.id}">#{$item.number}</a></td>
					<th><a href="?p={$item.path}">{$item.identity}</a></th>
					<td class="actions">
						{linkbutton href="!docs/?p=%s"|args:$item.path label="Fichiers" shape="menu"}
						{linkbutton href="!membres/fiche.php?id=%d"|args:$item.id label="Fiche membre" shape="user"}
					</td>
				{/if}
			</tr>
		{/foreach}
		</tbody>
		</table>

	{/if}

	<p class="actions">
		{linkbutton href="!docs/zip.php?p=%s"|args:$path label="Télécharger ce répertoire (ZIP)" shape="download"}
	</p>

</form>
{else}
	<p class="alert block">Il n'y a aucun fichier dans ce répertoire.</p>
{/if}

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

Modified src/templates/me/edit.tpl from [a8c6d019fb] to [30ffe59eb4].

1
2
3
4
5
6
7
8
9
10
11
12
13
..
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{include file="admin/_head.tpl" title="Mes informations personnelles" current="mes_infos"}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$admin_url}mes_infos.php">Mes informations personnelles</a></li>
		<li><a href="{$admin_url}mes_infos_securite.php">Mot de passe et options de sécurité</a></li>
	</ul>
</nav>

{form_errors membre=1}

<form method="post" action="{$self_url}">

................................................................................
			{/if}
			{/foreach}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Changer mon mot de passe</legend>
		<p><a href="{$admin_url}mes_infos_securite.php">Modifier mon mot de passe ou autres informations de sécurité.</a></p>
	</fieldset>

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

</form>

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



|
|







 







|










1
2
3
4
5
6
7
8
9
10
11
12
13
..
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{include file="admin/_head.tpl" title="Mes informations personnelles" current="me"}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$admin_url}me/">Mes informations personnelles</a></li>
		<li><a href="{$admin_url}me/security.php">Mot de passe et options de sécurité</a></li>
	</ul>
</nav>

{form_errors membre=1}

<form method="post" action="{$self_url}">

................................................................................
			{/if}
			{/foreach}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Changer mon mot de passe</legend>
		<p><a href="{$admin_url}me/security.php">Modifier mon mot de passe ou autres informations de sécurité.</a></p>
	</fieldset>

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

</form>

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

Added src/templates/me/export.tpl version [8a7de5d535].





































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
<!DOCTYPE html>
<html>
<head>
	<title>Données utilisateur</title>
</head>

<body>
<h1>Données utilisateur</h1>
<p>Ce document contient une copie de toutes les données détenues sur vous par {$config.nom_asso}, conformément à la réglementation.</p>

<hr />

<h2>Profil</h2>

{include file="admin/membres/_details.tpl" champs=$champs_list data=$data show_message_button=false mode="export"}

<hr />

<h2>Inscriptions aux activités et cotisations</h2>

<table>
	<thead>
		<tr>
		{foreach from=$services_list->getHeaderColumns() key="key" item="column"}
			<th>{$column.label}</th>
		{/foreach}
		</tr>
	</thead>

	<tbody>

	{foreach from=$services_list->iterate() item="row"}
		<tr>
			<th>{$row.label}</th>
			<td>{$row.date|date_short}</td>
			<td>{$row.expiry|date_short}</td>
			<td>{$row.fee}</td>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td>{$row.amount|raw|money_currency}</td>
			<td class="actions">
			</td>
		</tr>
	{/foreach}

	</tbody>
</table>


</body>
</html>

Modified src/templates/me/index.tpl from [9470f7e272] to [40ad0c52b7].

1
2
3
4
5
6
7
8
9






10
11
12
13
14
15
16
17




18
{include file="admin/_head.tpl" title="Mes informations personnelles" current="mes_infos"}

<nav class="tabs">
    <ul>
        <li class="current"><a href="{$admin_url}mes_infos.php">Mes informations personnelles</a></li>
        <li><a href="{$admin_url}mes_infos_securite.php">Mot de passe et options de sécurité</a></li>
    </ul>
</nav>







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

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





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

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$admin_url}me/">Mes informations personnelles</a></li>
		<li><a href="{$admin_url}me/security.php">Mot de passe et options de sécurité</a></li>
	</ul>
</nav>

{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="admin/membres/_details.tpl" champs=$champs data=$data show_message_button=false mode="user"}

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

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

Modified src/templates/me/security.tpl from [f732fceed2] to [c9bb6e985d].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
{include file="admin/_head.tpl" title="Mes informations de connexion et sécurité" current="mes_infos"}

<nav class="tabs">
    <ul>
        <li><a href="{$admin_url}mes_infos.php">Mes informations personnelles</a></li>
        <li class="current"><a href="{$admin_url}mes_infos_securite.php">Mot de passe et options de sécurité</a></li>
    </ul>
</nav>

{if $ok}
<p class="block confirm">
    Changements enregistrés.
</p>
{/if}

{form_errors}

{if $confirm}
    <form method="post" action="{$self_url_no_qs}">

    {if !empty($otp) && $otp == 'disable'}
        <p class="block alert">
            Confirmez la désactivation de l'authentification à double facteur TOTP.
        </p>
    {elseif !empty($otp)}
        <p class="block alert">
            Confirmez l'activation de l'authentification à double facteur TOTP en l'utilisant une première fois.
        </p>

        <fieldset>
            <legend>Confirmer l'activation de l'authentification à double facteur (2FA)</legend>
            <img class="qrcode" src="{$otp.qrcode}" alt="" />
            <dl>
                <dt>Votre clé secrète est&nbsp;:</dt>
                <dd><code>{$otp.secret_display}</code></dd>
                <dd class="help">Recopiez la clé secrète ou scannez le QR code pour configurer votre application TOTP (par exemple <a href="https://freeotp.github.io/">FreeOTP</a>), puis utilisez celle-ci pour générer un code d'accès et confirmer l'activation.</dd>
                <dd class="help">Pour configurer une autre application, vous pouvez utiliser ces paramètres&nbsp;: <tt>{$otp.url}</tt></dd>
                <dt><label for="f_code">Code TOTP</label></dt>
                <dd class="help">Entrez ici le code donné par l'application d'authentification double facteur.</dd>
                <dd><input type="text" name="code" id="f_code" value="{form_field name=code}" autocomplete="off" /></dd>
            </dl>
        </fieldset>
    {/if}

    <fieldset>
        <legend>Confirmer les changements</legend>
        <dl>
            <dt><label for="f_passe_confirm">Mot de passe actuel</label></dt>
            <dd class="help">Entrez votre mot de passe actuel pour confirmer les changements demandés.</dd>
            <dd><input type="password" name="passe_check" autocomplete="current-password" /></dd>
        </dl>
    </fieldset>

    <p class="submit">
        {csrf_field key="edit_me_security"}
        <input type="hidden" name="passe" value="{form_field name="passe"}" />
        <input type="hidden" name="passe_confirmed" value="{form_field name="passe_confirmed"}" />
        <input type="hidden" name="clef_pgp" value="{form_field name="clef_pgp"}" />
        {if !empty($otp)}
        <input type="hidden" name="otp_secret" value="{$otp.secret}" />
        {/if}
        {button type="submit" name="confirm" label="Confirmer" shape="right" class="main"}
    </p>

    </form>
{else}

    <form method="post" action="{$self_url_no_qs}">

        <fieldset>
            <legend>Changer mon mot de passe</legend>
            {if $user.droit_membres < $session::ACCESS_ADMIN && (!empty($champs.passe.private) || empty($champs.passe.editable))}
                <p class="help">Vous devez contacter un administrateur pour changer votre mot de passe.</p>
            {else}
                <dl>
                    <dd>Vous avez déjà un mot de passe, ne remplissez les champs suivants que si vous souhaitez en changer.</dd>
                    <dt><label for="f_passe">Nouveau mot de passe</label> (minimum {$password_length} caractères)</dt>
                    <dd class="help">
                        Astuce : un mot de passe de quatre mots choisis au hasard dans le dictionnaire est plus sûr 
                        et plus simple à retenir qu'un mot de passe composé de 10 lettres et chiffres.
                    </dd>
                    <dd class="help">
                        Pas d'idée&nbsp;? Voici une suggestion choisie au hasard :
                        <input type="text" readonly="readonly" title="Cliquer pour utiliser cette suggestion comme mot de passe" id="pw_suggest" value="{$passphrase}" autocomplete="off" />
                    </dd>
                    <dd><input type="password" name="passe" id="f_passe" value="{form_field name=passe}" pattern="{$password_pattern}" autocomplete="new-password" /></dd>
                    <dt><label for="f_repasse">Encore le mot de passe</label> (vérification)</dt>
                    <dd><input type="password" name="passe_confirmed" id="f_passe_confirmed" value="{form_field name=passe_confirmed}" pattern="{$password_pattern}" autocomplete="new-password" /></dd>
                </dl>
            {/if}
        </fieldset>

        <fieldset>
            <legend>Authentification à double facteur (2FA)</legend>
            <p class="help">Pour renforcer la sécurité de votre connexion en cas de vol de votre mot de passe, vous pouvez activer
                l'authentification à double facteur. Cela nécessite d'installer une application comme <a href="https://freeotp.github.io/">FreeOTP</a>
                sur votre téléphone.</p>
            <dl>
                <dt>Authentification à double facteur (TOTP)</dt>
            {if $membre.secret_otp}
                {input type="radio" name="otp" value="" default="" label="Activée"}
                {input type="radio" name="otp" value="generate" label="Re-générer une nouvelle clé secrète" help="Si la clé a été compromise ou perdue"}
                {input type="radio" name="otp" value="disable" label="Désactiver l'authentification à double facteur"}
            {else}
                <dd><em>Désactivée</em></dd>
                {input type="checkbox" name="otp" value="generate" label="Activer"}
            {/if}
            </dl>
        </fieldset>

        {if $pgp_disponible}
        <fieldset>
            <legend>Protéger mes mails personnels par chiffrement PGP/GnuPG</legend>
            <dl>
                <dt><label for="f_clef_pgp">Ma clé publique PGP</label></dt>
                <dd class="help">En inscrivant ici votre clé publique, tous les emails personnels (non collectifs) qui vous
                    sont envoyés seront chiffrés (cryptés) avec cette clé&nbsp;: messages envoyés par les membres, rappels de cotisation,
                    procédure de récupération de mot de passe, etc.</dd>
                <dd><textarea name="clef_pgp" id="f_clef_pgp" cols="90" rows="5">{form_field name="clef_pgp" data=$user}</textarea></dd>
                {if $clef_pgp_fingerprint}<dd class="help">L'empreinte de la clé est&nbsp;: <code>{$clef_pgp_fingerprint}</code></dd>{/if}
            </dl>
            <p class="block alert">
                Attention&nbsp;: en inscrivant ici votre clé PGP, les emails de récupération de mot de passe perdu vous seront envoyés chiffrés
                et ne pourront être lus sans utiliser le mot de passe protégeant votre clé privée correspondante.
            </p>
        </fieldset>
        {/if}

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

    </form>

    <script type="text/javascript">
    {literal}
    g.script('scripts/password.js', () => {
        initPasswordField('pw_suggest', 'f_passe', 'f_passe_confirmed');
    });
    {/literal}
    </script>
{/if}

{include file="admin/_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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
{include file="admin/_head.tpl" title="Mes informations de connexion et sécurité" current="me"}

<nav class="tabs">
	<ul>
		<li><a href="{$admin_url}me/">Mes informations personnelles</a></li>
		<li class="current"><a href="{$admin_url}me/security.php">Mot de passe et options de sécurité</a></li>
	</ul>
</nav>

{if $ok}
<p class="block confirm">
	Changements enregistrés.
</p>
{/if}

{form_errors}

{if $confirm}
	<form method="post" action="{$self_url_no_qs}">

	{if !empty($otp) && $otp == 'disable'}
		<p class="block alert">
			Confirmez la désactivation de l'authentification à double facteur TOTP.
		</p>
	{elseif !empty($otp)}
		<p class="block alert">
			Confirmez l'activation de l'authentification à double facteur TOTP en l'utilisant une première fois.
		</p>

		<fieldset>
			<legend>Confirmer l'activation de l'authentification à double facteur (2FA)</legend>
			<img class="qrcode" src="{$otp.qrcode}" alt="" />
			<dl>
				<dt>Votre clé secrète est&nbsp;:</dt>
				<dd><code>{$otp.secret_display}</code></dd>
				<dd class="help">Recopiez la clé secrète ou scannez le QR code pour configurer votre application TOTP (par exemple <a href="https://getaegis.app/" target="_blank">Aegis</a>), puis utilisez celle-ci pour générer un code d'accès et confirmer l'activation.</dd>
				<dd class="help">Pour configurer une autre application, vous pouvez utiliser ces paramètres&nbsp;: <tt>{$otp.url}</tt></dd>
				<dt><label for="f_code">Code TOTP</label></dt>
				<dd class="help">Entrez ici le code donné par l'application d'authentification double facteur.</dd>
				<dd><input type="text" name="code" id="f_code" value="{form_field name=code}" autocomplete="off" /></dd>
			</dl>
		</fieldset>
	{/if}

	<fieldset>
		<legend>Confirmer les changements</legend>
		<dl>
			<dt><label for="f_passe_confirm">Mot de passe actuel</label></dt>
			<dd class="help">Entrez votre mot de passe actuel pour confirmer les changements demandés.</dd>
			<dd><input type="password" name="passe_check" autocomplete="current-password" /></dd>
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key="edit_me_security"}
		<input type="hidden" name="passe" value="{form_field name="passe"}" />
		<input type="hidden" name="passe_confirmed" value="{form_field name="passe_confirmed"}" />
		<input type="hidden" name="clef_pgp" value="{form_field name="clef_pgp"}" />
		{if !empty($otp)}
		<input type="hidden" name="otp_secret" value="{$otp.secret}" />
		{/if}
		{button type="submit" name="confirm" label="Confirmer" shape="right" class="main"}
	</p>

	</form>
{else}

	<form method="post" action="{$self_url_no_qs}">

		<fieldset>
			<legend>Changer mon mot de passe</legend>
			{if $user.droit_membres < $session::ACCESS_ADMIN && (!empty($champs.passe.private) || empty($champs.passe.editable))}
				<p class="help">Vous devez contacter un administrateur pour changer votre mot de passe.</p>
			{else}
				<dl>
					<dd>Vous avez déjà un mot de passe, ne remplissez les champs suivants que si vous souhaitez en changer.</dd>
					<dt><label for="f_passe">Nouveau mot de passe</label> (minimum {$password_length} caractères)</dt>
					<dd class="help">
						Astuce : un mot de passe de quatre mots choisis au hasard dans le dictionnaire est plus sûr 
						et plus simple à retenir qu'un mot de passe composé de 10 lettres et chiffres.
					</dd>
					<dd class="help">
						Pas d'idée&nbsp;? Voici une suggestion choisie au hasard :
						<input type="text" readonly="readonly" title="Cliquer pour utiliser cette suggestion comme mot de passe" id="pw_suggest" value="{$passphrase}" autocomplete="off" />
					</dd>
					<dd><input type="password" name="passe" id="f_passe" value="{form_field name=passe}" pattern="{$password_pattern}" autocomplete="new-password" /></dd>
					<dt><label for="f_repasse">Encore le mot de passe</label> (vérification)</dt>
					<dd><input type="password" name="passe_confirmed" id="f_passe_confirmed" value="{form_field name=passe_confirmed}" pattern="{$password_pattern}" autocomplete="new-password" /></dd>
				</dl>
			{/if}
		</fieldset>

		<fieldset>
			<legend>Authentification à double facteur (2FA)</legend>
			<p class="help">Pour renforcer la sécurité de votre connexion en cas de vol de votre mot de passe, vous pouvez activer
				l'authentification à double facteur. Cela nécessite d'installer une application comme <a href="https://getaegis.app/" target="_blank">Aegis</a>
				sur votre téléphone.</p>
			<dl>
				<dt>Authentification à double facteur (TOTP)</dt>
			{if $membre.secret_otp}
				{input type="radio" name="otp" value="" default="" label="Activée"}
				{input type="radio" name="otp" value="generate" label="Re-générer une nouvelle clé secrète" help="Si la clé a été compromise ou perdue"}
				{input type="radio" name="otp" value="disable" label="Désactiver l'authentification à double facteur"}
			{else}
				<dd><em>Désactivée</em></dd>
				{input type="checkbox" name="otp" value="generate" label="Activer"}
			{/if}
			</dl>
		</fieldset>

		{if $pgp_disponible}
		<fieldset>
			<legend>Protéger mes mails personnels par chiffrement PGP/GnuPG</legend>
			<dl>
				<dt><label for="f_clef_pgp">Ma clé publique PGP</label></dt>
				<dd class="help">En inscrivant ici votre clé publique, tous les emails personnels (non collectifs) qui vous
					sont envoyés seront chiffrés (cryptés) avec cette clé&nbsp;: messages envoyés par les membres, rappels de cotisation,
					procédure de récupération de mot de passe, etc.</dd>
				<dd><textarea name="clef_pgp" id="f_clef_pgp" cols="90" rows="5">{form_field name="clef_pgp" data=$user}</textarea></dd>
				{if $clef_pgp_fingerprint}<dd class="help">L'empreinte de la clé est&nbsp;: <code>{$clef_pgp_fingerprint}</code></dd>{/if}
			</dl>
			<p class="block alert">
				Attention&nbsp;: en inscrivant ici votre clé PGP, les emails de récupération de mot de passe perdu vous seront envoyés chiffrés
				et ne pourront être lus sans utiliser le mot de passe protégeant votre clé privée correspondante.
			</p>
		</fieldset>
		{/if}

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

	</form>

	<script type="text/javascript">
	{literal}
	g.script('scripts/password.js', () => {
		initPasswordField('pw_suggest', 'f_passe', 'f_passe_confirmed');
	});
	{/literal}
	</script>
{/if}

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

Modified src/templates/me/services.tpl from [79bef75320] to [4bbaabc663].

1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Mes activités & cotisations" current="my_services"}

<dl class="cotisation">
	<dt>Mes activités et cotisations</dt>
	{foreach from=$services item="service"}
	<dd>
		{$service.label}
		{if $service.status == -1 && $service.end_date} — terminée
|







1
2
3
4
5
6
7
8
{include file="admin/_head.tpl" title="Mes activités & cotisations" current="me/services"}

<dl class="cotisation">
	<dt>Mes activités et cotisations</dt>
	{foreach from=$services item="service"}
	<dd>
		{$service.label}
		{if $service.status == -1 && $service.end_date} — terminée

Modified src/templates/services/details.tpl from [f17fcbe21b] to [4db5880253].

46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
			</td>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td>{$row.expiry|date_short}</td>
			<td>{$row.fee}</td>
			<td>{$row.date|date_short}</td>
			<td class="actions">
				{linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user.php?id=%d"|args:$row.id_user}
				{*FIXME TODO linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user*}
			</td>
		</tr>
	{/foreach}

	</tbody>
	{if $can_action}
		{include file="admin/membres/_list_actions.tpl" colspan=7 export=false}







|







46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
			</td>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td>{$row.expiry|date_short}</td>
			<td>{$row.fee}</td>
			<td>{$row.date|date_short}</td>
			<td class="actions">
				{linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user.php?id=%d"|args:$row.id_user}
				{linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user}
			</td>
		</tr>
	{/foreach}

	</tbody>
	{if $can_action}
		{include file="admin/membres/_list_actions.tpl" colspan=7 export=false}

Modified src/templates/services/fees/details.tpl from [883468d464] to [17c011b163].

31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
			{/if}
			<th><a href="../../membres/fiche.php?id={$row.id_user}">{$row.identity}</a></th>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td class="money">{$row.paid_amount|raw|money_currency}</td>
			<td>{$row.date|date_short}</td>
			<td class="actions">
				{linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user.php?id=%d"|args:$row.id_user}
				{* FIXME TODO {linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user} *}
			</td>
		</tr>
	{/foreach}

	</tbody>

	{if $can_action}







|







31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
			{/if}
			<th><a href="../../membres/fiche.php?id={$row.id_user}">{$row.identity}</a></th>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td class="money">{$row.paid_amount|raw|money_currency}</td>
			<td>{$row.date|date_short}</td>
			<td class="actions">
				{linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user.php?id=%d"|args:$row.id_user}
				{linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user}
			</td>
		</tr>
	{/foreach}

	</tbody>

	{if $can_action}

Added src/templates/services/reminders/user.tpl version [f448277717].























































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
{include file="admin/_head.tpl" title="Rappels envoyés à un membre" current="membres/services"}

<nav class="tabs">
	<ul>
		<li><a href="{$admin_url}membres/fiche.php?id={$user_id}">Fiche membre</a></li>
		<li class="current"><a href="{$self_url}">Liste des rappels envoyés</a></li>
	</ul>
</nav>

{include file="common/dynamic_list_head.tpl"}

	{foreach from=$list->iterate() item="row"}
		<tr>
			<th>{$row.label}</th>
			<td>{if $row.delay > 0}{$row.delay} jours après l'expiration{elseif $row.delay < 0}{$row.delay|abs} jours avant l'expiration{else}le jour de l'expiration{/if}</td>
			<td>{$row.date|date_short}</td>
			<td></td>
		</tr>
	{/foreach}

	</tbody>
</table>

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


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

Modified src/templates/services/save.tpl from [4ec50aff25] to [c8f34251fe].

102
103
104
105
106
107
108

109
110
111
112
113
114
115
		<legend>{input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"}</legend>

		<dl>
			{input type="money" name="amount" label="Montant réglé par le membre" fake_required=1 help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."}
			{input type="list" target="acc/charts/accounts/selector.php?targets=%s"|args:$account_targets name="account" label="Compte de règlement" fake_required=1}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}

		</dl>
{/if}
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{if $user_id}







>







102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
		<legend>{input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"}</legend>

		<dl>
			{input type="money" name="amount" label="Montant réglé par le membre" fake_required=1 help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."}
			{input type="list" target="acc/charts/accounts/selector.php?targets=%s"|args:$account_targets name="account" label="Compte de règlement" fake_required=1}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
			{input type="textarea" name="notes" label="Remarques"}
		</dl>
{/if}
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{if $user_id}

Modified src/templates/web/_attach.tpl from [d9dde30b66] to [0bf38746a5].

39
40
41
42
43
44
45



46
47
48
49
50
51
52

{if !empty($images)}
<ul class="gallery">
{foreach from=$images item="file"}
	<li>
		<figure>
			<a href="{$file->url()}" data-name="{$file.name}" data-insert="image" data-thumb="{$file->thumb_url()}"><img src="{$file->thumb_url()}" alt="" title="{$file.name}" /></a>



			<form class="actions" method="post" action="{$self_url}">
				{linkbutton shape="download" label="Télécharger" href=$file->url() target="_blank"}
				{linkbutton shape="plus" label="Insérer" href=$file->url() data-name=$file.name data-insert="image" data-thumb=$file->thumb_url()}
				{csrf_field key=$csrf_key}
				<input type="hidden" name="delete" value="{$file.name}" />
				<noscript><input type="submit" value="Supprimer" /></noscript>
			</form>







>
>
>







39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

{if !empty($images)}
<ul class="gallery">
{foreach from=$images item="file"}
	<li>
		<figure>
			<a href="{$file->url()}" data-name="{$file.name}" data-insert="image" data-thumb="{$file->thumb_url()}"><img src="{$file->thumb_url()}" alt="" title="{$file.name}" /></a>
			<figcaption>
				<a href="{$file->url()}" data-name="{$file.name}" data-insert="image" data-thumb="{$file->thumb_url()}">{$file.name}</a>
			</figcaption>
			<form class="actions" method="post" action="{$self_url}">
				{linkbutton shape="download" label="Télécharger" href=$file->url() target="_blank"}
				{linkbutton shape="plus" label="Insérer" href=$file->url() data-name=$file.name data-insert="image" data-thumb=$file->thumb_url()}
				{csrf_field key=$csrf_key}
				<input type="hidden" name="delete" value="{$file.name}" />
				<noscript><input type="submit" value="Supprimer" /></noscript>
			</form>

Modified src/templates/web/_selector.tpl from [b84b0a915e] to [f6ba705f24].

6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
			<td><input type="button" value="Choisir" data-path="" data-label="Racine du site" /></td>
			<th><h3><a href="?current={$selected}">Racine du site</a></h3></th>
		</tr>
		<?php $last = 1; ?>
		{foreach from=$breadcrumbs item="_title" key="_path"}
		<tr{if $_path == $parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="{$_path}" data-label="{$_title}" /></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last)?> <b class="icn">&rarr;</b> <a href="?parent={$_path}&amp;current={$selected}">{$_title}</a></th>
			<?php $last = $iteration; ?>
		</tr>
		{/foreach}
		{foreach from=$categories item="cat"}
		<tr{if $cat.path == $parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="{$cat.path}" data-label="{$cat.title}" /></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last)?> <b class="icn">&rarr;</b> <a href="?parent={$cat.path}&amp;current={$selected}">{$cat.title}</a></th>
		</tr>
		{foreachelse}
		<tr>
			<td></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last+1)?> <b class="icn">&rarr;</b> <em>Pas de sous-catégorie…</em></th>
		</tr>
		{/foreach}







|






|







6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
			<td><input type="button" value="Choisir" data-path="" data-label="Racine du site" /></td>
			<th><h3><a href="?current={$selected}">Racine du site</a></h3></th>
		</tr>
		<?php $last = 1; ?>
		{foreach from=$breadcrumbs item="_title" key="_path"}
		<tr{if $_path == $parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="{$_path}" data-label="{$_title}" /></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $iteration)?> <b class="icn">&rarr;</b> <a href="?parent={$_path}&amp;current={$selected}">{$_title}</a></th>
			<?php $last = $iteration; ?>
		</tr>
		{/foreach}
		{foreach from=$categories item="cat"}
		<tr{if $cat.path == $parent} class="focused"{/if}>
			<td><input type="button" value="Choisir" data-path="{$cat.path}" data-label="{$cat.title}" /></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last+1)?> <b class="icn">&rarr;</b> <a href="?parent={$cat.path}&amp;current={$selected}">{$cat.title}</a></th>
		</tr>
		{foreachelse}
		<tr>
			<td></td>
			<th><?=str_repeat('<i>&nbsp;</i>', $last+1)?> <b class="icn">&rarr;</b> <em>Pas de sous-catégorie…</em></th>
		</tr>
		{/foreach}

Modified src/templates/web/edit.tpl from [a716c2cf03] to [4b0b9a3fe9].

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
<form method="post" action="{$self_url}" class="web-edit" data-focus="#f_content">

	<fieldset class="wikiMain">
		<legend>Informations générales</legend>
		<dl>
			{input type="text" name="title" source=$page required=true label="Titre"}
			{input type="text" name="uri" default=$page.uri required=true label="Adresse unique URI" help="Utilisée pour désigner l'adresse de la page sur le site. Ne peut comporter que des lettres, des chiffres, des tirets et des tirets bas." pattern="[A-Za-z0-9_-]+"}
			{input type="list" name="parent" label="Catégorie" default=$parent target="web/_selector.php?current=%s"|args:$page.path required=true}
			{input type="datetime" name="date" label="Date" required=true default=$page.published}
			<dt>Statut</dt>
			{input type="radio" name="status" value=$page::STATUS_ONLINE label="En ligne" source=$page}
			{input type="radio" name="status" value=$page::STATUS_DRAFT label="Brouillon" source=$page help="ne sera pas visible sur le site"}

		</dl>
	</fieldset>

	<fieldset class="wikiEncrypt">
		<dl>
			<dt>
				<input type="checkbox" name="encryption" id="f_encryption" {if $encrypted} checked="checked"{/if} value="1" onchange="checkEncryption(this);" />
				<label for="f_encryption">Chiffrer le contenu</label> <i>(facultatif)</i>
			</dt>
			<noscript>
			<dd>Nécessite JavaScript activé pour fonctionner !</dd>
			</noscript>
			<dd>Mot de passe : <i id="encryptPasswordDisplay" title="Chiffrement désactivé">désactivé</i></dd>
			<dd class="help">Le mot de passe n'est ni transmis ni enregistré,
				il n'est pas possible de retrouver le contenu si vous perdez le mot de passe.</dd>
		</dl>
	</fieldset>


	<fieldset class="wikiText">
		<div class="textEditor">
			{input type="textarea" name="content" cols="70" rows="35" default=$new_content data-attachments=1 data-savebtn=2 data-preview-url="!common/files/_preview.php?w=%s"|local_url|args:$page.path}
		</div>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		<input type="hidden" name="editing_started" value="{$editing_started}" />
		{button type="submit" name="save" label="Enregistrer et fermer" shape="upload" class="main"}
	</p>

</form>

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







|




>





<
<
<
<












|












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
<form method="post" action="{$self_url}" class="web-edit" data-focus="#f_content">

	<fieldset class="wikiMain">
		<legend>Informations générales</legend>
		<dl>
			{input type="text" name="title" source=$page required=true label="Titre"}
			{input type="text" name="uri" default=$page.uri required=true label="Adresse unique URI" help="Utilisée pour désigner l'adresse de la page sur le site. Ne peut comporter que des lettres, des chiffres, des tirets et des tirets bas." pattern="[A-Za-z0-9_-]+"}
			{input type="list" name="parent" label="Catégorie" default=$parent target="web/_selector.php?current=%s&parent=%s"|args:$page.path,$page.parent required=true}
			{input type="datetime" name="date" label="Date" required=true default=$page.published}
			<dt>Statut</dt>
			{input type="radio" name="status" value=$page::STATUS_ONLINE label="En ligne" source=$page}
			{input type="radio" name="status" value=$page::STATUS_DRAFT label="Brouillon" source=$page help="ne sera pas visible sur le site"}
			{input type="select" name="format" options=$formats source=$page label="Format"}
		</dl>
	</fieldset>

	<fieldset class="wikiEncrypt">
		<dl>




			<noscript>
			<dd>Nécessite JavaScript activé pour fonctionner !</dd>
			</noscript>
			<dd>Mot de passe : <i id="encryptPasswordDisplay" title="Chiffrement désactivé">désactivé</i></dd>
			<dd class="help">Le mot de passe n'est ni transmis ni enregistré,
				il n'est pas possible de retrouver le contenu si vous perdez le mot de passe.</dd>
		</dl>
	</fieldset>


	<fieldset class="wikiText">
		<div class="textEditor">
			{input type="textarea" name="content" cols="70" rows="35" default=$new_content data-attachments=1 data-savebtn=2 data-preview-url="!common/files/_preview.php?w=%s"|local_url|args:$page.path data-format="#f_format"}
		</div>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		<input type="hidden" name="editing_started" value="{$editing_started}" />
		{button type="submit" name="save" label="Enregistrer et fermer" shape="upload" class="main"}
	</p>

</form>

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

Modified src/templates/web/index.tpl from [a8040f4151] to [387d7766db].

39
40
41
42
43
44
45


46
47
48
49
50
51
52
..
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103




104
105
106
</nav>

{if $config.site_disabled && $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)}
	<p class="block alert">
		Le site public est désactivé. <a href="{"!web/config.php"|local_url}">Activer le site dans la configuration.</a>
	</p>
{/if}



{if count($categories)}
	<h2 class="ruler">Catégories</h2>
	<table class="list">
		<tbody>
			{foreach from=$categories item="p"}
			<tr>
................................................................................
			{foreach from=$pages item="p"}
			<tr>
				<th>{$p.title}</th>
				<td>{if $p.status == $p::STATUS_ONLINE}En ligne{else}<em>Brouillon</em>{/if}</td>
				<td>{$p.created|date_short}</td>
				<td>Modifié {$p.modified|relative_date:true}</td>
				<td class="actions">
					{if $p.status == $p::STATUS_ONLINE}
						{linkbutton shape="eye" label="Voir sur le site" href=$p->url() target="_blank"}
					{/if}
					{linkbutton shape="image" label="Prévisualiser" href="page.php?p=%s"|args:$p.path}
					{if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)}
					{linkbutton shape="edit" label="Éditer" href="edit.php?p=%s"|args:$p.path}
					{linkbutton shape="delete" label="Supprimer" target="_dialog" href="delete.php?p=%s"|args:$p.path}
					{/if}
				</td>
			</tr>
			{/foreach}
		</tbody>
	</table>
{/if}






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







>
>







 







|













>
>
>
>



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
..
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
</nav>

{if $config.site_disabled && $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)}
	<p class="block alert">
		Le site public est désactivé. <a href="{"!web/config.php"|local_url}">Activer le site dans la configuration.</a>
	</p>
{/if}

{form_errors}

{if count($categories)}
	<h2 class="ruler">Catégories</h2>
	<table class="list">
		<tbody>
			{foreach from=$categories item="p"}
			<tr>
................................................................................
			{foreach from=$pages item="p"}
			<tr>
				<th>{$p.title}</th>
				<td>{if $p.status == $p::STATUS_ONLINE}En ligne{else}<em>Brouillon</em>{/if}</td>
				<td>{$p.created|date_short}</td>
				<td>Modifié {$p.modified|relative_date:true}</td>
				<td class="actions">
					{if $p.status == $p::STATUS_ONLINE && !$config.site_disabled}
						{linkbutton shape="eye" label="Voir sur le site" href=$p->url() target="_blank"}
					{/if}
					{linkbutton shape="image" label="Prévisualiser" href="page.php?p=%s"|args:$p.path}
					{if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)}
					{linkbutton shape="edit" label="Éditer" href="edit.php?p=%s"|args:$p.path}
					{linkbutton shape="delete" label="Supprimer" target="_dialog" href="delete.php?p=%s"|args:$p.path}
					{/if}
				</td>
			</tr>
			{/foreach}
		</tbody>
	</table>
{/if}

{if !count($categories) && !count($pages)}
	<p class="alert block">Il n'y a aucune page ou sous-catégorie dans cette catégorie.</p>
{/if}


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

Modified src/templates/web/page.tpl from [419cb9b89a] to [3803091252].

2
3
4
5
6
7
8




9
10
11
12
13
14
15

<nav class="tabs">
	{if $page.type == $page::TYPE_CATEGORY}
	<aside>
		{linkbutton shape="plus" label="Nouvelle page" href="new.php?type=%d&parent=%d"|args:$type_page,$page.path}
		{linkbutton shape="plus" label="Nouvelle catégorie" href="new.php?type=%d&parent=%d"|args:$type_category,$page.path}
	</aside>




	{/if}
	<ul>
		<li><a href="{$admin_url}web/?p={$page.parent}">Retour à la liste</a></li>
		{if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)}
			<li><a href="{$admin_url}web/edit.php?p={$page.path}">Modifier</a></li>
		{/if}
		{if $page.status == $page::STATUS_ONLINE && !$config.site_disabled}







>
>
>
>







2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

<nav class="tabs">
	{if $page.type == $page::TYPE_CATEGORY}
	<aside>
		{linkbutton shape="plus" label="Nouvelle page" href="new.php?type=%d&parent=%d"|args:$type_page,$page.path}
		{linkbutton shape="plus" label="Nouvelle catégorie" href="new.php?type=%d&parent=%d"|args:$type_category,$page.path}
	</aside>
	{else}
	<aside>
		{linkbutton href="?p=%s&toggle_type"|args:$page.path label="Transformer en catégorie" shape="reset"}
	</aside>
	{/if}
	<ul>
		<li><a href="{$admin_url}web/?p={$page.parent}">Retour à la liste</a></li>
		{if $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE)}
			<li><a href="{$admin_url}web/edit.php?p={$page.path}">Modifier</a></li>
		{/if}
		{if $page.status == $page::STATUS_ONLINE && !$config.site_disabled}

Modified src/www/admin/acc/reports/trial_balance.php from [a483e23c57] to [c1cd5c7e43].

2
3
4
5
6
7
8
9
10
11

namespace Garradin;

use Garradin\Accounting\Reports;

require_once __DIR__ . '/_inc.php';

$tpl->assign('balance', Reports::getClosingSumsWithAccounts($criterias));

$tpl->display('acc/reports/trial_balance.tpl');







|


2
3
4
5
6
7
8
9
10
11

namespace Garradin;

use Garradin\Accounting\Reports;

require_once __DIR__ . '/_inc.php';

$tpl->assign('balance', Reports::getTrialBalance($criterias));

$tpl->display('acc/reports/trial_balance.tpl');

Modified src/www/admin/common/files/_preview.php from [cc05638664] to [35c5f4da82].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
..
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Web\Render\Skriv;
use Garradin\Web\Web;

require_once __DIR__ . '/../../_inc.php';

$page = null;

if ($path = qg('f')) {
................................................................................
}
else {
	throw new UserException('Fichier inconnu');
}

$prefix = $page ? 'web/page.php?uri=' : 'common/files/_preview.php?p=' . File::CONTEXT_DOCUMENTS . '/';

$content = Skriv::render($file, f('content'), ['prefix' => ADMIN_URL . $prefix]);

$tpl->assign(compact('file', 'content'));

$tpl->assign('custom_css', ['!web/css.php']);

$tpl->display('common/files/_preview.tpl');






|







 







|






1
2
3
4
5
6
7
8
9
10
11
12
13
14
..
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Web\Render\Render;
use Garradin\Web\Web;

require_once __DIR__ . '/../../_inc.php';

$page = null;

if ($path = qg('f')) {
................................................................................
}
else {
	throw new UserException('Fichier inconnu');
}

$prefix = $page ? 'web/page.php?uri=' : 'common/files/_preview.php?p=' . File::CONTEXT_DOCUMENTS . '/';

$content = Render::render(f('format'), $file, f('content'), ['prefix' => ADMIN_URL . $prefix]);

$tpl->assign(compact('file', 'content'));

$tpl->assign('custom_css', ['!web/css.php']);

$tpl->display('common/files/_preview.tpl');

Modified src/www/admin/common/files/edit.php from [9d117de49a] to [5a8ed5bb74].

12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
	throw new UserException('Fichier inconnu');
}

if (!$file->checkWriteAccess($session)) {
	throw new UserException('Vous n\'avez pas le droit de modifier ce fichier.');
}

$editor = $file->getEditor();
$csrf_key = 'edit_file_' . $file->pathHash();

$form->runIf('content', function () use ($file) {
	$file->setContent(f('content'));

	if (qg('js') !== null) {
		die('{"success":true}');







|







12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
	throw new UserException('Fichier inconnu');
}

if (!$file->checkWriteAccess($session)) {
	throw new UserException('Vous n\'avez pas le droit de modifier ce fichier.');
}

$editor = $file->editorType();
$csrf_key = 'edit_file_' . $file->pathHash();

$form->runIf('content', function () use ($file) {
	$file->setContent(f('content'));

	if (qg('js') !== null) {
		die('{"success":true}');

Modified src/www/admin/config/edit_file.php from [a041a95b07] to [8a746033ac].

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
	$file = File::create(Utils::dirname($file_path), Utils::basename($file_path), null, '');
	$content = '';
}
else {
	$content = $file->fetch();
}

$editor = $file->getEditor();
$csrf_key = 'edit_file_' . $file->pathHash();

$form->runIf('save', function () use ($file, $key) {
	// For config files, make sure config value is updated
	$config = Config::getInstance();

	if (trim(f('content')) === '') {







|







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
	$file = File::create(Utils::dirname($file_path), Utils::basename($file_path), null, '');
	$content = '';
}
else {
	$content = $file->fetch();
}

$editor = $file->editorType();
$csrf_key = 'edit_file_' . $file->pathHash();

$form->runIf('save', function () use ($file, $key) {
	// For config files, make sure config value is updated
	$config = Config::getInstance();

	if (trim(f('content')) === '') {

Added src/www/admin/docs/zip.php version [a9aa6c6682].







































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

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Files\Transactions;
use Garradin\Files\Users;
use Garradin\Entities\Files\File;

require_once __DIR__ . '/_inc.php';

$path = trim(qg('p')) ?: File::CONTEXT_DOCUMENTS;

$name = preg_replace('/[^\p{L}_-]+/i', '_', $path);
$name = sprintf('%s - Fichiers - %s.zip', Config::getInstance()->get('nom_asso'), $name);
header('Content-type: application/zip');
header(sprintf('Content-Disposition: attachment; filename="%s"', $name));

Files::zip($path, $session);

Modified src/www/admin/me/edit.php from [37640b7c31] to [80d70aeee3].

1
2
3
4
5
6
7
8
9
10
11
..
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php
namespace Garradin;

require_once __DIR__ . '/_inc.php';

$csrf_key = 'edit_my_info';

$form->runIf('save', function () use ($session) {
	$data = [];
	$config = Config::getInstance();
	$champs = Config::getInstance()->get('champs_membres');
................................................................................
	}

	if (isset($data[$config->get('champ_identifiant')]) && !trim($data[$config->get('champ_identifiant')]) && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) {
		throw new UserException("Le champ identifiant ne peut être vide pour un administrateur, sinon vous ne pourriez plus vous connecter.");
	}

	$session->editUser($data);
}, $csrf_key, '!mes_infos.php');

$data = $session->getUser();
$champs = Config::getInstance()->get('champs_membres')->getAll();

$tpl->assign(compact('csrf_key', 'champs', 'data'));

$tpl->display('admin/mes_infos_modifier.tpl');



|







 







|






|
1
2
3
4
5
6
7
8
9
10
11
..
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php
namespace Garradin;

require_once __DIR__ . '/../_inc.php';

$csrf_key = 'edit_my_info';

$form->runIf('save', function () use ($session) {
	$data = [];
	$config = Config::getInstance();
	$champs = Config::getInstance()->get('champs_membres');
................................................................................
	}

	if (isset($data[$config->get('champ_identifiant')]) && !trim($data[$config->get('champ_identifiant')]) && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) {
		throw new UserException("Le champ identifiant ne peut être vide pour un administrateur, sinon vous ne pourriez plus vous connecter.");
	}

	$session->editUser($data);
}, $csrf_key, '!me/?ok');

$data = $session->getUser();
$champs = Config::getInstance()->get('champs_membres')->getAll();

$tpl->assign(compact('csrf_key', 'champs', 'data'));

$tpl->display('me/edit.tpl');

Added src/www/admin/me/export.php version [0d8851baae].























































































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

use Garradin\Services\Services_User;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use KD2\ZipWriter;

require_once __DIR__ . '/../_inc.php';

$data = $session->getUser();
$champs = Config::getInstance()->get('champs_membres');
$champs_list = $champs->getList();

$services_list = Services_User::perUserList($user->id);
$services_list->setPageSize(null);

$export_data = [
	'user' => $data,
	'services' => $services_list->asArray(),
];

$tpl->assign(compact('champs_list', 'data', 'services_list'));

$name = sprintf('%s - Donnees - %s.zip', Config::getInstance()->get('nom_asso'), $data->identite);
header('Content-type: application/zip');
header(sprintf('Content-Disposition: attachment; filename="%s"', $name));

$zip = new ZipWriter('php://output');
$zip->setCompression(0);


$zip->add('info.html', $tpl->fetch('me/export.tpl'));
$zip->add('info.json', json_encode($export_data));

foreach (Files::listForContext(File::CONTEXT_USER, $data->id) as $dir) {
	foreach (Files::list($dir->path) as $file) {
		$zip->add($file->path, null, $file->fullpath());
	}
}

$zip->close();

Modified src/www/admin/me/index.php from [56acaec485] to [cd4ab73468].

1
2
3
4
5
6
7
8


9
10
11
<?php
namespace Garradin;

require_once __DIR__ . '/_inc.php';

$data = $session->getUser();
$champs = Config::getInstance()->get('champs_membres')->getList();



$tpl->assign(compact('champs', 'data'));

$tpl->display('admin/mes_infos.tpl');



|




>
>
|

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

require_once __DIR__ . '/../_inc.php';

$data = $session->getUser();
$champs = Config::getInstance()->get('champs_membres')->getList();

$ok = qg('ok');

$tpl->assign(compact('champs', 'data', 'ok'));

$tpl->display('me/index.tpl');

Modified src/www/admin/me/security.php from [07c69221f1] to [52a874b9dd].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
<?php

namespace Garradin;

require_once __DIR__ . '/_inc.php';

$confirm = false;

if (f('confirm'))
{
    $form->check('edit_me_security', [
        'passe'       => 'confirmed|min:6',
        'passe_check' => 'required',
    ]);

    if (f('passe_check') && !$session->checkPassword(f('passe_check'), $user->passe))
    {
        $form->addError('Le mot de passe fourni ne correspond pas au mot de passe actuel. Merci de bien vouloir renseigner votre mot de passe courant pour confirmer les changements.');
    }
    elseif (f('otp_secret') && f('otp_secret') != 'disable' && !f('code')) {
        $form->addError('Le code OTP est obligatoire');
    }
    elseif (f('code') && !$session->checkOTP(f('otp_secret'), f('code')))
    {
        $form->addError('Le code TOTP entré n\'est pas valide.');
    }

    if (!$form->hasErrors())
    {
        try {
            $data = [
                'clef_pgp' => f('clef_pgp'),
            ];

            if (f('passe') && !empty($config->get('champs_membres')->get('passe')->editable))
            {
                $data['passe'] = f('passe');
            }

            if (f('otp_secret') == 'disable')
            {
                $data['secret_otp'] = null;
            }
            elseif (f('otp_secret') !== null)
            {
                $data['secret_otp'] = f('otp_secret');
            }

            $session->editSecurity($data);
            Utils::redirect(ADMIN_URL . 'mes_infos_securite.php?ok');
        }
        catch (UserException $e)
        {
            $form->addError($e->getMessage());
        }
    }

    $confirm = true;
}
elseif (f('save'))
{
    $form->check('edit_me_security', [
        'passe'       => 'confirmed|min:6',
    ]);

    if (f('clef_pgp') && !$session->getPGPFingerprint(f('clef_pgp')))
    {
        $form->addError('Clé PGP invalide : impossible de récupérer l\'empreinte de la clé.');
    }
    
    if (!$form->hasErrors())
    {
        $confirm = true;
    }
}

$tpl->assign('confirm', $confirm);

if (f('otp') == 'generate')
{
    $tpl->assign('otp', $session->getNewOTPSecret());
}
elseif (f('otp') == 'disable')
{
    $tpl->assign('otp', 'disable');
}
elseif (f('otp_secret'))
{
    $tpl->assign('otp', $session->getOTPSecret(f('otp_secret')));
}
else
{
    $tpl->assign('otp', false);
}

$tpl->assign('pgp_disponible', \KD2\Security::canUseEncryption());

$fingerprint = '';

if ($user->clef_pgp)
{
    $fingerprint = $session->getPGPFingerprint($user->clef_pgp, true);
}

$tpl->assign('clef_pgp_fingerprint', $fingerprint);

$tpl->assign('passphrase', Utils::suggestPassword());
$tpl->assign('champs', $config->get('champs_membres')->getAll());

$tpl->assign('membre', $user);
$tpl->assign('ok', qg('ok') !== null);

$tpl->display('admin/mes_infos_securite.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
<?php

namespace Garradin;

require_once __DIR__ . '/../_inc.php';

$confirm = false;

if (f('confirm'))
{
	$form->check('edit_me_security', [
		'passe'       => 'confirmed|min:6',
		'passe_check' => 'required',
	]);

	if (f('passe_check') && !$session->checkPassword(f('passe_check'), $user->passe))
	{
		$form->addError('Le mot de passe fourni ne correspond pas au mot de passe actuel. Merci de bien vouloir renseigner votre mot de passe courant pour confirmer les changements.');
	}
	elseif (f('otp_secret') && f('otp_secret') != 'disable' && !f('code')) {
		$form->addError('Le code OTP est obligatoire');
	}
	elseif (f('code') && !$session->checkOTP(f('otp_secret'), f('code')))
	{
		$form->addError('Le code TOTP entré n\'est pas valide.');
	}

	if (!$form->hasErrors())
	{
		try {
			$data = [
				'clef_pgp' => f('clef_pgp'),
			];

			if (f('passe') && !empty($config->get('champs_membres')->get('passe')->editable))
			{
				$data['passe'] = f('passe');
			}

			if (f('otp_secret') == 'disable')
			{
				$data['secret_otp'] = null;
			}
			elseif (f('otp_secret') !== null)
			{
				$data['secret_otp'] = f('otp_secret');
			}

			$session->editSecurity($data);
			Utils::redirect(ADMIN_URL . 'me/security.php?ok');
		}
		catch (UserException $e)
		{
			$form->addError($e->getMessage());
		}
	}

	$confirm = true;
}
elseif (f('save'))
{
	$form->check('edit_me_security', [
		'passe'       => 'confirmed|min:6',
	]);

	if (f('clef_pgp') && !$session->getPGPFingerprint(f('clef_pgp')))
	{
		$form->addError('Clé PGP invalide : impossible de récupérer l\'empreinte de la clé.');
	}
	
	if (!$form->hasErrors())
	{
		$confirm = true;
	}
}

$tpl->assign('confirm', $confirm);

if (f('otp') == 'generate')
{
	$tpl->assign('otp', $session->getNewOTPSecret());
}
elseif (f('otp') == 'disable')
{
	$tpl->assign('otp', 'disable');
}
elseif (f('otp_secret'))
{
	$tpl->assign('otp', $session->getOTPSecret(f('otp_secret')));
}
else
{
	$tpl->assign('otp', false);
}

$tpl->assign('pgp_disponible', \KD2\Security::canUseEncryption());

$fingerprint = '';

if ($user->clef_pgp)
{
	$fingerprint = $session->getPGPFingerprint($user->clef_pgp, true);
}

$tpl->assign('clef_pgp_fingerprint', $fingerprint);

$tpl->assign('passphrase', Utils::suggestPassword());
$tpl->assign('champs', $config->get('champs_membres')->getAll());

$tpl->assign('membre', $user);
$tpl->assign('ok', qg('ok') !== null);

$tpl->display('me/security.tpl');

Modified src/www/admin/me/services.php from [395c086295] to [29032889e2].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
namespace Garradin;

use Garradin\Services\Services_User;

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->display('my_services.tpl');





|










|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
namespace Garradin;

use Garradin\Services\Services_User;

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->display('me/services.tpl');

Added src/www/admin/services/reminders/user.php version [2a049e4faf].































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
namespace Garradin;

use Garradin\Entities\Services\Reminder;
use Garradin\Services\Reminders;
use Garradin\Services\Services;

require_once __DIR__ . '/../_inc.php';

$user_id = (int) qg('id');
$list = Reminders::listSentForUser($user_id);

$tpl->assign(compact('list', 'user_id'));

$tpl->display('services/reminders/user.tpl');

Modified src/www/admin/static/scripts/wiki-encryption.js from [7a2e88e445] to [f41b9dc8c7].

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
...
130
131
132
133
134
135
136

137
138
139
140
141
142
143
144
145
146
147
148
149
...
152
153
154
155
156
157
158

159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176

177

178
179
180

181
182
183
184
185


186
187
188
189
190
191
192
193
194
195

196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248

		// nl2br
		content = content.replace(/\r/g, '').replace(/\n/g, '<br />');

		return content;
	}















































	window.wikiDecrypt = function ()
	{



















		load_aes();

		encryptPassword = window.prompt('Mot de passe ?');

		if (!encryptPassword)
		{
			encryptPassword = null;

			if (document.getElementById('f_content'))

			{
				if (window.confirm("Aucun mot de passe entré.\nDésactiver le chiffrement et effacer le contenu ?"))
				{
					document.getElementById('f_content').value = '';
					document.getElementById('f_encryption').checked = false;
					checkEncryption(document.getElementById('f_encryption'));
				}
				else
				{
					wikiDecrypt();
				}
			}

			return;
		}

		iteration = 0;




		decrypt();
	};

	var decrypt = function ()
	{




		if (typeof GibberishAES == 'undefined')
		{
			if (iteration >= 10)
			{
				iteration = 0;
				encryptPassword = null;
				window.alert("Impossible de charger la bibliothèque AES, empêchant le déchiffrement de la page.\nAttendez quelques instants avant de recommencer ou rechargez la page.");
................................................................................
			}

			iteration++;
			window.setTimeout(decrypt, 500);
			return;
		}


		var content = document.getElementById('f_content');
		var edit = true;

		if (!content) {
		 	content = document.getElementById('wikiEncryptedContent');
		 	edit = false;
		}

		var wikiContent = content.value || content.innerText;
		wikiContent = wikiContent.replace(/\s+/g, '');

		try {
			wikiContent = GibberishAES.dec(wikiContent, encryptPassword);
................................................................................
		{
			encryptPassword = null;
			window.alert('Impossible de déchiffrer. Mauvais mot de passe ?');

			if (edit)
			{
				// Redemander le mot de passe

				wikiDecrypt();
			}
			return false;
		}

		if (!edit)
		{
			content.style.display = 'block';
			document.getElementById('wikiEncryptedMessage').style.display = 'none';
			content.innerHTML = formatContent(wikiContent);
		}
		else
		{
			content.value = wikiContent;
			checkEncryption(document.getElementById('f_encryption'));
		}
	};


	window.checkEncryption = function(elm)

	{
		String.prototype.repeat = function(num)
		{

			return new Array(num + 1).join(this);
		};

		if (elm.checked)
		{


			if (!encryptPassword)
			{
				wikiDecrypt();
			}

			if (!encryptPassword)
			{
				elm.checked = false;
				encryptPassword = null;
				return;

			}

			load_aes(function () {
				var hidden = true;
				var d = document.getElementById('encryptPasswordDisplay');
				d.innerHTML = '&bull;'.repeat(encryptPassword.length);
				d.title = 'Cliquer pour voir le mot de passe';
				d.onclick = function () {
					if (hidden)
					{
						this.innerHTML = encryptPassword;
						this.title = 'Cliquer pour cacher le mot de passe.';
					}
					else
					{
						this.innerHTML = '&bull;'.repeat(encryptPassword.length);
						this.title = 'Cliquer pour voir le mot de passe';
					}
					hidden = !hidden;
				};

				elm.form.onsubmit = function ()
				{
					if (typeof GibberishAES == 'undefined')
					{
						alert("Le chargement de la bibliothèque AES n'est pas terminé.\nLe chiffrement est impossible pour le moment, recommencez dans quelques instants ou désactivez le chiffrement.");
						return false;
					}

					var content = document.getElementById('f_content');
					content.value = GibberishAES.enc(content.value, encryptPassword);
					content.readOnly = true;
					return true;
				};
			});
		}
		else
		{
			encryptPassword = null;
			var d = document.getElementById('encryptPasswordDisplay');
			d.innerHTML = 'désactivé';
			d.title = 'Chiffrement désactivé';
			d.onclick = null;
			elm.form.onsubmit = null;
		}
	};

	document.addEventListener('DOMContentLoaded', () => {
		if (e = document.getElementById('f_encryption')) {
			checkEncryption(e);
		}
	});
} ());







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








<
>



|
<
<
<
<
<
<







>
>
>
>





>
>
>
>







 







>
|
<
|
|
|
<







 







>
|













<



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



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163

164
165
166
167
168






169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
...
197
198
199
200
201
202
203
204
205

206
207
208

209
210
211
212
213
214
215
...
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

		// nl2br
		content = content.replace(/\r/g, '').replace(/\n/g, '<br />');

		return content;
	}

	let edit = document.getElementById('f_content') ? true : false;

	let disableEncryption = (reset) => {
		if (reset) {
			document.getElementById('f_content').value = '';
			document.getElementById('f_format').selectedIndex = 0;
		}

		document.getElementById('f_content').disabled = false;
		encryptPassword = null;
	};

	let enableEncryption = (form, do_decrypt) => {
		document.getElementById('f_content').disabled = true;

		String.prototype.repeat = function(num)
		{
			return new Array(num + 1).join(this);
		};

		load_aes(function () {
			askPassword();
			document.getElementById('f_content').disabled = false;

			if (do_decrypt) {
				decrypt();
			}

			var hidden = true;
			var d = document.getElementById('encryptPasswordDisplay');
			d.innerHTML = '&bull;'.repeat(encryptPassword.length);
			d.title = 'Cliquer pour voir le mot de passe';
			d.onclick = function () {
				if (hidden)
				{
					this.innerHTML = encryptPassword;
					this.title = 'Cliquer pour cacher le mot de passe.';
				}
				else
				{
					this.innerHTML = '&bull;'.repeat(encryptPassword.length);
					this.title = 'Cliquer pour voir le mot de passe';
				}
				hidden = !hidden;
			};

			form.onsubmit = function ()
			{
				if (typeof GibberishAES == 'undefined')
				{
					alert("Le chargement de la bibliothèque AES n'est pas terminé.\nLe chiffrement est impossible pour le moment, recommencez dans quelques instants ou désactivez le chiffrement.");
					return false;
				}

				if (!encryptPassword) {
					return;
				}

				var content = document.getElementById('f_content');
				content.value = GibberishAES.enc(content.value, encryptPassword);
				content.readOnly = true;
				return true;
			};
		});
	};

	let askPassword = () => {
		load_aes();

		encryptPassword = window.prompt('Mot de passe ?');

		if (!encryptPassword)
		{
			encryptPassword = null;


			if (edit)
			{
				if (window.confirm("Aucun mot de passe entré.\nDésactiver le chiffrement et effacer le contenu ?"))
				{
					disableEncryption(true);






				}
			}

			return;
		}

		iteration = 0;
	};

	window.pleaseDecrypt = () => {
		askPassword();
		decrypt();
	};

	var decrypt = function ()
	{
		if (!encryptPassword) {
			return;
		}

		if (typeof GibberishAES == 'undefined')
		{
			if (iteration >= 10)
			{
				iteration = 0;
				encryptPassword = null;
				window.alert("Impossible de charger la bibliothèque AES, empêchant le déchiffrement de la page.\nAttendez quelques instants avant de recommencer ou rechargez la page.");
................................................................................
			}

			iteration++;
			window.setTimeout(decrypt, 500);
			return;
		}

		if (edit) {
			var content = document.getElementById('f_content');

		}
		else {
		 	var content = document.getElementById('wikiEncryptedContent');

		}

		var wikiContent = content.value || content.innerText;
		wikiContent = wikiContent.replace(/\s+/g, '');

		try {
			wikiContent = GibberishAES.dec(wikiContent, encryptPassword);
................................................................................
		{
			encryptPassword = null;
			window.alert('Impossible de déchiffrer. Mauvais mot de passe ?');

			if (edit)
			{
				// Redemander le mot de passe
				askPassword();
				decrypt();
			}
			return false;
		}

		if (!edit)
		{
			content.style.display = 'block';
			document.getElementById('wikiEncryptedMessage').style.display = 'none';
			content.innerHTML = formatContent(wikiContent);
		}
		else
		{
			content.value = wikiContent;

		}
	};

	document.addEventListener('DOMContentLoaded', () => {
		if (e = document.getElementById('f_format')) {
			edit = true;



			if (e.value == "skriv/encrypted") {
				enableEncryption(e.form, true);

			}


			e.addEventListener('change', () => {
				if (e.value == 'skriv/encrypted') {
					enableEncryption(e.form);


				}

				else if (encryptPassword) {




					disableEncryption(false);
				}











			})





































		}
	});
} ());

Modified src/www/admin/static/scripts/wiki_editor.js from [8d430d7dbe] to [25e6fd36a1].

27
28
29
30
31
32
33
34

35
36
37
38
39
40
41
..
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
...
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
...
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
		var t = new textEditor('f_content');
		t.parent = t.textarea.parentNode;

		var config = {
			fullscreen: t.textarea.getAttribute('data-fullscreen') == 1,
			attachments: t.textarea.getAttribute('data-attachments') == 1,
			savebtn: t.textarea.getAttribute('data-savebtn'),
			preview_url: t.textarea.getAttribute('data-preview-url')

		};

		// Cancel Escape to close.value
		if (window.parent && window.parent.g.dialog) {
			// Always fullscreen in dialogs
			config.fullscreen = true;

................................................................................

		var openPreview = function ()
		{
			openIFrame('');
			var form = document.createElement('form');
			form.appendChild(t.textarea.cloneNode(true));
			form.firstChild.value = t.textarea.value;





			form.target = 'editorFrame';
			form.action = config.preview_url;
			form.style.display = 'none';
			form.method = 'post';
			document.body.appendChild(form);
			form.submit();
			return true;
		};

		var openSyntaxHelp = function ()
		{

			openIFrame(g.admin_url + 'web/_syntaxe.html');
			return true;


		};

		var openFileInsert = function ()
		{
			let args = new URLSearchParams(window.location.search);
			var wiki_id = args.get('p');
			g.openFrameDialog(g.admin_url + 'web/_attach.php?_dialog&p=' + wiki_id);
			return true;
		};

		window.te_insertFile = function (file)
		{
			var tag = '<<file|'+file+'>>';

			t.insertAtPosition(t.getSelection().start, tag);
................................................................................
			}
			btn.className = 'icn-btn ' +name;
			btn.onclick = function () { action.call(); return false; };

			toolbar.appendChild(btn);
			return btn;
		};


















		var wrapTags = function (left, right)
		{
			t.wrapSelection(t.getSelection(), left, right);
			return true;
		};

		let insertURL = function () {
			if (url = window.prompt('Adresse URL ?'))
				wrapTags("[[", "|" + url + ']]');


			return true;








		};

		let save = function () {
			const data = new URLSearchParams();

			for (const pair of new FormData(t.textarea.form)) {
				data.append(pair[0], pair[1]);
................................................................................
				}
				showSaved();
				t.textarea.defaultValue = t.textarea.value;
			}).catch(e => t.textarea.form.querySelector('[type=submit]').click() );
			return true;
		};


		appendButton('title', "== Titre", function () { wrapTags("== ", ""); } );
		appendButton('bold', '**gras**', function () { wrapTags('**', '**'); } );
		appendButton('italic', "''italique''", function () { wrapTags("''", "''"); } );
		appendButton('link', "[[lien|http://]]", insertURL);

		appendButton('ext preview', '👁', openPreview, 'Prévisualiser');
		appendButton('ext help', '❓', openSyntaxHelp, 'Aide sur la syntaxe');

		if (config.attachments) {
			appendButton('file', "📎 Fichiers", openFileInsert, 'Insérer fichier / image');
			t.shortcuts.push({ctrl: true, shift: true, key: 'i', callback: openFileInsert});
		}

		if (!config.fullscreen) {
			appendButton('ext fullscreen', 'Plein écran', toggleFullscreen, 'Plein écran');
		}

		if (config.savebtn == 1) {
			appendButton('ext save', 'Enregistrer', save, 'Enregistrer');
		}
		else if (config.savebtn == 2) {
			appendButton('ext save', '⇑', save, 'Enregistrer sans fermer');
		}

		appendButton('ext close', 'Fermer', closeIFrame);

		t.parent.insertBefore(toolbar, t.parent.firstChild);




















		t.shortcuts.push({key: 'F11', callback: toggleFullscreen});
		t.shortcuts.push({ctrl: true, key: 'b', callback: function () { return wrapTags('**', '**'); } });
		t.shortcuts.push({ctrl: true, key: 'g', callback: function () { return wrapTags('**', '**'); } });
		t.shortcuts.push({ctrl: true, key: 'i', callback: function () { return wrapTags("''", "''"); } });
		t.shortcuts.push({ctrl: true, key: 't', callback: function () { return wrapTags("\n== ", "\n"); } });
		t.shortcuts.push({ctrl: true, key: 'l', callback: insertURL});
		t.shortcuts.push({ctrl: true, key: 's', callback: save});
		t.shortcuts.push({ctrl: true, shift: true, key: 'p', callback: openPreview});
		t.shortcuts.push({key: 'F1', callback: openSyntaxHelp});
	});
}());







|
>







 







>
>
>
>
>











>
|
<
>
>







<







 







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








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







 







>
|
|
|
|
>
|
|

|
|
|
|

|
|
|

|
|
|
|
|
|

|

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

|
|
|
|






27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
..
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

94
95
96
97
98
99
100
101
102

103
104
105
106
107
108
109
...
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
...
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
		var t = new textEditor('f_content');
		t.parent = t.textarea.parentNode;

		var config = {
			fullscreen: t.textarea.getAttribute('data-fullscreen') == 1,
			attachments: t.textarea.getAttribute('data-attachments') == 1,
			savebtn: t.textarea.getAttribute('data-savebtn'),
			preview_url: t.textarea.getAttribute('data-preview-url'),
			format: t.textarea.getAttribute('data-format')
		};

		// Cancel Escape to close.value
		if (window.parent && window.parent.g.dialog) {
			// Always fullscreen in dialogs
			config.fullscreen = true;

................................................................................

		var openPreview = function ()
		{
			openIFrame('');
			var form = document.createElement('form');
			form.appendChild(t.textarea.cloneNode(true));
			form.firstChild.value = t.textarea.value;
			let f = document.createElement('input');
			f.type = 'hidden';
			f.name = 'format';
			f.value = config.format;
			form.appendChild(f);
			form.target = 'editorFrame';
			form.action = config.preview_url;
			form.style.display = 'none';
			form.method = 'post';
			document.body.appendChild(form);
			form.submit();
			return true;
		};

		var openSyntaxHelp = function ()
		{
			let url = config.format == 'markdown' ? '_syntax_markdown.html' : '_syntax_skriv.html';
			url = g.admin_url + 'web/' + url;


			openIFrame(url);
		};

		var openFileInsert = function ()
		{
			let args = new URLSearchParams(window.location.search);
			var wiki_id = args.get('p');
			g.openFrameDialog(g.admin_url + 'web/_attach.php?_dialog&p=' + wiki_id);

		};

		window.te_insertFile = function (file)
		{
			var tag = '<<file|'+file+'>>';

			t.insertAtPosition(t.getSelection().start, tag);
................................................................................
			}
			btn.className = 'icn-btn ' +name;
			btn.onclick = function () { action.call(); return false; };

			toolbar.appendChild(btn);
			return btn;
		};

		let applyHeader = () => {
			wrapTags(config.format == 'markdown' ? '## ' : '== ', '');
		};

		let applyBold = () => {
			wrapTags('**', '**');
		};

		let applyItalic = () => {
			if (config.format == 'markdown') {
				wrapTags("_", "_");
			}
			else {
				wrapTags("''", "''");
			}
		};

		var wrapTags = function (left, right)
		{
			t.wrapSelection(t.getSelection(), left, right);
			return true;
		};

		let insertURL = function () {
			let url = window.prompt('Adresse URL ?');


			if (!url) {
				return true;
			}

			if (config.format == 'markdown') {
				wrapTags("[", "](" + url + ')');
			}
			else {
				wrapTags("[[", "|" + url + ']]');
			}
		};

		let save = function () {
			const data = new URLSearchParams();

			for (const pair of new FormData(t.textarea.form)) {
				data.append(pair[0], pair[1]);
................................................................................
				}
				showSaved();
				t.textarea.defaultValue = t.textarea.value;
			}).catch(e => t.textarea.form.querySelector('[type=submit]').click() );
			return true;
		};

		let createToolbar = () => {
			appendButton('title', "Titre", applyHeader );
			appendButton('bold', 'Gras', applyBold );
			appendButton('italic', "Italique", applyItalic );
			appendButton('link', "Lien", insertURL);

			appendButton('ext preview', '👁', openPreview, 'Prévisualiser');
			appendButton('ext help', '❓', openSyntaxHelp, 'Aide sur la syntaxe');

			if (config.attachments) {
				appendButton('file', "📎 Fichiers", openFileInsert, 'Insérer fichier / image');
				t.shortcuts.push({ctrl: true, shift: true, key: 'i', callback: openFileInsert});
			}

			if (!config.fullscreen) {
				appendButton('ext fullscreen', 'Plein écran', toggleFullscreen, 'Plein écran');
			}

			if (config.savebtn == 1) {
				appendButton('ext save', 'Enregistrer', save, 'Enregistrer');
			}
			else if (config.savebtn == 2) {
				appendButton('ext save', '⇑', save, 'Enregistrer sans fermer');
			}

			appendButton('ext close', 'Fermer', closeIFrame);

			t.parent.insertBefore(toolbar, t.parent.firstChild);
		}

		let toggleFormat = (format) => {
			config.format = format;
			g.toggle('.wikiEncrypt', format == 'skriv/encrypted');
		};

		if (config.format.substr(0, 1) == '#') {
			let s = document.querySelector(config.format);
			s.onchange = () => {
				toggleFormat(s.value);
			};
			toggleFormat(s.value);
		}
		else {
			toggleFormat(config.format);
		}

		createToolbar();

		t.shortcuts.push({key: 'F11', callback: toggleFullscreen});
		t.shortcuts.push({ctrl: true, key: 'b', callback: applyBold });
		t.shortcuts.push({ctrl: true, key: 'g', callback: applyBold });
		t.shortcuts.push({ctrl: true, key: 'i', callback: applyItalic });
		t.shortcuts.push({ctrl: true, key: 't', callback: applyHeader });
		t.shortcuts.push({ctrl: true, key: 'l', callback: insertURL});
		t.shortcuts.push({ctrl: true, key: 's', callback: save});
		t.shortcuts.push({ctrl: true, shift: true, key: 'p', callback: openPreview});
		t.shortcuts.push({key: 'F1', callback: openSyntaxHelp});
	});
}());

Modified src/www/admin/static/styles/03-forms.css from [249e20cb24] to [d66655de4b].

8
9
10
11
12
13
14

15
16
17
18
19
20
21
22

fieldset legend {
    padding: 0 0.5em;
    font-weight: bold;
    color: #000;
}


table tr.clickable:hover {
    cursor: pointer;
    color: #633;
    background: #ffc;
}

table tr.focused {
    color: #633;







>
|







8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

fieldset legend {
    padding: 0 0.5em;
    font-weight: bold;
    color: #000;
}

/* Override selector in 06-tables.css */
table tr.clickable:hover, table tr.clickable:nth-child(even):hover {
    cursor: pointer;
    color: #633;
    background: #ffc;
}

table tr.focused {
    color: #633;

Added src/www/admin/web/_syntax_markdown.html version [1df2308f82].

































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Syntaxe SkrivML</title>
  <style type="text/css">
  body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
      margin: 0;
      padding: 0;
  }
  h1  { font-size: 2em; }
  h2  { font-size: 1.5em; }
  h3  { font-size: 1.2em; }
  h4  { font-size: 1em; }
  h5  { font-size: 0.9em; }
  h6  { font-size: 0.8em; }
  article, aside, figure, footer, header, hgroup, menu, nav, section { display: block; }

  body {
    font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
    padding: .8em;
  }
  pre, samp {
    display: block;
    background: #ccc;
    color: #000;
    border-radius: .5em;
    padding: .4em;
  }

  code {
    background: #ccc;
    padding: .2em;
  }

  samp {
    background: #999;
    font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
  }

  p, header, article {
    margin: .8em 0;
    clear: both;
  }

  h1, h2, h3, h4, samp, pre {
    margin: .4em 0;
  }

  ul, ol {
    margin-left: 1.5em;
  }

  table {
    border-collapse: collapse;
  }
  table td, table th {
    border: 1px solid #666;
    padding: .3em;
  }

  a {
    color: blue;
    text-decoration: underline;
    cursor: pointer;
  }

  </style>
</head>

<body>

<section>
  <h1>Raccourcis clavier</h1>
  <table>
    <tbody>
      <tr>
        <th><kbd>Ctrl</kbd> + <kbd>G</kbd></th>
        <td>Mettre en gras</td>
      </tr>
      <tr>
        <th><kbd>Ctrl</kbd> + <kbd>I</kbd></th>
        <td>Mettre en italique</td>
      </tr>
      <tr>
        <th><kbd>Ctrl</kbd> + <kbd>T</kbd></th>
        <td>Mettre en titre</td>
      </tr>
      <tr>
        <th><kbd>Ctrl</kbd> + <kbd>L</kbd></th>
        <td>Mettre en lien</td>
      </tr>
      <tr>
        <th><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd></th>
        <td>Insérer un fichier ou image</td>
      </tr>
      <tr>
        <th><kbd>Ctrl</kbd> + <kbd>P</kbd></th>
        <td>Prévisualiser</td>
      </tr>
      <tr>
        <th><kbd>Ctrl</kbd> + <kbd>S</kbd></th>
        <td>Enregistrer</td>
      </tr>
      <tr>
        <th><kbd>Echap</kbd></th>
        <td>Fermer l'aide, la prévisualisation ou l'insertion de fichier</td>
      </tr>
      <tr>
        <th><kbd>F11</kbd></th>
        <td>Activer ou désactiver l'édition plein écran</td>
      </tr>
      <tr>
        <th><kbd>F1</kbd></th>
        <td>Afficher l'aide</td>
      </tr>
    </tbody>
  </table>
</section>

<section>
  <h2>Extensions Markdown</h2>
  <h3>Sommaire / table des matières automatique</h3>
  <p>Il suffit de placer la mention <code>[toc]</code> ou <code>[sommaire]</code> sur une ligne (sans aucun autre texte) pour afficher un sommaire automatique de la page&nbsp;:</p>
<div class="toc"><ol><li><a href="#La-syntaxe-markdown">La syntaxe markdown</a></li><ol><li><a href="#Styles-de-texte">Styles de texte</a></li><li><a href="#Blocs-de-code">Blocs de code</a></li><li><a href="#Liens">Liens</a></li><li><a href="#Images">Images</a></li><li><a href="#Citation">Citation</a></li><li><a href="#Listes">Listes</a></li><li><a href="#Titres">Titres</a></li><li><a href="#Tableaux">Tableaux</a></li></ol></ol></div>
  <h3>Images</h3>
  <p>Il est possible d'intégrer une image jointe à la page web en plaçant le code suivant sur une ligne (sans autre texte)&nbsp;:</p>
  <pre>&lt;&lt;image|nom_fichier.jpg|alignement|Légende&gt;&gt;</pre>
  <p><code>nom_fichier.jpg</code> indique le nom du fichier de l'image</p>
  <p><code>alignement</code> indique l'alignement de l'image peut être <code>centre</code>, <code>gauche</code>, ou <code>droite</code></p>
  <p><code>Légende</code> indique une légende facultative à afficher en dessous de l'image</p>
  <h3>Fichiers</h3>
  <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>&lt;&lt;fichier|nom_fichier.pdf|Légende&gt;&gt;</pre>
  <p><code>nom_fichier.jpg</code> indique le nom du fichier</p>
  <p><code>Légende</code> indique une légende facultative pour le texte du bouton, si aucune légende n'est donnée alors c'est le nom du fichier qui sera affiché</p>
  <h3>Notes de bas de page</h3>
  <pre>Texte très intéressant[^1]<br><br>[^1]: Ceci est une note de bas de page</pre>
</section>

<section>

<div class="web-content"><h1>La syntaxe markdown</h1>
<p>Pour formater votre texte vous avez la possibilité d'utiliser la barre d'outils située au-dessus de la zone de texte, ou vous pouvez utiliser la syntaxe markdown.</p>
<h2>Styles de texte</h2>
<p>Vous pouvez utiliser <code>_</code> ou <code>*</code> autour d'un mot pour le mettre en italique. Mettez-en deux pour le mettre en gras.</p>
<ul>
<li><code>_italique_</code> s'affiche ainsi&nbsp;: <em>italique</em></li>
<li><code>**gras**</code> s'affiche ainsi&nbsp;: <strong>gras</strong></li>
<li><code>**_gras-italique_**</code> s'affiche ainsi&nbsp;: <strong><em>gras-italique</em></strong></li>
<li><code>~~barré~~</code> s'affiche ainsi&nbsp;: <del>barré</del></li>
</ul>
<h2>Blocs de code</h2>
<p>Créez un bloc de code en indentant chaque ligne avec quatre espaces, ou en mettant trois accents graves sur la ligne au dessus et en dessous de votre code.<br>
Exemple :</p>
<p><code>```<br />bloc de code<br />```</code></p>
<p>s'affiche ainsi :</p>
<pre>bloc de code</pre>
<h2>Liens</h2>
<p>Créez un lien intégré en mettant le texte désiré entre crochets et le lien associé entre parenthèses.</p>
<p><code>Je connais un super gestionnaire [d'association](https://garradin.eu/) !</code></p>
<p>s'affichera :</p>
<p>Je connais un super gestionnaire <a href="https://garradin.eu/">d'association</a> !</p>
<h2>Images</h2>
<p>Utilisez une image en ligne en copiant son adresse (finissant par <code>.jpg</code>, <code>.png</code>, <code>.gif</code> etc…) avec un texte alternatif entre crochets (qui sera affiché si l'image n'apparaît pas) et le lien entre parenthèses.</p>
<pre>![Logo Garradin](https://fossil.kd2.org/garradin/logo)</pre>
<p>donnera :</p>
<p><img src="https://fossil.kd2.org/garradin/logo" alt="Logo Garradin"></p>
<h2>Citation</h2>
<p>Les citations se font avec le signe <code>&gt;</code> :</p>
<pre>&gt; Ah je ris de me voir si belle !</pre>
<blockquote>
<p>Ah je ris de me voir si belle !</p>
</blockquote>
<h2>Listes</h2>
<p>Vous pouvez créer des listes avec les caractères <code>*</code> et <code>-</code> pour des listes non ordonnées ou avec des nombres pour des listes ordonnées.</p>
<p>Une liste non ordonnée :</p>
<pre>* une élément
* un autre
 * un sous élément
 * un autre sous élément
* un dernier élément</pre>
<ul>
<li>une élément</li>
<li>un autre
<ul>
<li>un sous élément</li>
<li>un autre sous élément</li>
</ul></li>
<li>un dernier élément</li>
</ul>
<p>Une liste ordonnée :</p>
<pre>1. élément un
2. élément deux</pre>
<ol>
<li>élément un</li>
<li>élément deux</li>
</ol>
<h2>Titres</h2>
<p>Pour faire un titre, vous devez mettre un <code>#</code> devant la ligne. Pour faire un titre plus petit, ajoutez un <code>#</code> (jusque 6) :</p>
<pre># Un grand titre
## Un titre un peu moins grand
### Un titre encore moins grand</pre>
<h2>Tableaux</h2>
<p>Pour créer un tableau vous devez placer une ligne de tirets (<code>-</code>) sous la ligne d'entête et séparer les colonnes avec des <code>|</code>. Vous pouvez aussi préciser l'alignement en utilisant des <code>:</code>. :</p>
<pre>| Aligné à gauche  | Centré          | Aligné à droite |
| :--------------- |:---------------:| -----:|
| Aligné à gauche  |   ce texte        |  Aligné à droite |
| Aligné à gauche  | est             |   Aligné à droite |
| Aligné à gauche  | centré          |    Aligné à droite |</pre>
<table>
<thead>
<tr>
<th style="text-align&nbsp;: left;">Aligné à gauche</th>
<th style="text-align&nbsp;: center;">Centré</th>
<th style="text-align&nbsp;: right;">Aligné à droite</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align&nbsp;: left;">Aligné à gauche</td>
<td style="text-align&nbsp;: center;">ce texte</td>
<td style="text-align&nbsp;: right;">Aligné à droite</td>
</tr>
<tr>
<td style="text-align&nbsp;: left;">Aligné à gauche</td>
<td style="text-align&nbsp;: center;">est</td>
<td style="text-align&nbsp;: right;">Aligné à droite</td>
</tr>
<tr>
<td style="text-align&nbsp;: left;">Aligné à gauche</td>
<td style="text-align&nbsp;: center;">centré</td>
<td style="text-align&nbsp;: right;">Aligné à droite</td>
</tr>
</tbody>
</table></div>
</section>

</body>
</html>

Modified src/www/admin/web/_syntax_skriv.html from [1b5e7a5287] to [292a1094f3].

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
...
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
				<td>Fermer l'aide, la prévisualisation ou l'insertion de fichier</td>
			</tr>
			<tr>
				<th><kbd>F11</kbd></th>
				<td>Activer ou désactiver l'édition plein écran</td>
			</tr>
			<tr>
				<th><kbd>F11</kbd></th>
				<td>Afficher l'aide</td>
			</tr>
		</tbody>
	</table>
</section>

<header>
	<h1>Syntaxe</h1>
	<p>Garradin utilise la syntaxe <a href="http://markup.skriv.org/language/syntax">SkrivML</a> pour 
		le formatage du texte dans le wiki.</p>
</header>

















<section>
	<h2>Styles de base</h2>
	<article>
		<h3>Gras</h3>
		<pre>Utiliser **deux astérisques**.</pre>
		<samp>Utiliser <b>deux astérisques</b>.</samp>
................................................................................
	<article>
		<h3>Lien wiki avec libellé différent</h3>
		<pre>Voir [[cette page|Autre page du wiki]]</pre>
		<samp>Voir <a title="Autre_page_du_wiki">cette page</a></samp>
	</article>
	<article>
		<h3>Lien externe</h3>
		<pre>[[http://garradin.eu/]]</pre>
		<samp><a>http://garradin.eu/</a></samp>
	</article>
</section>

<section>
	<h2>Tableaux</h2>
	<article>
		<pre>!! Colonne 1 !! Colonne 2<br />|| Cellule 1 || Cellule 2<br />|| Cellule 3 || Cellule 4</pre>







|











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







 







|
|







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
...
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
				<td>Fermer l'aide, la prévisualisation ou l'insertion de fichier</td>
			</tr>
			<tr>
				<th><kbd>F11</kbd></th>
				<td>Activer ou désactiver l'édition plein écran</td>
			</tr>
			<tr>
				<th><kbd>F1</kbd></th>
				<td>Afficher l'aide</td>
			</tr>
		</tbody>
	</table>
</section>

<header>
	<h1>Syntaxe</h1>
	<p>Garradin utilise la syntaxe <a href="http://markup.skriv.org/language/syntax">SkrivML</a> pour 
		le formatage du texte dans le wiki.</p>
</header>

<section>
	<h2>Extensions</h2>
	<h3>Images</h3>
	<p>Il est possible d'intégrer une image jointe à la page web en plaçant le code suivant sur une ligne (sans autre texte)&nbsp;:</p>
	<pre>&lt;&lt;image|nom_fichier.jpg|alignement|Légende&gt;&gt;</pre>
	<p><code>nom_fichier.jpg</code> indique le nom du fichier de l'image</p>
	<p><code>alignement</code> indique l'alignement de l'image peut être <code>centre</code>, <code>gauche</code>, ou <code>droite</code></p>
	<p><code>Légende</code> indique une légende facultative à afficher en dessous de l'image</p>
	<h3>Fichiers</h3>
	<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>&lt;&lt;fichier|nom_fichier.pdf|Légende&gt;&gt;</pre>
	<p><code>nom_fichier.jpg</code> indique le nom du fichier</p>
	<p><code>Légende</code> indique une légende facultative pour le texte du bouton, si aucune légende n'est donnée alors c'est le nom du fichier qui sera affiché</p>
</section>


<section>
	<h2>Styles de base</h2>
	<article>
		<h3>Gras</h3>
		<pre>Utiliser **deux astérisques**.</pre>
		<samp>Utiliser <b>deux astérisques</b>.</samp>
................................................................................
	<article>
		<h3>Lien wiki avec libellé différent</h3>
		<pre>Voir [[cette page|Autre page du wiki]]</pre>
		<samp>Voir <a title="Autre_page_du_wiki">cette page</a></samp>
	</article>
	<article>
		<h3>Lien externe</h3>
		<pre>[[https://garradin.eu/]]</pre>
		<samp><a>https://garradin.eu/</a></samp>
	</article>
</section>

<section>
	<h2>Tableaux</h2>
	<article>
		<pre>!! Colonne 1 !! Colonne 2<br />|| Cellule 1 || Cellule 2<br />|| Cellule 3 || Cellule 4</pre>

Modified src/www/admin/web/edit.php from [735f5d3003] to [d2da8ff862].

51
52
53
54
55
56
57


58
59
60
61
62
63

$parent = $page->parent ? [$page->parent => Web::get($page->parent)->title] : ['' => 'Racine du site'];
$encrypted = f('encrypted') || $page->format == Page::FORMAT_ENCRYPTED;

$old_content = f('content');
$new_content = $page->content;



$tpl->assign(compact('page', 'parent', 'editing_started', 'encrypted', 'csrf_key', 'old_content', 'new_content', 'show_diff'));

$tpl->assign('custom_js', ['wiki_editor.js', 'wiki-encryption.js']);
$tpl->assign('custom_css', ['wiki.css']);

$tpl->display('web/edit.tpl');







>
>
|





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

$parent = $page->parent ? [$page->parent => Web::get($page->parent)->title] : ['' => 'Racine du site'];
$encrypted = f('encrypted') || $page->format == Page::FORMAT_ENCRYPTED;

$old_content = f('content');
$new_content = $page->content;

$formats = $page::FORMATS_LIST;

$tpl->assign(compact('page', 'parent', 'editing_started', 'encrypted', 'csrf_key', 'old_content', 'new_content', 'show_diff', 'formats'));

$tpl->assign('custom_js', ['wiki_editor.js', 'wiki-encryption.js']);
$tpl->assign('custom_css', ['wiki.css']);

$tpl->display('web/edit.tpl');

Modified src/www/admin/web/index.php from [5baaa10463] to [030354055c].

6
7
8
9
10
11
12
13
14
15
16
17
18
19
20





21
22
23
24
25
26
27
use Garradin\Entities\Web\Page;

require_once __DIR__ . '/_inc.php';

$current_path = qg('p') ?: '';
$cat = null;

Web::sync($current_path);

if ($current_path) {
	$cat = Web::get($current_path);

	if (!$cat) {
		throw new UserException('Catégorie inconnue');
	}





}

$order_date = qg('order_title') === null;

$categories = Web::listCategories($cat ? $cat->path : '');
$pages = Web::listPages($cat ? $cat->path : '', $order_date);
$title = $cat ? sprintf('Gestion du site web : %s', $cat->title) : 'Gestion du site web';







<
<






>
>
>
>
>







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
use Garradin\Entities\Web\Page;

require_once __DIR__ . '/_inc.php';

$current_path = qg('p') ?: '';
$cat = null;



if ($current_path) {
	$cat = Web::get($current_path);

	if (!$cat) {
		throw new UserException('Catégorie inconnue');
	}
}
else {
	foreach (Web::sync() as $error) {
		$form->addError($error);
	}
}

$order_date = qg('order_title') === null;

$categories = Web::listCategories($cat ? $cat->path : '');
$pages = Web::listPages($cat ? $cat->path : '', $order_date);
$title = $cat ? sprintf('Gestion du site web : %s', $cat->title) : 'Gestion du site web';

Modified src/www/admin/web/page.php from [c7ad7078d0] to [da153abd6b].

8
9
10
11
12
13
14






15
16
17
18
19
20
21
require_once __DIR__ . '/_inc.php';

$page = Web::get(qg('p'));

if (!$page) {
	throw new UserException('Page inconnue');
}







$membres = new Membres;

$tpl->assign('breadcrumbs', $page->getBreadcrumbs());

$images = $page->getImageGallery(true);
$files = $page->getAttachmentsGallery(true);







>
>
>
>
>
>







8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
require_once __DIR__ . '/_inc.php';

$page = Web::get(qg('p'));

if (!$page) {
	throw new UserException('Page inconnue');
}

if (qg('toggle_type') !== null) {
	$page->toggleType();
	$page->save();
	Utils::redirect('!web/page.php?p=' . $page->path);
}

$membres = new Membres;

$tpl->assign('breadcrumbs', $page->getBreadcrumbs());

$images = $page->getImageGallery(true);
$files = $page->getAttachmentsGallery(true);

Modified src/www/skel-dist/content.css from [38f956b17f] to [dd0eed56a6].

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




















































17
18
19
20
21
22
23
..
25
26
27
28
29
30
31




















32
33
34
35
36
37
38
.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 {
    margin-bottom: .8em;
}

.web-content ul, .web-content ol, .web-content dd {
    margin-left: 2em;
}

.web-content ul {
    list-style-type: disc;
}

.web-content ol {
    list-style-type: decimal;
}





















































.web-content table {
    border-collapse: collapse;
}

.web-content table th, .web-content table td {
    border: 1px solid #999;
................................................................................
    text-align: center;
}

.web-content table th {
    background: #eee;
    font-weight: bold;
}





















.web-content aside.file {
    margin: 1em;
}

.web-content aside.file small {
    opacity: 0.7;

|
|













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







 







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







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
..
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
.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: .8em 0;
}

.web-content ul, .web-content ol, .web-content dd {
    margin-left: 2em;
}

.web-content ul {
    list-style-type: disc;
}

.web-content ol {
    list-style-type: decimal;
}

.web-content blockquote::before {
    content: "”";
    color: #999;
    display: block;
    position: absolute;
    font-style: italic;
    font-size: 3rem;
    line-height: 2rem;
    margin-left: -3rem;
}

.web-content blockquote {
    font-size: 1.1em;
    padding-left: 3rem;
    margin: 1rem 0;
}

.web-content a.footnote-ref, .web-content .footnotes dt a {
    color: blue;
}

.web-content a.footnote-ref::before, .web-content .footnotes dt a::before {
    content: "[";
}

.web-content a.footnote-ref::after, .web-content .footnotes dt a::after {
    content: "]";
}

.web-content dl.footnotes {
    display: grid;
    grid-template-columns: .1fr .9fr;
    border-top: 2px solid #999;
    margin-top: 1rem;
    padding-top: 1rem;
}

.web-content code {
    background: rgba(100, 100, 100, 0.2);
    padding: .2rem;
}

.web-content dl.footnotes dd {
    margin: 0;
    margin-bottom: 1rem;
}

.web-content dl.footnotes dd p {
    margin: 0;
    margin-bottom: 1rem;
}

.web-content table {
    border-collapse: collapse;
}

.web-content table th, .web-content table td {
    border: 1px solid #999;
................................................................................
    text-align: center;
}

.web-content table th {
    background: #eee;
    font-weight: bold;
}

.web-content .toc {
    margin: 1rem 0;
    border: 1px solid rgba(0, 0, 0, 0.2);
    background: rgba(0, 0, 0, 0.1);
    padding: .3rem;
    width: max-content;
}

.web-content .toc ol {
    list-style: none;
    counter-reset: item;
    margin: .5rem 0 .5rem .5rem;
}

.web-content .toc ol li:before {
    content: counters(item, ".") " ";
    counter-increment: item;
    color: #666;
}

.web-content aside.file {
    margin: 1em;
}

.web-content aside.file small {
    opacity: 0.7;