Index: debian/makedeb.sh
==================================================================
--- debian/makedeb.sh
+++ debian/makedeb.sh
@@ -143,15 +143,13 @@
.
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.
+ 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
}
Index: doc/index.md
==================================================================
--- doc/index.md
+++ doc/index.md
@@ -159,11 +159,11 @@
## 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`
+* [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 :)
Index: src/Makefile
==================================================================
--- src/Makefile
+++ src/Makefile
@@ -9,10 +9,12 @@
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:
Index: src/VERSION
==================================================================
--- src/VERSION
+++ src/VERSION
@@ -1,1 +1,1 @@
-1.1.4
+1.1.8
Index: src/include/data/1.1.0_schema.sql
==================================================================
--- src/include/data/1.1.0_schema.sql
+++ src/include/data/1.1.0_schema.sql
@@ -20,11 +20,11 @@
perm_config INTEGER NOT NULL DEFAULT 0,
hidden INTEGER NOT NULL DEFAULT 0
);
-CREATE INDEX users_categories_hidden ON users_categories (hidden);
+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
@@ -108,14 +108,15 @@
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)
+ 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, 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);
--
@@ -322,15 +323,16 @@
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);
+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
(
ADDED src/include/data/1.1.7_migration.sql
Index: src/include/data/1.1.7_migration.sql
==================================================================
--- src/include/data/1.1.7_migration.sql
+++ src/include/data/1.1.7_migration.sql
@@ -0,0 +1,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
Index: src/include/data/1.1.8_migration.sql
==================================================================
--- src/include/data/1.1.8_migration.sql
+++ src/include/data/1.1.8_migration.sql
@@ -0,0 +1,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);
Index: src/include/data/schema.sql
==================================================================
--- src/include/data/schema.sql
+++ src/include/data/schema.sql
@@ -20,11 +20,11 @@
perm_config INTEGER NOT NULL DEFAULT 0,
hidden INTEGER NOT NULL DEFAULT 0
);
-CREATE INDEX users_categories_hidden ON users_categories (hidden);
+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
@@ -61,12 +61,12 @@
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
+ 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
(
@@ -108,14 +108,15 @@
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)
+ 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, 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);
--
@@ -166,10 +167,15 @@
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,
@@ -317,15 +323,16 @@
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);
+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
(
Index: src/include/lib/Garradin/API.php
==================================================================
--- src/include/lib/Garradin/API.php
+++ src/include/lib/Garradin/API.php
@@ -14,10 +14,15 @@
$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);
@@ -45,10 +50,57 @@
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);
@@ -57,19 +109,21 @@
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)
+ 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);
}
}
@@ -82,11 +136,11 @@
$api->method = $_SERVER['REQUEST_METHOD'] ?? null;
http_response_code(200);
try {
- $return = $api->dispatch($fn);
+ $return = $api->dispatch($fn, strtok(''));
if (null !== $return) {
echo json_encode($return);
}
}
Index: src/include/lib/Garradin/Accounting/Reports.php
==================================================================
--- src/include/lib/Garradin/Accounting/Reports.php
+++ src/include/lib/Garradin/Accounting/Reports.php
@@ -223,30 +223,36 @@
$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
+ 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
- HAVING sum != 0
+ %s
ORDER BY %s;',
- $reverse, Line::TABLE, Transaction::TABLE, Account::TABLE, $where, $order);
+ $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 => [],
Index: src/include/lib/Garradin/CSV.php
==================================================================
--- src/include/lib/Garradin/CSV.php
+++ src/include/lib/Garradin/CSV.php
@@ -154,18 +154,23 @@
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) {
- $v = $v->format('d/m/Y');
+ if ($v->format('His') == '000000') {
+ $v = $v->format('d/m/Y');
+ }
+ else {
+ $v = $v->format('d/m/Y H:i:s');
+ }
}
}
- $row = self::rowToArray($row, $row_map_callback);
-
if (!$header)
{
fputs($fp, self::row(array_keys($row)));
$header = true;
}
Index: src/include/lib/Garradin/DB.php
==================================================================
--- src/include/lib/Garradin/DB.php
+++ src/include/lib/Garradin/DB.php
@@ -13,10 +13,12 @@
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]);
@@ -47,13 +49,19 @@
// 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']);
+ 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) {
@@ -186,6 +194,35 @@
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);
+ }
}
Index: src/include/lib/Garradin/DynamicList.php
==================================================================
--- src/include/lib/Garradin/DynamicList.php
+++ src/include/lib/Garradin/DynamicList.php
@@ -105,10 +105,21 @@
}
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]');
}
Index: src/include/lib/Garradin/Entities/Files/File.php
==================================================================
--- src/include/lib/Garradin/Entities/Files/File.php
+++ src/include/lib/Garradin/Entities/Files/File.php
@@ -12,10 +12,11 @@
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};
@@ -70,17 +71,10 @@
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';
@@ -123,11 +117,11 @@
'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';
+ 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);
}
@@ -294,14 +288,11 @@
{
// 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') {
+ if ($this->mime === 'text/html' || $this->mime == 'text/xml') {
$content = strip_tags($content);
}
}
else {
$content = null;
@@ -698,27 +689,21 @@
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/') {
+ $editor_type = $this->renderFormat();
+
+ if ($editor_type == 'text') {
return sprintf('
%s
', htmlspecialchars($this->fetch()));
}
-
- throw new \LogicException('Cannot render file of this type');
+ 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
@@ -842,25 +827,10 @@
}
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);
}
@@ -904,18 +874,37 @@
$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;
- }
+ 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;
}
}
Index: src/include/lib/Garradin/Entities/Services/Reminder.php
==================================================================
--- src/include/lib/Garradin/Entities/Services/Reminder.php
+++ src/include/lib/Garradin/Entities/Services/Reminder.php
@@ -78,12 +78,13 @@
'email' => [
'label' => 'Adresse e-mail',
'select' => 'm.email',
],
'date' => [
- 'label' => 'Date',
- 'select' => 'srs.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';
Index: src/include/lib/Garradin/Entities/Web/Page.php
==================================================================
--- src/include/lib/Garradin/Entities/Web/Page.php
+++ src/include/lib/Garradin/Entities/Web/Page.php
@@ -6,11 +6,11 @@
use Garradin\Entity;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Entities\Files\File;
use Garradin\Files\Files;
-use Garradin\Web\Render\Skriv;
+use Garradin\Web\Render\Render;
use KD2\DB\EntityManager as EM;
use const Garradin\WWW_URL;
@@ -51,10 +51,11 @@
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';
@@ -70,10 +71,12 @@
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
@@ -137,23 +140,17 @@
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);
+
+ return Render::render($this->format, $this->file(), $this->content, $options);
}
public function preview(string $content): string
{
- return Skriv::render($this->file(), $content, ['prefix' => '#']);
+ 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;
@@ -201,17 +198,30 @@
$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
@@ -231,12 +241,12 @@
$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);
+ $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) {
@@ -439,18 +449,37 @@
throw new \LogicException('Invalid page content: ' . $file->parent);
}
$this->set('modified', $file->modified);
- foreach (Files::list($file->parent) as $subfile) {
+ $this->set('type', $this->checkRealType());
+ }
+
+ public function checkRealType(): int
+ {
+ foreach (Files::list(Utils::dirname($this->filepath())) as $subfile) {
if ($subfile->type == File::TYPE_DIRECTORY) {
- $this->set('type', self::TYPE_CATEGORY);
- return;
+ return self::TYPE_CATEGORY;
}
}
- $this->set('type', self::TYPE_PAGE); // Default
+ 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;
Index: src/include/lib/Garradin/Entity.php
==================================================================
--- src/include/lib/Garradin/Entity.php
+++ src/include/lib/Garradin/Entity.php
@@ -55,11 +55,11 @@
}
return parent::filterUserValue($type, $value, $key);
}
- protected function assert(?bool $test, string $message = null): void
+ protected function assert(?bool $test, string $message = null, int $code = 0): void
{
if ($test) {
return;
}
@@ -69,11 +69,11 @@
$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);
+ throw new ValidationException($message, $code);
}
}
// Add plugin signals to save/delete
public function save(): bool
Index: src/include/lib/Garradin/Files/Files.php
==================================================================
--- src/include/lib/Garradin/Files/Files.php
+++ src/include/lib/Garradin/Files/Files.php
@@ -9,10 +9,11 @@
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
{
@@ -55,10 +56,53 @@
}
// 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);
@@ -289,12 +333,12 @@
$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) {
+ foreach (Files::list($parent) as $file) {
$db->insert('tmp_files', $file->asArray(true));
}
$db->commit();
}
}
Index: src/include/lib/Garradin/Files/Storage/FileSystem.php
==================================================================
--- src/include/lib/Garradin/Files/Storage/FileSystem.php
+++ src/include/lib/Garradin/Files/Storage/FileSystem.php
@@ -209,10 +209,48 @@
$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;
Index: src/include/lib/Garradin/Files/Storage/SQLite.php
==================================================================
--- src/include/lib/Garradin/Files/Storage/SQLite.php
+++ src/include/lib/Garradin/Files/Storage/SQLite.php
@@ -122,10 +122,22 @@
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);
}
Index: src/include/lib/Garradin/Files/Storage/StorageInterface.php
==================================================================
--- src/include/lib/Garradin/Files/Storage/StorageInterface.php
+++ src/include/lib/Garradin/Files/Storage/StorageInterface.php
@@ -66,10 +66,16 @@
/**
* 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;
Index: src/include/lib/Garradin/Files/Users.php
==================================================================
--- src/include/lib/Garradin/Files/Users.php
+++ src/include/lib/Garradin/Files/Users.php
@@ -17,10 +17,14 @@
'select' => '',
'label' => '',
],
'path' => [
],
+ 'id' => [
+ 'label' => null,
+ 'select' => 'm.id',
+ ],
];
static public function list()
{
Files::syncVirtualTable(File::CONTEXT_USER);
Index: src/include/lib/Garradin/Membres/Import.php
==================================================================
--- src/include/lib/Garradin/Membres/Import.php
+++ src/include/lib/Garradin/Membres/Import.php
@@ -10,10 +10,12 @@
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)
@@ -152,11 +154,11 @@
foreach ($values as $v) {
$v = trim($v);
$found = array_search($v, $champs_multiples[$name]->options);
- if ($found) {
+ if ($found !== false) {
$data[$name] |= 0x01 << $found;
}
}
}
}
@@ -240,26 +242,34 @@
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);
-
+ 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;
}
}
Index: src/include/lib/Garradin/Plugin.php
==================================================================
--- src/include/lib/Garradin/Plugin.php
+++ src/include/lib/Garradin/Plugin.php
@@ -488,11 +488,20 @@
}
$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';
+ $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);
Index: src/include/lib/Garradin/Sauvegarde.php
==================================================================
--- src/include/lib/Garradin/Sauvegarde.php
+++ src/include/lib/Garradin/Sauvegarde.php
@@ -415,10 +415,12 @@
{
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)
@@ -428,11 +430,11 @@
'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.');
+ throw new UserException('Le fichier fourni est corrompu. Erreur SQLite : ' . $check);
}
if ($check_foreign_keys)
{
$check = $db->querySingle('PRAGMA foreign_key_check;');
Index: src/include/lib/Garradin/Services/Reminders.php
==================================================================
--- src/include/lib/Garradin/Services/Reminders.php
+++ src/include/lib/Garradin/Services/Reminders.php
@@ -2,10 +2,11 @@
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;
@@ -25,23 +26,41 @@
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);
+ $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 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
+ 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)
{
@@ -84,12 +103,12 @@
*/
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),
+ '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);
@@ -101,10 +120,11 @@
$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;
@@ -123,23 +143,24 @@
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
+ 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 services_reminders_sent srs ON srs.id_reminder = sr.id AND srs.id_user = su.id_user
+ 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
- GROUP BY su.id_user, s.id
+ 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)
Index: src/include/lib/Garradin/Template.php
==================================================================
--- src/include/lib/Garradin/Template.php
+++ src/include/lib/Garradin/Template.php
@@ -96,11 +96,12 @@
$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);
+ $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);
}
Index: src/include/lib/Garradin/Upgrade.php
==================================================================
--- src/include/lib/Garradin/Upgrade.php
+++ src/include/lib/Garradin/Upgrade.php
@@ -278,10 +278,52 @@
$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
Index: src/include/lib/Garradin/UserTemplate/Sections.php
==================================================================
--- src/include/lib/Garradin/UserTemplate/Sections.php
+++ src/include/lib/Garradin/UserTemplate/Sections.php
@@ -55,11 +55,11 @@
$result = $db->preparedQuery($sql);
while ($row = $result->fetchArray(\SQLITE3_ASSOC))
{
- $row['url'] = WWW_URL . $row['path'];
+ $row['url'] = WWW_URL . Utils::basename($row['path']);
yield $row;
}
}
static public function categories(array $params, UserTemplate $tpl, int $line): \Generator
Index: src/include/lib/Garradin/Utils.php
==================================================================
--- src/include/lib/Garradin/Utils.php
+++ src/include/lib/Garradin/Utils.php
@@ -13,10 +13,11 @@
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',
@@ -900,26 +901,42 @@
// with NUMERIC_COLLATION: 1, 2, 10, 11, 101
// without: 1, 10, 101, 11, 2
}
if (isset(self::$collator)) {
- return self::$collator->compare($a, $b);
+ return (int) 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));
- }
+ $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
Index: src/include/lib/Garradin/Web/Render/AbstractRender.php
==================================================================
--- src/include/lib/Garradin/Web/Render/AbstractRender.php
+++ src/include/lib/Garradin/Web/Render/AbstractRender.php
@@ -0,0 +1,75 @@
+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
Index: src/include/lib/Garradin/Web/Render/Parsedown.php
==================================================================
--- src/include/lib/Garradin/Web/Render/Parsedown.php
+++ src/include/lib/Garradin/Web/Render/Parsedown.php
@@ -0,0 +1,229 @@
+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' => '',
+ 'allowRawHtmlInSafeMode' => true,
+ ],
+ ];
+ }
+
+ public function buildTOC(): string
+ {
+ if (!count($this->toc)) {
+ return '';
+ }
+
+ $out = '
Si coché, ce champ ne sera pas visible par les membres dans leur espace personnel.
+
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.
Si coché, les membres pourront changer cette information depuis leur espace personnel.
Il n'est pas possible de créer de répertoire ici.
{if $context == File::CONTEXT_USER}
Utiliser le formulaire de création pour enregistrer un membre.
{else}
@@ -127,11 +127,11 @@
{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}
{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"}