Files
donut-gestprojet/src/Slugger.php
T
Arnaud 3a32e93ab1 feat: initial API skeleton — routes, controllers, migrations, deploy docs
Implements the JSON API for Le Donut Infolab per INSTRUCTIONS.md.

Schema (migrations/001-002):
  - 8 tables: categories, members, projects, project_members, roadmap_steps,
    discussions, resources, activity_log
  - 6 categories seeded with colors / dimensions (social, ecologique, transversal)
  - FK contraintes + soft delete sur projects, CHECK sur progress_percent

Bare PHP framework (src/):
  - Front controller public/index.php + router :params + auth flag par route
  - PSR-4 autoload sans Composer (bootstrap.php)
  - Database PDO singleton + helpers fetchOne/All/insert/update/delete
  - Validator fluent -> 422 {error: validation_failed, fields: {...}}
  - Bearer token admin (hash_equals) sur POST/PATCH/DELETE
  - Slugger avec unicité (suffixe -2, -3...) via intl Transliterator
  - ActivityLogger best-effort sur activity_log
  - Erreurs uniformes : 400/401/404/405/409/422/500 + CORS configurable

10 contrôleurs (Health, Categories, Members, Projects, ProjectMembers,
Roadmap, Discussions, Resources, Activity, Stats). Transitions de statut
loggées (created_project, completed_step, joined, etc.).

Cible prod :
  - Dockerfile (php:8.3-apache + pdo_mysql + intl + opcache)
  - docker-compose.yml sur réseau donut-net, bind 127.0.0.1:8003
  - caddy.conf pour infolab.ledonut-marseille.org
  - migrations/apply.sh idempotent (table schema_migrations)
  - DEPLOY.md avec commandes SSH exactes

Tests :
  - tests/smoke.sh exerce 10 endpoints clés, body envoyé via stdin
    (--data-binary @-) pour gérer l UTF-8 sous Git Bash Windows.

Vérifié localement sous Laragon Apache + MySQL 8.4 : 10/10 OK + cas
négatifs (401, 404, 405, 409, 422) conformes au contrat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:55:28 +02:00

68 lines
2.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Infolab;
/**
* Génère et vérifie l'unicité des slugs. Translittération ASCII via
* l'extension intl (Transliterator), avec fallback bas niveau.
*/
final class Slugger
{
/**
* Slugifie un texte : minuscules, accents retirés, non-alphanum -> tirets.
* Toujours non vide (retourne "item" en dernier recours).
*/
public static function slugify(string $text): string
{
$text = trim($text);
if ($text === '') {
return 'item';
}
// Transliteration intl si dispo (cas de Marseille -> marseille, é -> e).
if (class_exists(\Transliterator::class)) {
$tr = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
if ($tr !== null) {
$translit = $tr->transliterate($text);
if (is_string($translit)) {
$text = $translit;
}
}
}
$text = mb_strtolower($text);
// Remplace toute séquence non-[a-z0-9] par un tiret, puis trim les tirets de bord.
$text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? '';
$text = trim($text, '-');
return $text === '' ? 'item' : $text;
}
/**
* Garantit l'unicité d'un slug dans une table : suffixe `-2`, `-3`, …
* Si `$excludeId` est fourni, ignore cette ligne (cas PATCH d'un slug existant).
*/
public static function unique(string $base, string $table, string $column = 'slug', ?int $excludeId = null): string
{
$candidate = $base;
$n = 2;
while (self::exists($candidate, $table, $column, $excludeId)) {
$candidate = $base . '-' . $n;
$n++;
}
return $candidate;
}
private static function exists(string $slug, string $table, string $column, ?int $excludeId): bool
{
// Noms de table/colonne câblés depuis le code, jamais depuis l'utilisateur.
$sql = sprintf('SELECT 1 FROM `%s` WHERE `%s` = :slug', $table, $column);
$params = [':slug' => $slug];
if ($excludeId !== null) {
$sql .= ' AND id <> :id';
$params[':id'] = $excludeId;
}
$sql .= ' LIMIT 1';
return Database::fetchValue($sql, $params) !== null;
}
}