From 6833889cf240a7699af0cac9c3c00cde04cbdc27 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 31 Aug 2024 18:03:50 +0200 Subject: [PATCH] commente un peu plus le projet --- README.md | 66 ++++++++++++-- assets/app.js | 10 ++- assets/controllers/clipboard_controller.js | 7 ++ assets/controllers/josm_controller.js | 38 +++++++- assets/controllers/map_controller.js | 56 +++++++++--- assets/styles/app.css | 1 + src/Controller/HomeController.php | 10 ++- src/Controller/MapController.php | 3 + src/Controller/ProjectController.php | 43 ++++----- src/Controller/TaskController.php | 112 +++++++++--------------- src/DataFixtures/AppFixtures.php | 3 +- src/Entity/Comment.php | 3 + src/Entity/Project.php | 2 + src/Entity/Tag.php | 2 + src/Entity/Task.php | 2 + src/Entity/User.php | 2 + src/EventSubscriber/TaskLifecycleSubscriber.php | 14 +++ src/Form/TaskType.php | 2 +- src/Service/GeoJsonManager.php | 2 + src/Service/OpenStreetMapClient.php | 1 + src/Service/OsmoseClient.php | 3 + src/Service/OverpassClient.php | 1 + src/Service/SourceGenerator.php | 1 + src/Service/TaskLifecycleManager.php | 1 + templates/_header.html.twig | 2 +- templates/macro.html.twig | 22 +++++ 26 files changed, 280 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index b5b5568..8b767f6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,13 @@ -L'idée serait de faire une application Symfony pour gérer simplement des tâches de cartographie. +# Gestionnaire de tâches simple + +Voici un outil pour faciliter le travail collaboratif autour d’un projet. Le +projet regroupe des tâches que les contributeurs OSM peuvent s’approprier et +traiter dans JOSM. + +# Idée de départ + +L'idée serait de faire une application Symfony pour gérer simplement des tâches +de cartographie. * authentification OAuth2 sur OSM pour ne pas avoir à créer de compte * télécommande JOSM @@ -6,19 +15,60 @@ L'idée serait de faire une application Symfony pour gérer simplement des tâch * carte maplibre ou leaflet synchronisée avec la liste des tâches * possibilité de modifier les tâches en masse (actions groupées) * stockage des données dans sqlite -* presets dans le projet pour envoyer à JOSM (genre hashtags du message de commit, etc) +* presets dans le projet pour envoyer à JOSM (genre hashtags du message de + commit, etc) On créé un projet (titre, description), on peut manipuler les tâches d'un -projet (ajouter par import geojson, supprimer, diviser en n×n ou en surface (on en déduit n)) et pour chacune +projet (ajouter par import geojson, supprimer, diviser en n×n ou en surface (on +en déduit n)) et pour chacune d'elle on trouve un statut historicisé (à faire, en cours, fait) pour chaque action (mapper, vérifier). On peut imagine que certains status lockent la tâche qui se délocke au bout d'un certain temps. -Peut-être préférer un workflow : à mapper → mappage en cours → mappage terminé à vérifier → vérification en cours → vérifié -Où les étapes *en cours* sont lockantes et reviennent au statut précédent au bout d'une journée +Peut-être préférer un workflow : à mapper → mappage en cours → mappage terminé +à vérifier → vérification en cours → vérifié +Où les étapes *en cours* sont lockantes et reviennent au statut précédent au +bout d'une journée + +Un système de commentaires arborescent sur les tâches serait pas du luxe (lien +avec le statut via la date). + +On peut faire des statistiques par projet sur les statuts des tâches. Et +rappeller les commentaires par ordre antéchronologique globalement sur le +projet. + +En tous cas l'idéal serait de pouvoir faire tout ça via une api et de fournir +un client web en js moderne. Un client en ligne de commande serait pas du luxe +non plus. + +## Mise en place technique + +Il s’agit d’un petit projet Symfony (7.1.3) donc essentiellement en PHP +(développé avec la version 8.2.23) avec un peu de Javascript (utilisation du +framework Stimulus suggéré par Symfony) et de CSS (utilisation du framework +Bootstrap). Les données sont stockées dans une base SQLite localement. + +Les dépendances PHP sont gérées assez classiquement par Composer. On peut donc +les récuperer avec un simple `composer install` dans la racine de +l’application. + +On peut le faire tourner en local pour tester/développer grâce à l’outil en +ligne de commande [`symfony`](https://symfony.com/download) et notamment en +démarrant un serveur local : `symfony serve -d`. + +L’application peut être servie par un serveur web (nginx, Apache, etc) comme +une application Symfony classique (la racine du serveur web étant dans +`/public`) pour peu qu’il interprète le PHP. Il n’y a pas de référence à des +noms de domaines donc pas de soucis pour les adresses web absolues. + +La configuration de l’application se fait dans un fichier `.env.local` +(s’inspirer du `.env` fourni) dans lequel il faut essentiellement renseigner +les variables : -Un système de commentaires arborescent sur les tâches serait pas du luxe (lien avec le statut via la date). +* `APP_TIMEZONE` a priori `Europe/Paris` +* `OSM_CLIENT_ID` et `OSM_CLIENT_SECRET` à générer dans les options de son + compte OSM (onglet « application OAuth2 » avec comme URI de redirections + l’adresse web de son instance suffixée du chemin `/osm/callback` et comme + autorisation, uniquement « Lire les préférences de l’utilisateur ») -On peut faire des statistiques par projet sur les statuts des tâches. Et rappeller les commentaires par ordre antéchronologique globalement sur le projet. -En tous cas l'idéal serait de pouvoir faire tout ça via une api et de fournir un client web en js moderne. Un client en ligne de commande serait pas du luxe non plus. diff --git a/assets/app.js b/assets/app.js index 9576c2e..b070da6 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,13 +1,15 @@ -import './bootstrap.js'; +import './bootstrap.js'; // Stimulus -import './vendor/bootstrap/dist/css/bootstrap.min.css'; -import './vendor/leaflet/dist/leaflet.min.css'; -import './styles/app.css'; +import './vendor/bootstrap/dist/css/bootstrap.min.css'; // Bootstrap +import './vendor/leaflet/dist/leaflet.min.css'; // Leaflet +import './styles/app.css'; // Nos personnalisations import { Tooltip } from './vendor/bootstrap/bootstrap.index.js'; const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) +// Implémente une sécurité basique pour confirmer le clic sur tous les boutons +// qui emènent sur une adresse web susceptible de supprimer quelque-chose… document.addEventListener('click', function (event) { if (event.target.matches('a[href*="remove"]')) { if (!confirm(event.target.innerText + ' ?')) { diff --git a/assets/controllers/clipboard_controller.js b/assets/controllers/clipboard_controller.js index 18bc059..73d89d8 100644 --- a/assets/controllers/clipboard_controller.js +++ b/assets/controllers/clipboard_controller.js @@ -1,3 +1,10 @@ +/** +Simple contrôleur pour copier du texte dans le presse papier en cliquant +simplement sur un bouton + +Cf la macro twig `clipboard` +**/ + import { Controller } from "@hotwired/stimulus" export default class extends Controller { diff --git a/assets/controllers/josm_controller.js b/assets/controllers/josm_controller.js index c3c8d03..6ffc1e1 100644 --- a/assets/controllers/josm_controller.js +++ b/assets/controllers/josm_controller.js @@ -1,3 +1,40 @@ +/** +Implémente la télécommande JOSM + +Le HTML géré pourrait ressembler à : + +```html + +``` + +Où la valeur `commands` prend la forme d’un objet JSON dont chacune des entrées +représente une commande à enchaîner successivement et où la clef est le chemin +de la commande et la valeur un objet dont chacune des entrées représente les +paramètres de la commande. + +Par exemple : + +```json +{ + "version": {}, + "imagery": { + "id": "osmfr", + } +} +``` + +Avec cette valeur de `commands`, les appels successifs aux endpoints `/version` +puis `imagery?id=osmfr` seront effectués. + +Cf + +TODO Gérer le cas où il n’y a pas de JOSM en face pourrait être plus user-friendly. +**/ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { @@ -6,7 +43,6 @@ export default class extends Controller { } remoteControl() { - // cf const baseurl = 'http://localhost:8111'; const _this = this, commands = JSON.parse(this.commandsValue); for (var command in commands) { diff --git a/assets/controllers/map_controller.js b/assets/controllers/map_controller.js index e50c9eb..868e818 100644 --- a/assets/controllers/map_controller.js +++ b/assets/controllers/map_controller.js @@ -1,3 +1,12 @@ +/** + +Contrôleur qui gère la carte (charge son contenu et contrôle l’interactivité) + +Voir la macro Twig `map` qui produit le HTML géré par ce contrôleur. + +Cf + +**/ import { Controller } from '@hotwired/stimulus'; import 'leaflet'; @@ -10,13 +19,7 @@ export default class extends Controller { } connect() { - const simpleIcon = L.icon({ - iconUrl: this.iconValue, - iconSize: [16, 16], - iconAnchor: [8, 16], - popupAnchor: [0, 0], - }); - + // Constitue une collection d’icones aux couleurs Bootstrap const iconHtml = ` @@ -30,35 +33,50 @@ export default class extends Controller { }; var geojsons, _this = this, map = L.map(this.element); + + // Commence par déclarer le fond de carte classique OSM par défaut L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(map); + // Crée un ensemble de couches pour mieux les manipuler + // individuellement var layer = L.featureGroup(); + // Crée la couche dédiée à Overpass var overpassLayer = L.featureGroup(); if (this.overpassResultValue !== '') { geojsons = JSON.parse(this.overpassResultValue); if (geojsons.elements.length > 0) { + // Ajoute chaque forme geojsons.elements.forEach(function (element) { element.members.forEach(function (member) { + // TODO On est parti du principe que les features du + // geojson sont toutes des polylines ce qui est un peu + // réducteur, à terme il faudrait distinguer les nœuds, + // des chemins, des polygones, etc. const polygon = L.polyline(member.geometry.map(function (coord) { return L.latLng(coord.lat, coord.lon)}), { - color: '#0dcaf0', - weight: 6, + color: '#0dcaf0', // bleu info bootstrap + weight: 6, // l’idée c’est que ce soit plus gros (par défaut c’est 3) que le tracé des données des tâches parce que ce sera en dessous opacity: 0.8, }).addTo(overpassLayer).bindPopup(L.popup({ - overpassElement: element, - }).setContent('…')); + overpassElement: element, // on transmet les données du geojson à la popup par là + }).setContent('…')); // le contenu définitif de la popup sera chargé plus tard en ajax }); }); + // Intervient lors de l’ouverture de la popup associée à la forme overpassLayer.on('popupopen', function (event) { + // Récupère le geojson de la forme var element = event.popup.options.overpassElement; + // Enlève ce qui nous est inutile (les points, etc) delete element.members; + // Ajoute ce qui peut-être utile (concernant la carte0 element['map'] = { 'center': map.getCenter(), 'zoom': map.getZoom(), }; + // Effectue l’appel ajax pour récupérer le contenu de la popup fetch(_this.popupUrlValue + '?' + (new URLSearchParams({ 'element': JSON.stringify(element), }))) @@ -73,15 +91,25 @@ export default class extends Controller { } } + // Créé la couche dédiée aux tâches var taskLayer = L.featureGroup(); geojsons = JSON.parse(this.geojsonValue); if (geojsons.length > 0) { geojsons.forEach(function (geojson) { + // TODO Ici aussi on ne distingue pas les géométries des + // features mais ça vaudrait peut-être le coup de s’adapter un + // peu à la situation en cas de nœud, chemin ou zone… + // Par facilité on conserve les propriétés de la première feature pour plus tard const feature0 = geojson.features[0].properties; + // Dessine la forme de la tâche avec la proprieté `name` qui + // s’ffiche au survol et cliquable vers l’adresse web dans la + // propriété `url` const polygon = L.geoJSON(geojson, { style: function (feature) { + // Par défaut c’est un bleu par défaut de leaflet mais + // sinon on utilise les couleurs de Bootstrap var color = 'blue'; switch (feature.properties.color) { case 'danger' : color = '#dc3545'; break @@ -94,6 +122,8 @@ export default class extends Controller { window.location.href = event.layer.feature.properties.url; }); + // Ajoute un marqueur au centroïde de la forme (car les + // marqueurs gardent la même taille quelque-soit le zoom L.marker(polygon.getBounds().getCenter(), { icon: icons[feature0.color], title: feature0.name, @@ -108,6 +138,8 @@ export default class extends Controller { layer.addTo(map); + // Si la couche Overpass n’est pas vide, ajoute le sélecteur de couches + // sur la carte if (this.overpassResultValue !== '') { L.control.layers({}, { 'Overpass': overpassLayer, @@ -115,6 +147,8 @@ export default class extends Controller { }).addTo(map); } + // Zoome la carte pour que les données des tâches soient toutes + // visibles map.fitBounds(taskLayer.getBounds()); } } diff --git a/assets/styles/app.css b/assets/styles/app.css index 16ea686..0b1c029 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1,3 +1,4 @@ +/* Force la hauteur à un minimum de 50% de la hauteur du viewport */ .min-vh-50 { min-height: 50vh; } diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index f71e7a7..87b570a 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -12,6 +12,7 @@ use Symfony\Component\Routing\Attribute\Route; class HomeController extends AbstractController { + // Page d’accueil du site #[Route('/', name: 'app_home')] public function index(): Response { @@ -19,10 +20,12 @@ class HomeController extends AbstractController ]); } + // Page d’erreur public function error(Request $request, $exception, $logger = null): Response { $this->addFlash('danger', $exception->getMessage()); // . ' ' . $exception->getFile() . ':' . $exception->getLine()); + // Si le referer est renseigné on renvoie vers celui-ci où le message d’erreur sera affiché if ($request->headers->has('Referer')) { return $this->redirect($request->headers->get('Referer')); } @@ -31,16 +34,18 @@ class HomeController extends AbstractController ]); } + // L’oauth OSM commence ici #[Route('/osm/request', name: 'app_osm_request')] public function osmRequest(ClientRegistry $clientRegistry): Response { return $clientRegistry - ->getClient('openstreetmap') + ->getClient('openstreetmap') // cf `config/packages/knpu_oauth2_client.yaml` ->redirect([ - 'read_prefs', + 'read_prefs', // TODO est-ce qu’on a même besoin de ça ? ]); } + // L’oauth OSM aboutit ici #[Route('/osm/callback', name: 'app_osm_callback')] public function osmCallback(Request $request, ClientRegistry $clientRegistry): Response { @@ -58,6 +63,7 @@ class HomeController extends AbstractController return $this->redirectToRoute('app_home'); } + // La déconnexion passe par là mais c’est pas lié à l’oauth #[Route('/osm/logout', name: 'app_osm_logout')] public function osmLogout(Security $security): Response { diff --git a/src/Controller/MapController.php b/src/Controller/MapController.php index 2881ef7..346fb45 100644 --- a/src/Controller/MapController.php +++ b/src/Controller/MapController.php @@ -10,9 +10,12 @@ use Symfony\Component\Routing\Attribute\Route; #[Route('/map')] class MapController extends AbstractController { + // Produit le contenu de la popup (quand on clique sur un tracé issu d’overpass) #[Route('/popup', name: 'app_map_popup')] public function popup(Request $request): Response { + // Grosso modo, on récuère les données de la feature correspondante + // Cf `assets/controllers/map_controller.js` $element = json_decode($request->query->get('element'), true); $josmCommands = [ diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index d1bf253..38da4a4 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -19,6 +19,7 @@ use Symfony\Component\Routing\Attribute\Route; class ProjectController extends AbstractController { + // Page des projets, où l'on voit tous les projets #[Route('/', name: 'app_project')] public function index(EntityManagerInterface $entityManager): Response { @@ -29,6 +30,7 @@ class ProjectController extends AbstractController ]); } + // Formulaire de création d’un projet #[Route('/create', name: 'app_project_create')] public function create(Request $request, EntityManagerInterface $entityManager): Response { @@ -47,17 +49,11 @@ class ProjectController extends AbstractController $entityManager->persist($project); $entityManager->flush(); - $this->addFlash( - 'success', - 'Projet créé !' - ); + $this->addFlash('success', 'Projet créé !'); return $this->redirectToRoute('app_project'); } catch (\Exception $exception) { - $this->addFlash( - 'danger', - 'Impossible de créer le projet !' - ); + $this->addFlash('danger', 'Impossible de créer le projet !'); } } @@ -66,6 +62,7 @@ class ProjectController extends AbstractController ]); } + // Page d’un prtojet donné (où l’on voit ses tâches) #[Route('/{slug}', name: 'app_project_show')] public function show(EntityManagerInterface $entityManager, $slug): Response { @@ -77,14 +74,14 @@ class ProjectController extends AbstractController } $tasks = $entityManager->getRepository(Task::class)->findByProjectPaginated($project); - $randomTask = $entityManager->getRepository(Task::class)->findRandomByProject($project, Task::STATUS_TODO); + $randomTask = $entityManager->getRepository(Task::class)->findRandomByProject($project, Task::STATUS_TODO); // Trouve la tâche à faire que l’on piochera $csvForm = $this->createForm(CsvType::class, null, [ 'action' => $this->generateUrl('app_project_import', ['slug' => $slug]) ]); $csvForm->add('submit', SubmitType::class, ['label' => 'Importer']); - $comments = $entityManager->getRepository(Comment::class)->findLatestByProject($project); + $comments = $entityManager->getRepository(Comment::class)->findLatestByProject($project); // Commentaires agrégés de toutes les tâches du projet return $this->render('project/show.html.twig', [ 'project' => $project, @@ -95,6 +92,7 @@ class ProjectController extends AbstractController ]); } + // Import de tâches dans un projet #[Route('/{slug}/import', name: 'app_project_import')] public function import(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -112,10 +110,10 @@ class ProjectController extends AbstractController if ($csvForm->isSubmitted() and $csvForm->isValid()) { $csvFile = $csvForm->get('csv')->getData(); + // TODO un peu plus de gestion d’erreur ici serait pas du luxe… $csv = fopen($csvFile->getPathName(), 'r'); $col = array_flip(fgetcsv($csv)); while ($row = fgetcsv($csv)) { - $task = new Task(); $task->setCreatedBy($this->getUser()); $task->setProject($project); @@ -140,6 +138,7 @@ class ProjectController extends AbstractController return $this->redirectToRoute('app_project_show', ['slug' => $slug]); } + // Page de modification du projet #[Route('/{slug}/update', name: 'app_project_update')] public function update(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -147,10 +146,7 @@ class ProjectController extends AbstractController $project = $repository->findOneBySlug($slug); if (!$project) { - $this->addFlash( - 'warning', - 'Projet non trouvé !' - ); + $this->addFlash('warning', 'Projet non trouvé !'); return $this->redirectToRoute('app_project'); } @@ -168,17 +164,11 @@ class ProjectController extends AbstractController $entityManager->persist($project); $entityManager->flush(); - $this->addFlash( - 'success', - 'Projet modifié !' - ); + $this->addFlash('success', 'Projet modifié !'); return $this->redirectToRoute('app_project_show', ['slug' => $slug]); } catch (\Exception $exception) { - $this->addFlash( - 'danger', - 'Impossible de mopdifier le projet !' - ); + $this->addFlash('danger', 'Impossible de mopdifier le projet !'); } } @@ -188,6 +178,7 @@ class ProjectController extends AbstractController ]); } + // La suppression d’un projet passe par là #[Route('/{slug}/remove', name: 'app_project_remove')] public function remove(EntityManagerInterface $entityManager, $slug): Response { @@ -206,15 +197,13 @@ class ProjectController extends AbstractController return $this->redirectToRoute('app_project'); } catch (\Exception $exception) { - $this->addFlash( - 'danger', - 'Impossible de supprimer le projet !' - ); + $this->addFlash('danger', 'Impossible de supprimer le projet !'); } return $this->redirectToRoute('app_project'); } + // Effectue la requête Overpass liée au projet #[Route('/{slug}/overpass', name: 'app_project_overpass')] public function overpass(OverpassClient $overpassClient, EntityManagerInterface $entityManager, $slug): Response { diff --git a/src/Controller/TaskController.php b/src/Controller/TaskController.php index 38f3f5e..953a492 100644 --- a/src/Controller/TaskController.php +++ b/src/Controller/TaskController.php @@ -24,6 +24,7 @@ use Symfony\Component\Workflow\WorkflowInterface; #[Route('/task')] class TaskController extends AbstractController { + // Page de créatiom d’une tâche #[Route('/create', name: 'app_task_create')] public function create(Request $request, EntityManagerInterface $entityManager): Response { @@ -61,10 +62,7 @@ class TaskController extends AbstractController $entityManager->persist($task); $entityManager->flush(); - $this->addFlash( - 'success', - 'Tâche créée !' - ); + $this->addFlash('success', 'Tâche créée !'); return $this->redirectToRoute('app_project_show', ['slug' => $slug]); } catch (\Exception $exception) { @@ -79,6 +77,7 @@ class TaskController extends AbstractController ]); } + // Page spécifique à une tâche, où l’on trouve tout ce qui la concerne #[Route('/{slug}', name: 'app_task_show')] public function show(Request $request, EntityManagerInterface $entityManager, GeoJsonManager $geoJsonManager, $slug): Response { @@ -86,10 +85,7 @@ class TaskController extends AbstractController $task = $repository->findOneBySlug($slug); if (!$task) { - $this->addFlash( - 'warning', - 'Tâche non trouvée !' - ); + $this->addFlash('warning', 'Tâche non trouvée !'); if (!$request->headers->has('Referer')) { throw $this->createNotFoundException('Task not found'); @@ -108,10 +104,15 @@ class TaskController extends AbstractController 'label' => 'Commenter', ]); + // Programmation de la télécommande JOSM + $josmCommands = [ + // Charge l’imagerie (fond OSM par défaut) 'imagery' => [ 'id' => $project->hasImagery() ? $project->getImagery() : 'osmfr', ], + // Charge le XML OSM du projet danms un calque de données + // TODO C’est susceptible de planter s’il n’y a pas d’OSM dans le projet mais est-ce possible ? 'import' => [ 'new_layer' => true, 'layer_name' => $task->getName(), @@ -127,6 +128,7 @@ class TaskController extends AbstractController $geom = \geoPHP::load($task->getGeojson(), 'json'); $bbox = $geom->getBBox(); + // Zoome sur les données et préremplit le changeset $josmCommands['zoom'] = [ 'bottom' => $bbox['miny'], 'top' => $bbox['maxy'], @@ -147,6 +149,7 @@ class TaskController extends AbstractController ]); } + // Ajoute un commentaire à la tâche #[Route('/{slug}/comment', name: 'app_task_comment')] public function comment(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -154,10 +157,7 @@ class TaskController extends AbstractController $task = $repository->findOneBySlug($slug); if (!$task) { - $this->addFlash( - 'warning', - 'Tâche non trouvée !' - ); + $this->addFlash('warning', 'Tâche non trouvée !'); return $this->redirect($request->headers->get('Referer')); } @@ -179,16 +179,14 @@ class TaskController extends AbstractController $entityManager->flush(); } catch (\Exception $exception) { - $this->addFlash( - 'danger', - 'Impossible de commenter ! ' . $exception->getMessage() - ); + $this->addFlash('danger', 'Impossible de commenter ! ' . $exception->getMessage()); } } return $this->redirectToRoute('app_task_show', ['slug' => $slug]); } + // Modifie les informations d’une tâche #[Route('/{slug}/update', name: 'app_task_update')] public function update(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -196,10 +194,7 @@ class TaskController extends AbstractController $task = $repository->findOneBySlug($slug); if (!$task) { - $this->addFlash( - 'warning', - 'Tâche non trouvée !' - ); + $this->addFlash('warning', 'Tâche non trouvée !'); return $this->redirect($request->headers->get('Referer')); } @@ -219,10 +214,7 @@ class TaskController extends AbstractController return $this->redirectToRoute('app_task_show', ['slug' => $slug]); } catch (\Exception $exception) { - $this->addFlash( - 'danger', - 'Impossible de modifier la tâche !' - ); + $this->addFlash('danger', 'Impossible de modifier la tâche !'); } } @@ -233,6 +225,7 @@ class TaskController extends AbstractController ]); } + // Supprimer une tâche #[Route('/{slug}/remove', name: 'app_task_remove')] public function remove(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -240,10 +233,7 @@ class TaskController extends AbstractController $task = $repository->findOneBySlug($slug); if (!$task) { - $this->addFlash( - 'warning', - 'Tâche non trouvée !' - ); + $this->addFlash('warning', 'Tâche non trouvée !'); return $this->redirect($request->headers->get('Referer')); } @@ -256,25 +246,20 @@ class TaskController extends AbstractController return $this->redirectToRoute('app_project_show', ['slug' => $project->getSlug()]); } catch (\Exception $exception) { - $this->addFlash( - 'danger', - 'Impossible de supprimer la tâche !' - ); + $this->addFlash('danger', 'Impossible de supprimer la tâche !'); } return $this->redirectToRoute('app_project_show', ['slug' => $slug]); } + // Passe une tâche d’un état à un autre private function transition(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug, $transitionName): Response { $repository = $entityManager->getRepository(Task::class); $task = $repository->findOneBySlug($slug); if (!$task) { - $this->addFlash( - 'warning', - 'Tâche non trouvée !' - ); + $this->addFlash('warning', 'Tâche non trouvée !'); return $this->redirectToRoute('app_project'); } @@ -285,39 +270,41 @@ class TaskController extends AbstractController $entityManager->flush(); } catch (Exception $exception) { - $this->addFlash( - 'warning', - 'Impossible de modifier la tâche !' - ); + $this->addFlash('warning', 'Impossible de modifier la tâche !'); } return $this->redirectToRoute('app_task_show', ['slug' => $slug]); } + // Commence une tâche #[Route('/{slug}/start', name: 'app_task_start')] public function start(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response { return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_START); } + // Termine une tâche #[Route('/{slug}/finish', name: 'app_task_finish')] public function finish(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response { return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_FINISH); } + // Abandonne une tâche #[Route('/{slug}/cancel', name: 'app_task_cancel')] public function cancel(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response { return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_CANCEL); } + // Recommence une tâche #[Route('/{slug}/reset', name: 'app_task_reset')] public function reset(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response { return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_RESET); } + // Renvoie le geojson associé à une tâche #[Route('/download/{slug}.geojson', name: 'app_task_geojson')] public function geojson(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -325,11 +312,8 @@ class TaskController extends AbstractController $task = $repository->findOneBySlug($slug); if (!$task) { - $this->addFlash( - 'warning', - 'Tâche non trouvée !' - ); - // TODO faire pareil ailleurs où il y a des referer + $this->addFlash('warning', 'Tâche non trouvée !'); + if (!$request->headers->has('Referer')) { throw $this->createNotFoundException('Task not found'); } @@ -350,6 +334,7 @@ class TaskController extends AbstractController return $response; } + // Renvoie le gpx associé ã une tâche (concrètement il s’agit juste du geojson converti automqtiquement) #[Route('/download/{slug}.gpx', name: 'app_task_gpx')] public function gpx(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -371,6 +356,10 @@ class TaskController extends AbstractController $geom = \geoPHP::load($task->getGeojson(), 'json'); $gpx = $geom->out('gpx'); + // TODO On ne vérifie rien ici en partant du principe que tout se passe + // bien. Il doit y avoir des circonstances dans lesquelles ce n’est pas + // exactement le cas. + $response = new Response($gpx); $response->headers->set('Content-Type', 'application/xml'); @@ -382,6 +371,7 @@ class TaskController extends AbstractController return $response; } + // Renvoie le XML OSM associé à la tâche #[Route('/download/{slug}.osm', name: 'app_task_osm')] public function osm(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -396,6 +386,8 @@ class TaskController extends AbstractController return $this->redirect($request->headers->get('referer')); } + // On est pas obligé de faire ça maid en le faisant on s’assure que + // c’est bien du XML valide qu’on envoie. $xml = new \DOMDocument(); $xml->loadXml($task->getOsm()); @@ -410,6 +402,8 @@ class TaskController extends AbstractController return $response; } + // Renvoie la liste des tâches du projet sous forme de CSV (ce qui devrait + // corresponddre à ce que l’on a pu importer) #[Route('/download/{slug}.csv', name: 'app_task_csv')] public function csv(Request $request, EntityManagerInterface $entityManager, $slug): Response { @@ -459,32 +453,4 @@ class TaskController extends AbstractController return $response; } - #[Route('/{slug}/changesets', name: 'app_task_changesets')] - public function changesets(OpenStreetMapClient $osmClient, EntityManagerInterface $entityManager, $slug): Response - { - $repository = $entityManager->getRepository(Task::class); - $task = $repository->findOneBySlug($slug); - - if (!$task) { - $this->addFlash('warning', 'Tâche non trouvée !'); - return $this->redirect($request->headers->get('referer')); - } - - $task->setChangesetsResult( - json_encode( - $osmClient->getChangesets( - $task->getDoneBy()->getUsername(), - $task->getStartAt(), - $task->getFinishAt() - ) - ) - ); - - $entityManager->persist($task); - $entityManager->flush(); - - return $this->redirectToRoute('app_task_show', ['slug' => $task->getSlug()]); - } - - } diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index fa46000..6da7dba 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -9,11 +9,12 @@ use App\Entity\User; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; +// Les données ci-dessous n’ont servi que lors du premier import pour gérer +// l’historique mais n’ont strictement aucun intérêt au delà. class AppFixtures extends Fixture { public function load(ObjectManager $manager): void { - $user = new User(); $user->setUsername('Ptigrouick'); $user->setOsmId(195175); diff --git a/src/Entity/Comment.php b/src/Entity/Comment.php index 52fbc50..fd47a00 100644 --- a/src/Entity/Comment.php +++ b/src/Entity/Comment.php @@ -7,6 +7,9 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; +// Les commentaires sont rédigés par les utilisateurs et associés aux tâches. +// Ils sont égelement générés automatiquements lors des changements d’états des +// tâches. #[ORM\Entity(repositoryClass: CommentRepository::class)] class Comment { diff --git a/src/Entity/Project.php b/src/Entity/Project.php index a93b833..b26dabd 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -9,6 +9,8 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; +// Les projets sont des ensembles de tâches avec des paramètres de +// configuration pour la télécommande JOSM. #[ORM\Entity(repositoryClass: ProjectRepository::class)] class Project { diff --git a/src/Entity/Tag.php b/src/Entity/Tag.php index 9c9379c..dc447e8 100644 --- a/src/Entity/Tag.php +++ b/src/Entity/Tag.php @@ -7,6 +7,8 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +// On utilise les tags pour étiqueter les projets, histoire de les classer le +// jour où il y en aura beaucoup. #[ORM\Entity(repositoryClass: TagRepository::class)] class Tag { diff --git a/src/Entity/Task.php b/src/Entity/Task.php index 43009b5..1d8c7f8 100644 --- a/src/Entity/Task.php +++ b/src/Entity/Task.php @@ -9,6 +9,8 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; +// Les tâches sont l’unité de base du travail à faire. Elles sont groupées dans +// des projets et peut être appropriés par les utilisateurs selon leur état. #[ORM\Entity(repositoryClass: TaskRepository::class)] class Task { diff --git a/src/Entity/User.php b/src/Entity/User.php index aecce87..30d33eb 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -8,6 +8,8 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\UserInterface; +// Les utilisateurs sont générés automatiquements via l’oauth OSM et peuvent +// manipuler des projets et des tâches. #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])] class User implements UserInterface diff --git a/src/EventSubscriber/TaskLifecycleSubscriber.php b/src/EventSubscriber/TaskLifecycleSubscriber.php index b3eb79b..34ab654 100644 --- a/src/EventSubscriber/TaskLifecycleSubscriber.php +++ b/src/EventSubscriber/TaskLifecycleSubscriber.php @@ -15,6 +15,8 @@ use Symfony\Component\Workflow\Event\TransitionEvent; use Symfony\Component\Workflow\WorkflowInterface; use Twig\Environment; +// Surveille la vie des tâches et réalise automatiquement des actions selon les +// circonstances class TaskLifecycleSubscriber implements EventSubscriberInterface { @@ -27,6 +29,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface ) { } + // Cas où la tâche change d’état public function onTransition(Event $event): void { $transition = $event->getTransition(); @@ -57,6 +60,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface $this->entityManager->flush(); } + // Bloque certaines transitions public function onGuard(Event $event): void { $transition = $event->getTransition(); @@ -76,6 +80,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface $event->setBlocked(false); } + // Cas où on commence une tâche public function onStart(Event $event): void { $task = $event->getSubject(); @@ -83,6 +88,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface $task->setStartAt(new \DateTimeImmutable('now')); } + // Cas où on termine une tâche public function onFinish(Event $event): void { $task = $event->getSubject(); @@ -90,6 +96,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface $task->setFinishAt(new \DateTimeImmutable('now')); } + // Cas où une tâche est terminée public function onDone(Event $event): void { $task = $event->getSubject(); @@ -97,8 +104,15 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface $task->setDoneBy($user); + // Essaie de trouver les changesets correspondants à la tâche $task->setChangesetsResult( json_encode($this->osmClient->getChangesets( + // On part du principe que les changesets correspondants sont + // ceux de l’utilisateur entre le moment où la tâche a été + // commencée et celui où elle a été terminée + // TODO C’est pas très précis, on doit pouvoir faire mieux + // (hashatg avec un id spécifique à l’app dans le commentaire + // du changeset, c’est moche mais ça marcherait) $user->getUsername(), $task->getStartAt(), $task->getFinishAt() diff --git a/src/Form/TaskType.php b/src/Form/TaskType.php index d0176d3..2ea3b0f 100644 --- a/src/Form/TaskType.php +++ b/src/Form/TaskType.php @@ -24,7 +24,7 @@ class TaskType extends AbstractType 'label' => 'GeoJSON', 'required' => false, 'help_html' => true, - 'help' => 'Ce qu’il faut dessinner sur la carte au format GeoJSON et mettre à disposition à l’export sous forme de GPX pour l’import dans JOSM. Outil pratique : geojson.io.', + 'help' => 'Ce qu’il faut dessiner sur la carte au format GeoJSON et mettre à disposition à l’export sous forme de GPX pour l’import dans JOSM. Outil pratique : geojson.io.', ]) ->add('osm', TextareaType::class, [ 'label' => 'OSM', diff --git a/src/Service/GeoJsonManager.php b/src/Service/GeoJsonManager.php index 7448a1b..5225445 100644 --- a/src/Service/GeoJsonManager.php +++ b/src/Service/GeoJsonManager.php @@ -17,6 +17,7 @@ class GeoJsonManager ) { } + // Enrichit le geojson d’une tâche avec des propriétés calcuées private function getFullGeoJson(Task $task) { if (!$task->hasGeojson()) { @@ -48,6 +49,7 @@ class GeoJsonManager return $data; } + // Produit le geojson d’un projet ou d’une tâche de façon transparente pour simplifier public function generateGeoJson($entity) { $geoJsons = []; diff --git a/src/Service/OpenStreetMapClient.php b/src/Service/OpenStreetMapClient.php index 626d2a8..de62a50 100644 --- a/src/Service/OpenStreetMapClient.php +++ b/src/Service/OpenStreetMapClient.php @@ -6,6 +6,7 @@ use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\HttpClient\HttpClientInterface; +// Fournit un accès à l’API OSM class OpenStreetMapClient { public function __construct( diff --git a/src/Service/OsmoseClient.php b/src/Service/OsmoseClient.php index 237068c..3de2e09 100644 --- a/src/Service/OsmoseClient.php +++ b/src/Service/OsmoseClient.php @@ -19,6 +19,9 @@ namespace App\Service; use Symfony\Contracts\HttpClient\HttpClientInterface; +// Fournira un accès à l’API Osmose si nécessaire (histoire de vérifier qu’on a +// pas trop oublié de tenir compte de la validation côté JOSM lors de la +// contribution sur une tâche ?) class OsmoseClient { public function __construct( diff --git a/src/Service/OverpassClient.php b/src/Service/OverpassClient.php index 17c3afa..38f0738 100644 --- a/src/Service/OverpassClient.php +++ b/src/Service/OverpassClient.php @@ -4,6 +4,7 @@ namespace App\Service; use Symfony\Contracts\HttpClient\HttpClientInterface; +// Founit un accès à l’API Overpass class OverpassClient { public function __construct( diff --git a/src/Service/SourceGenerator.php b/src/Service/SourceGenerator.php index 69820e0..d206df2 100644 --- a/src/Service/SourceGenerator.php +++ b/src/Service/SourceGenerator.php @@ -4,6 +4,7 @@ namespace App\Service; use App\Entity\Task; +// Génère la source de changeset class SourceGenerator { diff --git a/src/Service/TaskLifecycleManager.php b/src/Service/TaskLifecycleManager.php index a093dc9..e36a6ec 100644 --- a/src/Service/TaskLifecycleManager.php +++ b/src/Service/TaskLifecycleManager.php @@ -6,6 +6,7 @@ use App\Entity\Project; use App\Entity\Task; use Symfony\Component\Workflow\WorkflowInterface; +// Utilistaire pour récupérer facilement les stats de réalisation d’un projet ou d’une tâche class TaskLifecycleManager { diff --git a/templates/_header.html.twig b/templates/_header.html.twig index 96ad3a5..3392829 100644 --- a/templates/_header.html.twig +++ b/templates/_header.html.twig @@ -1,7 +1,7 @@
- {{ title }} + {{ title }} diff --git a/templates/macro.html.twig b/templates/macro.html.twig index 14bc607..1e70998 100644 --- a/templates/macro.html.twig +++ b/templates/macro.html.twig @@ -19,6 +19,17 @@ {% endif %} {% endmacro %} +{# + +Produit le HTML géré par le contrôleur Stimulus `map` et permet d’afficher une +carte interactive + +Où : +* `entity` peut être une instance de `Project` ou de `Task` pour représenter ce + qu’il faut mapper +* `overpassResult` est optionnel pour représenter ce qui est actuellement mappé + +#} {% macro map(entity, overpassResult='') %}
{% endmacro %} +{# + +Génère le HTML qui va bien pour un bloc de texte que l’on peut copier dans le +presse papier en cliquant simplement sur un bouton. + +Cf le controlleur Stimulus `clipboard` + +TODO Rajouter un retour visuel pour signifier que la copie a bien eu lieu +serait pas du luxe… + +#} {% macro clipboard(text) %}