Interface locale : rtg interface (voyages, sources, journées, récit, génération)

Serveur web local sans dépendance nouvelle (http.server, Jinja2, htmx et
Leaflet fournis). Écrans : Mes voyages, nouveau voyage, un voyage (journées
avec carte, rattachement des traces sans heures à un jour avec suggestion
d'après les photos, sources avec choix de dossier, infos et récit,
génération avec avancement et arrêt), réglages du site et vérification
des outils.

Chaîne : jours imposés ([dates_traces] dans voyage.toml), arrêt propre de
la compression, suivi des étapes, état structuré (site.tableau_etat),
titre du voyage repris de voyage.toml sur l'accueil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-09 14:07:47 +00:00
1 parent 608334b9d8
commit 06153a227e
37 files changed
+2618 -57

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Interface locale de RTG (`rtg interface`)."""
+94
View File
@@ -0,0 +1,94 @@
<div class="analyse">
{% if not a.details_traces %}
<p class="vide">Aucune trace GPS. Indiquez où elles se trouvent dans l'onglet <a href="#sources">Sources</a>.</p>
{% else %}
<div class="carte" data-couches='{{ couches|tojson }}'></div>
{% if non_datees %}
<div class="encart attention">
<h3>{{ non_datees|length }} trace{{ 's' if non_datees|length > 1 }} à rattacher à un jour</h3>
<p class="aide">Ces traces n'ont pas d'heures (itinéraire préparé, par exemple) et leur nom ne contient pas de date au format AAAA-MM-JJ.
Choisissez le jour de chacune ; les traces en pointillé sur la carte sont celles-ci.</p>
<table>
<tbody>
{% for nom in non_datees %}
{% set d = a.details_traces|selectattr("nom", "equalto", nom)|first %}
<tr>
<td><code>{{ nom }}</code> <span class="discret">{{ d.km|round|int }} km</span></td>
<td>
<form class="en-ligne" hx-post="/voyage/date" hx-target="#analyse">
<input type="hidden" name="v" value="{{ v.chemin }}"><input type="hidden" name="fichier" value="{{ nom }}">
<input type="date" name="jour" required><button>OK</button>
</form>
</td>
<td>
{% set s = suggestions.get(nom) %}
{% if s and s[0] %}
<button class="petit" hx-post="/voyage/date" hx-target="#analyse"
hx-vals='{{ {"v": v.chemin, "fichier": nom, "jour": s[0].isoformat()}|tojson }}'
title="{{ s[1] }} photo(s) ou vidéo(s) de ce jour prises le long de la trace">Le {{ s[0]|date_longue }} ?</button>
{% else %}<span class="discret">pas de suggestion</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if non_datees|length > 1 %}
<form class="en-ligne" hx-post="/voyage/dates-suite" hx-target="#analyse">
<input type="hidden" name="v" value="{{ v.chemin }}">
Ou bien, dans l'ordre des noms, un jour par trace à partir du <input type="date" name="debut" required>
<button>Répartir</button>
</form>
{% endif %}
</div>
{% endif %}
<h3>{{ jours|length }} journée{{ 's' if jours|length > 1 }} · {{ total_km|round|int }} km</h3>
<table class="jours">
<thead><tr><th>Jour</th><th class="num">km</th><th>Traces</th></tr></thead>
<tbody>
{% for j in jours %}
<tr><td><span class="puce" style="background: {{ j.couleur }}"></span> {{ j.date|date_longue }}</td>
<td class="num">{{ j.km|round|int }}</td>
<td class="discret">{{ j.traces|join(", ") }}</td></tr>
{% endfor %}
</tbody>
</table>
<details>
<summary>Les {{ a.details_traces|length }} fichier{{ 's' if a.details_traces|length > 1 }} de traces et leur jour</summary>
<p class="aide">Un jour imposé ici remplace celui que RTG déduit des heures ou du nom du fichier.</p>
<table>
<thead><tr><th>Fichier</th><th>Jour(s) retenu(s)</th><th>Jour imposé</th></tr></thead>
<tbody>
{% for d in a.details_traces %}
<tr>
<td><code title="{{ d.fichier }}">{{ d.nom }}</code>{% if d.erreur %} <span class="pastille attention" title="{{ d.erreur }}">illisible</span>{% endif %}</td>
<td>{% for j in d.jours %}{{ j|date_courte }}{{ ", " if not loop.last }}{% else %}<span class="discret">—</span>{% endfor %}</td>
<td>
<form class="en-ligne" hx-post="/voyage/date" hx-target="#analyse">
<input type="hidden" name="v" value="{{ v.chemin }}"><input type="hidden" name="fichier" value="{{ d.nom }}">
<input type="date" name="jour" value="{{ d.impose.isoformat() if d.impose else '' }}">
<button class="petit">{{ "Changer" if d.impose else "Imposer" }}</button>
</form>
{% if d.impose %}
<button class="petit" hx-post="/voyage/date" hx-target="#analyse"
hx-vals='{{ {"v": v.chemin, "fichier": d.nom, "jour": ""}|tojson }}'>Annuler</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% set autres = a.a_verifier|reject("commence_par", "Trace à rattacher")|list %}
{% if autres %}
<div class="encart attention">
<h3>À vérifier</h3>
<ul>{% for m in autres %}<li>{{ m }}</li>{% endfor %}</ul>
</div>
{% endif %}
<p class="discret">Photos trouvées : {{ a.photos|length }} · Vidéos trouvées : {{ a.videos|length }}</p>
</div>
+5
View File
@@ -0,0 +1,5 @@
{% if tache and tache.en_cours %}
<a class="bandeau en-cours" href="/tache">⏳ {{ tache.libelle }} · {{ tache.etape }}{% if tache.etape_voyage %} ({{ tache.etape_voyage }}){% endif %}{% if tache.video and tache.video.rang %} · vidéo {{ tache.video.rang[0] }}/{{ tache.video.rang[1] }} ({{ tache.video.pct|int }} %){% endif %}</a>
{% elif tache %}
<a class="bandeau {{ 'ok' if tache.ok else 'echec' }}" href="/tache">{{ "✓" if tache.ok else "⚠️" }} {{ tache.libelle }} : {{ tache.etape|lower }}</a>
{% endif %}
+27
View File
@@ -0,0 +1,27 @@
<div class="fenetre-fond" data-fermer-fond>
<div class="fenetre" role="dialog" aria-label="Choisir un dossier">
<h3>Choisir un dossier de {{ libelle }}</h3>
<div class="raccourcis">
{% for nom, c in raccourcis %}
<button class="petit" hx-get="/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ c|url }}" hx-target="#fenetre">{{ nom }}</button>
{% endfor %}
</div>
<p class="chemin"><code>{{ chemin }}</code></p>
<ul class="liste-dossiers">
{% if parent %}<li><a href="#" hx-get="/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ parent|url }}" hx-target="#fenetre">⬆︎ Dossier parent</a></li>{% endif %}
{% for d in sous %}
<li><a href="#" hx-get="/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ (chemin.rstrip(sep) ~ sep ~ d)|url }}" hx-target="#fenetre">📁 {{ d }}</a></li>
{% else %}
<li class="vide">Pas de sous-dossier</li>
{% endfor %}
</ul>
<div class="pied">
<span class="{{ 'ok' if nb else 'discret' }}">{{ nb }} fichier{{ 's' if nb > 1 }} de {{ libelle }} ici</span>
<span>
<button data-fermer>Annuler</button>
<button class="principal" data-fermer hx-post="/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
hx-vals='{{ {"v": v, "cle": cle, "action": "ajouter", "chemin": chemin}|tojson }}'>Choisir ce dossier</button>
</span>
</div>
</div>
</div>
+12
View File
@@ -0,0 +1,12 @@
<div class="infos">
<div><span class="etiquette">Rushs (sources)</span><strong>{{ i.nb_rushs }}</strong> vidéo{{ 's' if i.nb_rushs > 1 }} · {{ i.rushs|taille }}</div>
<div><span class="etiquette">Reste à encoder</span>
{% if i.a_encoder %}<strong>{{ i.a_encoder }}</strong> · {{ i.a_encoder_octets|taille }}{% else %}<strong>rien</strong>{% endif %}</div>
<div><span class="etiquette">Vidéos web</span>{{ i.videos_web|taille }}{% if i.apercus %} <span class="discret">+ aperçus {{ i.apercus|taille }}</span>{% endif %}</div>
<div><span class="etiquette">Photos</span>{{ i.photos|taille }} <span class="discret">→ web {{ i.photos_web|taille }}</span></div>
<div><span class="etiquette">Espace libre (site)</span>{{ i.libre|taille }}</div>
<div><span class="etiquette">Encodage</span>
{{ noms_qualites.get(cfg.video.qualite, cfg.video.qualite) }} · {{ "matériel" if i.encodage.encodeur == "vt" else "processeur" }}
<span class="discret">(HEVC CRF {{ i.encodage.crf_hevc }} / H.264 CRF {{ i.encodage.crf_h264 }})</span>
<a href="/reglages#video">changer</a></div>
</div>
+31
View File
@@ -0,0 +1,31 @@
<div id="sources" class="sources">
{% for cle, s in sources.items() %}
<div class="bloc-source">
<h3>{{ s.libelle }}</h3>
<ul>
{% for l in s.lignes %}
<li class="{{ '' if l.existe else 'manque' }}">
<code title="{{ l.absolu }}">{{ l.source }}</code>
{% if l.existe %}<span class="pastille {{ 'ok' if l.nb else 'neutre' }}">{{ l.nb }} fichier{{ 's' if l.nb > 1 }}</span>
{% elif l.lien_casse %}<span class="pastille attention">disque débranché ?</span>
{% else %}<span class="pastille attention">introuvable</span>{% endif %}
<button class="petit" hx-post="/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
hx-vals='{{ {"v": v.chemin, "cle": cle, "action": "retirer", "chemin": l.source}|tojson }}'>Retirer</button>
</li>
{% else %}
<li class="vide">Aucune source</li>
{% endfor %}
</ul>
<div class="ajout">
<button hx-get="/dossiers?cle={{ cle }}&v={{ v.chemin|url }}" hx-target="#fenetre">Choisir un dossier…</button>
<form hx-post="/voyage/sources" hx-target="#sources" hx-swap="outerHTML" class="en-ligne">
<input type="hidden" name="v" value="{{ v.chemin }}">
<input type="hidden" name="cle" value="{{ cle }}">
<input type="hidden" name="action" value="ajouter">
<input name="chemin" placeholder="ou collez un chemin (dossier ou fichier)">
<button>Ajouter</button>
</form>
</div>
</div>
{% endfor %}
</div>
+2
View File
@@ -0,0 +1,2 @@
<pre class="journal court">{{ lignes|join("\n") }}</pre>
<p class="discret">Disque du site : {{ libre|taille }} libres sur {{ total|taille }} · <code>{{ racine }}</code></p>
+24
View File
@@ -0,0 +1,24 @@
{% if message %}<div class="erreur">{{ message }}</div>{% endif %}
{% if tache %}
<div class="tache {{ 'en-cours' if tache.en_cours else ('ok' if tache.ok else 'echec') }}"
{% if tache.en_cours %}hx-get="/tache/fragment{{ '?complet=1' if complet }}" hx-trigger="every 1s" hx-swap="outerHTML"{% endif %}>
<div class="tache-tete">
<strong>{{ tache.libelle }}</strong>
<span class="etape">{{ "⏳ " if tache.en_cours }}{{ tache.etape }}{% if tache.etape_voyage %} — {{ tache.etape_voyage }}{% endif %}</span>
<span class="discret">{{ tache.duree }}</span>
{% if tache.en_cours %}
<button class="danger petit" hx-post="/tache/arreter" hx-target="closest .tache" hx-swap="outerHTML">Arrêter</button>
{% endif %}
</div>
{% if tache.video and tache.video.rang %}
{% set i, n = tache.video.rang %}
<div class="video-cours">
<div>🎬 Vidéo {{ i }}/{{ n }} · <code>{{ tache.video.nom }}</code> · {{ tache.video.pct|int }} %</div>
<progress max="100" value="{{ tache.video.pct }}"></progress>
<progress class="global" max="{{ n }}" value="{{ i - 1 + tache.video.pct / 100 }}" title="Ensemble des vidéos"></progress>
</div>
{% endif %}
{% set lignes = tache.lignes|list %}
<pre class="journal">{{ lignes[-(1000 if complet else 30):]|join("\n") }}</pre>
</div>
{% endif %}
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titre %}{% endblock %} · RTG</title>
<link rel="stylesheet" href="/static/rtg.css">
{% block tete %}{% endblock %}
<script src="/static/htmx.min.js"></script>
</head>
<body>
<header class="barre">
<a class="logo" href="/">RTG</a>
<nav>
<a href="/">Mes voyages</a>
<a href="/reglages">Réglages</a>
<a href="/tache">Traitement</a>
<a href="/site/" target="_blank">Voir le site ↗</a>
</nav>
<div id="bandeau" hx-get="/tache/bandeau" hx-trigger="load, every 2s"></div>
</header>
<main>
{% block contenu %}{% endblock %}
</main>
<div id="messages" aria-live="polite"></div>
<div id="fenetre"></div>
<footer>RoadTripsGenerator {{ version }} · <code>{{ racine }}</code></footer>
<script src="/static/rtg.js"></script>
{% block fin %}{% endblock %}
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block titre %}Nouveau voyage{% endblock %}
{% block contenu %}
<h1>Nouveau voyage</h1>
<form id="formulaire" class="formulaire" hx-post="/nouveau" hx-target="#formulaire" hx-select="#formulaire" hx-swap="outerHTML">
{% if erreur %}<div class="erreur">{{ erreur }}</div>{% endif %}
<label>Titre
<input name="titre" value="{{ form.titre[0] if form and form.titre else '' }}" placeholder="Été 2027 en Écosse" autofocus>
</label>
<label>Nom du dossier
<input name="dossier" required value="{{ form.dossier[0] if form and form.dossier else '' }}" placeholder="Ete-2027">
<span class="aide">Il apparaît dans l'adresse des pages : évitez espaces et accents.</span>
</label>
<label>Moto
<input name="moto" value="{{ form.moto[0] if form and form.moto else '' }}" placeholder="facultatif">
</label>
<fieldset>
<legend>Rubrique</legend>
<label class="choix"><input type="radio" name="groupe" value="" checked> Voyages <span class="aide">(dossier à la racine du site, affiché sur la carte globale)</span></label>
{% for g in groupes %}
<label class="choix"><input type="radio" name="groupe" value="{{ g }}"> {{ g }}</label>
{% endfor %}
<label class="choix">Nouvelle rubrique : <input name="groupe_nouveau" placeholder="Balades"></label>
</fieldset>
<p><button class="principal">Créer le voyage</button> <a href="/">Annuler</a></p>
<p class="aide">Le dossier est créé avec un <code>voyage.toml</code>. Vous indiquerez ensuite où sont les traces, les vidéos et les photos : elles restent où elles sont.</p>
</form>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block titre %}Réglages{% endblock %}
{% block contenu %}
<h1>Réglages du site</h1>
<p class="aide">Enregistrés dans <code>site.toml</code>. Ils valent pour tous les voyages.</p>
<form class="formulaire" hx-post="/reglages" hx-target="#retour-reglages">
<fieldset>
<legend>Site</legend>
<label>Titre <input name="titre" value="{{ cfg.titre }}"></label>
<label>Adresse publique <input name="adresse_publique" value="{{ cfg.adresse_publique }}" placeholder="https://…/">
<span class="aide">Adresse où le site est consulté (liens de partage).</span></label>
<label>Voyages récents par rubrique sur l'accueil <input type="number" min="1" max="12" name="accueil_nb_recents" value="{{ cfg.accueil_nb_recents }}"></label>
</fieldset>
<fieldset id="video">
<legend>Vidéos</legend>
<p class="aide">Chaque rush donne une version 1080p HEVC (H.265) et une version 720p H.264, lue par défaut et partout.</p>
{% set descriptions = {"econome": "fichiers plus légers, légère perte visible sur les scènes chargées",
"equilibree": "réglage d'origine, très bon compromis",
"haute": "presque indiscernable de l'original, fichiers nettement plus gros"} %}
{% for q, p in qualites.items() %}
<label class="choix"><input type="radio" name="qualite" value="{{ q }}" {{ 'checked' if cfg.video.qualite == q }}>
<strong>{{ {"econome": "Économe", "equilibree": "Équilibrée", "haute": "Haute"}[q] }}</strong>
<span class="aide">{{ descriptions[q] }} (CRF {{ p.crf_hevc }} / {{ p.crf_h264 }})</span></label>
{% endfor %}
<label>Encodeur
<select name="encodeur">
<option value="processeur" {{ 'selected' if cfg.video.encodeur != 'materiel' }}>Processeur (x265 / x264) : meilleure qualité à taille égale</option>
<option value="materiel" {{ 'selected' if cfg.video.encodeur == 'materiel' }}>Matériel (VideoToolbox, Mac) : bien plus rapide, fichiers plus gros</option>
</select></label>
<label>Vitesse de l'encodeur processeur
<select name="preset">
{% for p in ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower"] %}
<option {{ 'selected' if cfg.video.preset == p }}>{{ p }}</option>
{% endfor %}
</select>
<span class="aide">« medium » par défaut ; plus lent = un peu plus léger à qualité égale.</span></label>
<label>Vidéos encodées en parallèle <input type="number" min="0" max="16" name="jobs" value="{{ cfg.video.jobs }}">
<span class="aide">0 = automatique selon le nombre de cœurs.</span></label>
</fieldset>
<fieldset>
<legend>Photos</legend>
<label>Plus grand côté des photos web (pixels) <input type="number" min="640" max="6000" step="10" name="grand_cote" value="{{ cfg.photos.grand_cote }}"></label>
<label>Qualité JPEG <input type="number" min="40" max="100" name="qualite_photos" value="{{ cfg.photos.qualite }}"></label>
<label class="choix"><input type="checkbox" name="positions_deduites" {{ 'checked' if cfg.photos.positions_deduites }}>
Placer les photos sans GPS d'après l'heure et la trace (à 5 minutes près)</label>
</fieldset>
<fieldset>
<legend>Journées</legend>
<label>Fuseau horaire <input name="fuseau" value="{{ cfg.journees.fuseau }}" placeholder="Europe/Paris"></label>
<label>Heure de changement de jour <input type="number" min="0" max="12" name="coupure_heure" value="{{ cfg.journees.coupure_heure }}">
<span class="aide">Une étape finie après minuit mais avant cette heure reste sur la veille.</span></label>
</fieldset>
<fieldset>
<legend>Carte globale</legend>
<label>Cartes Google My Maps à reprendre (une adresse par ligne)
<textarea name="mymaps" rows="3">{{ cfg.carte_globale.mymaps|join("\n") }}</textarea>
<span class="aide">Leurs tracés sont récupérés une fois et ajoutés à la carte de l'accueil, sur fond OpenStreetMap.</span></label>
</fieldset>
<p><button class="principal">Enregistrer</button> <span id="retour-reglages"></span></p>
</form>
<h2>Système</h2>
<div hx-get="/systeme" hx-trigger="load"><p class="chargement">Vérification des outils…</p></div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block titre %}Traitement{% endblock %}
{% block contenu %}
<h1>Traitement</h1>
{% if tache %}
{% if tache.voyage %}<p><a href="/voyage?v={{ tache.voyage|url }}">← {{ tache.voyage }}</a></p>{% endif %}
{% with complet = True %}{% include "_tache.html" %}{% endwith %}
{% else %}
<p class="vide">Aucun traitement lancé depuis l'ouverture de l'interface.</p>
{% endif %}
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block titre %}{{ v.titre }}{% endblock %}
{% block tete %}<link rel="stylesheet" href="/static/leaflet.css"><script src="/static/leaflet.js"></script>{% endblock %}
{% block contenu %}
<div class="entete-page">
<div>
<p class="fil"><a href="/">Mes voyages</a> › {{ v.rubrique }}</p>
<h1>{{ v.titre }}</h1>
<p class="discret">{{ [periode(v.debut, v.fin), v.moto]|select|join(" · ") }} <code>{{ v.chemin }}</code></p>
</div>
<div class="actions">
{% if v.genere %}<a class="bouton" href="/site/{{ v.chemin }}/index.html" target="_blank">Voir la page ↗</a>{% endif %}
</div>
</div>
<nav class="onglets">
<a href="#journees">Journées</a>
<a href="#sources">Sources</a>
<a href="#infos">Infos et récit</a>
<a href="#generer">Générer</a>
</nav>
<section class="onglet" id="onglet-journees">
<div id="analyse" hx-get="/voyage/analyse?v={{ v.chemin|url }}" hx-trigger="load, sources-modifiees from:body, tache-finie from:body">
<p class="chargement">Lecture des traces…</p>
</div>
</section>
<section class="onglet" id="onglet-sources">
<p class="aide">Indiquez où se trouvent les fichiers d'origine : un ou plusieurs dossiers (ou fichiers) par type, sur n'importe quel disque.
RTG ne les modifie jamais : les traces sont recopiées par journée dans <code>routes/</code>, les photos dans <code>photos/</code> (HEIC converties en JPEG),
et les vidéos sont lues directement pour l'encodage.</p>
{% include "_sources.html" %}
</section>
<section class="onglet" id="onglet-infos">
<form class="formulaire" hx-post="/voyage/infos" hx-target="find .retour">
<input type="hidden" name="v" value="{{ v.chemin }}">
<label>Titre <input name="titre" value="{{ cfg.titre }}"></label>
<label>Moto <input name="moto" value="{{ cfg.moto }}" placeholder="facultatif : affichée dans la légende de la carte globale"></label>
<p class="aide">Rubrique : <strong>{{ v.rubrique }}</strong> (elle vient du dossier où est rangé le voyage).</p>
<p><button class="principal">Enregistrer</button> <span class="retour"></span></p>
</form>
<form class="formulaire large" hx-post="/voyage/recit" hx-target="find .retour">
<input type="hidden" name="v" value="{{ v.chemin }}">
<label>Récit (<code>voyage.md</code>, en Markdown)
<textarea name="recit" rows="18" spellcheck="true">{{ recit }}</textarea>
</label>
<p class="aide">Liens spéciaux : <code>[Carte du jour](jour:2026-08-01)</code> ouvre la carte d'un jour,
<code>[Le col](media:photos/IMG_4831.jpeg)</code> un média du voyage.</p>
<p><button class="principal">Enregistrer le récit</button> <span class="retour"></span></p>
</form>
</section>
<section class="onglet" id="onglet-generer">
<div hx-get="/voyage/infos-utiles?v={{ v.chemin|url }}" hx-trigger="load, sources-modifiees from:body, tache-finie from:body"><p class="chargement">Calcul des tailles…</p></div>
<form class="formulaire" hx-post="/tache/lancer" hx-target="#tache-zone">
<input type="hidden" name="v" value="{{ v.chemin }}">
<label class="choix"><input type="checkbox" name="sans_compression"> Sans encoder les vidéos <span class="aide">(photos, positions et pages seulement)</span></label>
<label class="choix"><input type="checkbox" name="force"> Tout refaire, même ce qui est déjà à jour</label>
<p>
<button class="principal" name="quoi" value="generer">Générer le voyage</button>
<button name="quoi" value="compresser">Encoder les vidéos seulement</button>
</p>
</form>
<div id="tache-zone">{% if tache and tache.voyage == v.chemin %}{% include "_tache.html" %}{% endif %}</div>
</section>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block titre %}Mes voyages{% endblock %}
{% block contenu %}
{% if not site_existe %}
<div class="encart">
<p><strong>Ce dossier n'est pas encore un site RTG.</strong> Il n'y a pas de <code>site.toml</code> dans <code>{{ racine }}</code>.</p>
<p><button class="principal" hx-post="/site/creer">Créer le site dans ce dossier</button></p>
<p class="aide">Pour un autre dossier, relancez avec <code>rtg interface --racine CHEMIN</code>.</p>
</div>
{% endif %}
<div class="entete-page">
<h1>Mes voyages <span class="compte">{{ nb }}</span></h1>
<div class="actions">
<a class="bouton principal" href="/nouveau">+ Nouveau voyage</a>
<form hx-post="/tache/lancer" hx-target="#tache-zone">
<input type="hidden" name="quoi" value="accueil">
<button title="index.html, pages des rubriques et carte globale">Refaire l'accueil</button>
</form>
</div>
</div>
<div id="tache-zone"></div>
{% for g, liste in groupes %}
<section>
<h2>{{ g }} <span class="compte">{{ liste|length }}</span></h2>
<table class="voyages">
<thead><tr><th>Voyage</th><th>Dates</th><th class="num">Jours</th><th>Vidéos web</th><th>Photos web</th><th>Pages</th><th></th></tr></thead>
<tbody>
{% for v in liste %}
<tr>
<td><a href="/voyage?v={{ v.chemin|url }}"><strong>{{ v.titre }}</strong></a>
{% if v.moto %}<div class="discret">{{ v.moto }}</div>{% endif %}</td>
<td>{{ periode(v.debut, v.fin) }}</td>
<td class="num">{{ v.jours or "—" }}</td>
<td>
{% if v.videos_sources %}
<span class="pastille {{ 'ok' if v.videos_faites == v.videos_sources else 'attention' }}">{{ v.videos_faites }}/{{ v.videos_sources }}</span>
{% elif v.videos_web %}
<span class="pastille neutre" title="Les rushs ne sont pas accessibles (disque débranché ?)">{{ v.videos_web }} · sources absentes</span>
{% else %}<span class="discret">—</span>{% endif %}
</td>
<td>
{% if v.photos %}
<span class="pastille {{ 'ok' if v.photos_web >= v.photos else 'attention' }}">{{ v.photos_web }}/{{ v.photos }}</span>
{% else %}<span class="discret">—</span>{% endif %}
</td>
<td>
{% if not v.genere %}<span class="pastille neutre">jamais générées</span>
{% elif v.pages_a_jour %}<span class="pastille ok">à jour</span>
{% else %}<span class="pastille attention">à refaire</span>{% endif %}
{% if v.a_verifier %}<span class="pastille attention" title="{{ v.a_verifier|join('\n') }}">⚠️ {{ v.a_verifier|length }}</span>{% endif %}
</td>
<td class="droite">{% if v.genere %}<a href="/site/{{ v.chemin }}/index.html" target="_blank">Voir ↗</a>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% else %}
<p class="vide">Aucun voyage pour l'instant. Commencez par « Nouveau voyage ».</p>
{% endfor %}
{% endblock %}
+670
View File
@@ -0,0 +1,670 @@
"""Interface de RTG : un petit serveur web local, ouvert dans le navigateur.
Rien n'est installé en plus : le serveur vient de Python, les pages sont des
modèles Jinja2, htmx (échanges avec le serveur) et Leaflet (cartes) sont
fournis dans static/. Le serveur n'écoute que sur 127.0.0.1.
Sécurité : une page web quelconque ouverte dans le navigateur pourrait
envoyer des requêtes à ce serveur. Toute requête qui modifie quelque chose
doit donc porter l'en-tête HX-Request (qu'un autre site ne peut pas ajouter
sans l'accord du serveur), et l'en-tête Host doit être le nôtre.
"""
import io
import json
import mimetypes
import os
import platform
import re
import shutil
import string
import threading
import webbrowser
from contextlib import redirect_stdout
from datetime import date, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, unquote, urlsplit
from jinja2 import Environment, FileSystemLoader, select_autoescape
from rtg import __version__, accueil, site, sources, video
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video,
reglages_site, reglages_voyage)
from rtg.interface import taches
from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible
ICI = os.path.dirname(os.path.abspath(__file__))
STATIC = os.path.join(ICI, "static")
MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août",
"septembre", "octobre", "novembre", "décembre"]
JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
COULEURS = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#46a0b4", "#f032e6",
"#7a9a01", "#c47f00", "#008080", "#9a6324", "#800000", "#2f4b7c", "#808000"]
CLES_SOURCES = {"traces": ("Traces GPS", (".gpx",), "traces GPS"),
"videos": ("Vidéos", EXT_VIDEOS, "vidéos"),
"photos": ("Photos", EXT_PHOTOS, "photos")}
def _date_longue(d):
return f"{JOURS[d.weekday()]} {d.day} {MOIS[d.month - 1]} {d.year}" if d else ""
def _periode(debut, fin):
if not debut:
return ""
if debut == fin:
return f"{debut.day} {MOIS[debut.month - 1]} {debut.year}"
if debut.year == fin.year:
return f"{debut.day} {MOIS[debut.month - 1]} – {fin.day} {MOIS[fin.month - 1]} {fin.year}"
return f"{debut.day} {MOIS[debut.month - 1]} {debut.year} – {fin.day} {MOIS[fin.month - 1]} {fin.year}"
ENV = Environment(loader=FileSystemLoader(os.path.join(ICI, "modeles")),
autoescape=select_autoescape(["html"]), trim_blocks=True, lstrip_blocks=True)
ENV.filters["date_longue"] = _date_longue
ENV.filters["date_courte"] = lambda d: f"{d.day} {MOIS[d.month - 1][:4].rstrip('.')}" if d else ""
ENV.filters["taille"] = taille_lisible
ENV.filters["url"] = lambda s: quote(str(s), safe="")
ENV.tests["commence_par"] = lambda s, debut: str(s).startswith(debut)
ENV.globals.update(version=__version__, periode=_periode, qualites=QUALITES,
noms_qualites={"econome": "économe", "equilibree": "équilibrée", "haute": "haute"})
class Erreur(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code, self.message = code, message
class Reponse:
def __init__(self, corps=b"", type_="text/html; charset=utf-8", code=200, entetes=None):
self.corps = corps.encode("utf-8") if isinstance(corps, str) else corps
self.type, self.code, self.entetes = type_, code, entetes or {}
def page(modele, **ctx):
return Reponse(ENV.get_template(modele).render(**ctx))
def json_rep(donnees):
return Reponse(json.dumps(donnees, ensure_ascii=False, default=str), "application/json")
# --- Application ---------------------------------------------------------------
class Application:
def __init__(self, racine):
self.racine = os.path.abspath(racine)
self._cache = {} # voyage → (empreinte des sources, analyse)
self.routes = []
for methode, motif, fn in [
("GET", r"/", self.accueil),
("POST", r"/site/creer", self.creer_site),
("GET", r"/nouveau", self.nouveau),
("POST", r"/nouveau", self.nouveau_creer),
("GET", r"/voyage", self.voyage),
("POST", r"/voyage/infos", self.voyage_infos),
("POST", r"/voyage/recit", self.voyage_recit),
("POST", r"/voyage/sources", self.voyage_sources),
("GET", r"/voyage/analyse", self.voyage_analyse),
("POST", r"/voyage/date", self.voyage_date),
("POST", r"/voyage/dates-suite", self.voyage_dates_suite),
("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles),
("GET", r"/dossiers", self.dossiers),
("GET", r"/reglages", self.reglages),
("POST", r"/reglages", self.reglages_enregistrer),
("GET", r"/systeme", self.systeme),
("POST", r"/tache/lancer", self.tache_lancer),
("POST", r"/tache/arreter", self.tache_arreter),
("GET", r"/tache", self.tache),
("GET", r"/tache/fragment", self.tache_fragment),
("GET", r"/tache/bandeau", self.tache_bandeau),
]:
self.routes.append((methode, re.compile(motif + "$"), fn))
# --- outils
def contexte(self, **ctx):
ctx.setdefault("racine", self.racine)
ctx.setdefault("site_existe", os.path.exists(os.path.join(self.racine, SITE_TOML)))
ctx.setdefault("tache", taches.COURANTE)
return ctx
def chemin_voyage(self, rel):
if not rel:
raise Erreur(400, "Voyage non précisé")
chemin = os.path.realpath(os.path.join(self.racine, rel))
if not chemin.startswith(os.path.realpath(self.racine) + os.sep) or not site.est_voyage(chemin):
raise Erreur(404, f"Voyage introuvable : {rel}")
return chemin
def cfg_voyage(self, chemin):
return reglages_voyage(chemin, reglages_site(self.racine))
def fiche_voyage(self, chemin):
return site.etat_voyage(chemin, self.racine, reglages_site(self.racine))
def toml_voyage(self, chemin, changements):
modifier_toml(os.path.join(chemin, VOYAGE_TOML), changements,
entete="Voyage RoadTripsGenerator : sources, infos et réglages propres.")
# --- Mes voyages
def accueil(self, r):
voyages = site.tableau_etat(self.racine)
groupes = {}
for v in sorted(voyages, key=lambda v: v["debut"] or date.min, reverse=True):
groupes.setdefault(v["rubrique"], []).append(v)
ordre = sorted(groupes, key=lambda g: (g != "Voyages", g))
return page("voyages.html", **self.contexte(groupes=[(g, groupes[g]) for g in ordre],
nb=len(voyages)))
def creer_site(self, r):
chemin = os.path.join(self.racine, SITE_TOML)
if not os.path.exists(chemin):
modifier_toml(chemin, {"titre": "Nos Road-Trips"},
entete="Réglages du site RoadTripsGenerator (rtg).")
return Reponse("", entetes={"HX-Redirect": "/"})
def groupes_existants(self):
res = []
for d in sorted(os.listdir(self.racine)):
c = os.path.join(self.racine, d)
if os.path.isdir(c) and not d.startswith((".", "_")) and d not in site.IGNORES \
and not site.est_voyage(c):
res.append(d)
return res
def nouveau(self, r):
return page("nouveau.html", **self.contexte(groupes=self.groupes_existants()))
def nouveau_creer(self, r):
dossier = r.f("dossier").strip()
groupe = r.f("groupe_nouveau").strip() or r.f("groupe").strip()
if not dossier:
return page("nouveau.html", **self.contexte(groupes=self.groupes_existants(),
erreur="Indiquez un nom de dossier.", form=r.form))
try:
chemin = site.creer_voyage(self.racine, dossier, r.f("titre").strip(), groupe, r.f("moto").strip())
except (ValueError, OSError) as e:
return page("nouveau.html", **self.contexte(groupes=self.groupes_existants(),
erreur=str(e), form=r.form))
rel = os.path.relpath(chemin, self.racine).replace(os.sep, "/")
return Reponse("", entetes={"HX-Redirect": f"/voyage?v={quote(rel)}#sources"})
# --- Un voyage
def voyage(self, r):
chemin = self.chemin_voyage(r.q("v"))
cfg = self.cfg_voyage(chemin)
recit_f = os.path.join(chemin, "voyage.md")
recit = open(recit_f, encoding="utf-8").read() if os.path.exists(recit_f) else ""
return page("voyage.html", **self.contexte(
v=self.fiche_voyage(chemin), cfg=cfg, recit=recit,
sources=self.etat_sources(chemin, cfg)))
def voyage_infos(self, r):
chemin = self.chemin_voyage(r.f("v"))
self.toml_voyage(chemin, {"titre": r.f("titre").strip() or None, "moto": r.f("moto").strip() or None})
return Reponse('<span class="ok">✓ Enregistré. Les pages seront à jour à la prochaine génération.</span>')
def voyage_recit(self, r):
chemin = self.chemin_voyage(r.f("v"))
texte = r.f("recit").replace("\r\n", "\n")
f = os.path.join(chemin, "voyage.md")
if texte.strip() or os.path.exists(f):
with open(f, "w", encoding="utf-8") as fh:
fh.write(texte)
return Reponse('<span class="ok">✓ Récit enregistré.</span>')
def etat_sources(self, chemin, cfg):
res = {}
for cle, (libelle, ext, _) in CLES_SOURCES.items():
lignes = []
for s in cfg["sources"].get(cle, []):
p = os.path.join(chemin, os.path.expanduser(s))
fichiers = lister_fichiers([s], ext, chemin) if os.path.exists(p) else []
lignes.append({"source": s, "absolu": os.path.abspath(p), "existe": os.path.exists(p),
"lien_casse": os.path.islink(p) and not os.path.exists(p),
"nb": len(fichiers)})
res[cle] = {"libelle": libelle, "lignes": lignes}
return res
def voyage_sources(self, r):
chemin = self.chemin_voyage(r.f("v"))
cle = r.f("cle")
if cle not in CLES_SOURCES:
raise Erreur(400, "Type de source inconnu")
cfg = self.cfg_voyage(chemin)
liste = list(cfg["sources"].get(cle, []))
source = r.f("chemin").strip()
if r.f("action") == "ajouter" and source:
source = os.path.abspath(os.path.expanduser(source))
if not os.path.exists(source):
raise Erreur(400, f"Introuvable : {source}")
# Dans le dossier du voyage : chemin relatif (le site reste déplaçable).
if os.path.realpath(source).startswith(os.path.realpath(chemin) + os.sep):
source = os.path.relpath(os.path.realpath(source), os.path.realpath(chemin))
if source not in liste:
liste.append(source)
elif r.f("action") == "retirer":
liste = [s for s in liste if s != source]
self.toml_voyage(chemin, {"sources": {cle: liste}})
cfg = self.cfg_voyage(chemin)
rel = r.f("v")
return Reponse(ENV.get_template("_sources.html").render(
v={"chemin": rel}, sources=self.etat_sources(chemin, cfg)),
entetes={"HX-Trigger": "sources-modifiees"})
def _empreinte(self, chemin, cfg):
"""Ce dont dépend l'analyse : si rien n'a changé, on la réutilise."""
def etat(f):
try:
st = os.stat(f)
return (f, st.st_mtime, st.st_size)
except OSError:
return (f, None, None)
fichiers = lister_fichiers(cfg["sources"].get("traces", []), (".gpx",), chemin)
autres = [os.path.join(chemin, n) for n in (VOYAGE_TOML, "export_photos.csv", "export_videos.csv")]
autres.append(os.path.join(self.racine, SITE_TOML))
photos = cfg["sources"].get("photos", [])
return tuple(etat(f) for f in fichiers + autres) + (tuple(photos),)
def analyse(self, chemin):
cfg = self.cfg_voyage(chemin)
cle = self._empreinte(chemin, cfg)
if self._cache.get(chemin, (None,))[0] == cle:
return self._cache[chemin][1]
res = self._analyse(chemin, cfg)
self._cache[chemin] = (cle, res)
return res
def _analyse(self, chemin, cfg):
a = sources.analyser(chemin, cfg)
jours = []
for i, j in enumerate(sorted(a.journees)):
noms = sorted({d["nom"] for d in a.details_traces if j in d["jours"]})
jours.append({"date": j, "km": a.km(j), "traces": noms, "couleur": COULEURS[i % len(COULEURS)]})
suggestions = {}
if a.non_datees:
medias = sources.positions_medias(chemin, a)
for f, pistes in a.non_datees.items():
suggestions[os.path.basename(f)] = sources.suggerer_jour(pistes, medias)
return cfg, a, jours, suggestions
def voyage_analyse(self, r):
chemin = self.chemin_voyage(r.q("v"))
cfg, a, jours, suggestions = self.analyse(chemin)
total = sum(j["km"] for j in jours)
return page("_analyse.html", v={"chemin": r.q("v")}, a=a, jours=jours, total_km=total,
suggestions=suggestions, cfg=cfg, couches=_couches(a, jours),
non_datees=sorted(os.path.basename(f) for f in a.non_datees))
def voyage_date(self, r):
chemin = self.chemin_voyage(r.f("v"))
nom, jour = r.f("fichier"), r.f("jour").strip()
if jour and not sources.jour_texte(jour):
raise Erreur(400, f"Date invalide : {jour}")
self.toml_voyage(chemin, {"dates_traces": {nom: jour or None}})
return self._rafraichir_analyse(r.f("v"))
def voyage_dates_suite(self, r):
chemin = self.chemin_voyage(r.f("v"))
debut = sources.jour_texte(r.f("debut"))
if not debut:
raise Erreur(400, "Indiquez le premier jour")
_, a, _, _ = self.analyse(chemin)
# Toutes les traces qui ne se datent pas seules, même déjà rattachées.
noms = sorted(d["nom"] for d in a.details_traces if not d["erreur"] and not d["datable"])
self.toml_voyage(chemin, {"dates_traces": {n: (debut + timedelta(days=i)).isoformat()
for i, n in enumerate(noms)}})
return self._rafraichir_analyse(r.f("v"))
def _rafraichir_analyse(self, rel):
return self.voyage_analyse(Requete({"v": rel}, {}))
def voyage_infos_utiles(self, r):
chemin = self.chemin_voyage(r.q("v"))
cfg = self.cfg_voyage(chemin)
a = sources.analyser(chemin, cfg, leger=True)
def taille_dossier(d):
d = os.path.join(chemin, d)
if not os.path.isdir(d):
return 0
return sum(e.stat().st_size for e in os.scandir(d) if e.is_file() and not est_cache(e.name))
rushs = sum(os.path.getsize(f) for f in a.videos if os.path.exists(f))
infos = {
"rushs": rushs, "nb_rushs": len(a.videos),
"videos_web": taille_dossier("videos"), "photos": taille_dossier("photos"),
"photos_web": taille_dossier("photos_web"), "apercus": taille_dossier("apercus"),
"libre": shutil.disk_usage(chemin).free,
"encodage": parametres_video(cfg),
}
reste = [f for f in a.videos if not video.deja_faite(chemin, f, rapide=True)]
infos["a_encoder"] = len(reste)
infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f))
return page("_infos_utiles.html", i=infos, cfg=cfg)
# --- Navigateur de dossiers
def dossiers(self, r):
chemin = r.q("chemin") or os.path.expanduser("~")
chemin = os.path.abspath(os.path.expanduser(chemin))
if not os.path.isdir(chemin):
chemin = os.path.dirname(chemin)
cle = r.q("cle")
ext = CLES_SOURCES.get(cle, ("", (), ""))[1]
sous, nb = [], 0
try:
for e in sorted(os.scandir(chemin), key=lambda e: e.name.lower()):
if est_cache(e.name):
continue
if e.is_dir():
sous.append(e.name)
elif ext and e.name.lower().endswith(ext):
nb += 1
except PermissionError:
pass
raccourcis = [("Dossier personnel", os.path.expanduser("~")), ("Site", self.racine)]
if r.q("v"):
raccourcis.append(("Ce voyage", os.path.join(self.racine, r.q("v"))))
if platform.system() == "Darwin" and os.path.isdir("/Volumes"):
raccourcis += [(d, os.path.join("/Volumes", d)) for d in sorted(os.listdir("/Volumes"))
if not d.startswith(".")]
elif platform.system() == "Windows":
raccourcis += [(f"{l}:", f"{l}:\\") for l in string.ascii_uppercase if os.path.exists(f"{l}:\\")]
else:
for base in ("/media", "/mnt", f"/media/{os.environ.get('USER', '')}"):
if os.path.isdir(base):
raccourcis += [(d, os.path.join(base, d)) for d in sorted(os.listdir(base))
if os.path.isdir(os.path.join(base, d)) and not d.startswith(".")]
parent = os.path.dirname(chemin)
return page("_dossiers.html", chemin=chemin, parent=parent if parent != chemin else None,
sous=sous, nb=nb, cle=cle, libelle=CLES_SOURCES.get(cle, ("", (), "fichiers"))[2],
v=r.q("v"), raccourcis=raccourcis, sep=os.sep)
# --- Réglages du site
def reglages(self, r):
return page("reglages.html", **self.contexte(cfg=reglages_site(self.racine)))
def reglages_enregistrer(self, r):
def entier(k, defaut):
try:
return int(r.f(k))
except ValueError:
return defaut
cfg = reglages_site(self.racine)
liens = [l.strip() for l in r.f("mymaps").splitlines() if l.strip()]
modifier_toml(os.path.join(self.racine, SITE_TOML), {
"titre": r.f("titre").strip() or cfg["titre"],
"adresse_publique": r.f("adresse_publique").strip(),
"accueil_nb_recents": entier("accueil_nb_recents", 2),
"video": {"qualite": r.f("qualite") if r.f("qualite") in QUALITES else "equilibree",
"encodeur": "materiel" if r.f("encodeur") == "materiel" else "processeur",
"preset": r.f("preset") or "medium", "jobs": entier("jobs", 0)},
"photos": {"grand_cote": entier("grand_cote", 1600), "qualite": entier("qualite_photos", 80),
"positions_deduites": r.f("positions_deduites") == "on"},
"journees": {"fuseau": r.f("fuseau").strip() or "Europe/Paris",
"coupure_heure": entier("coupure_heure", 4)},
"carte_globale": {"mymaps": liens},
}, entete="Réglages du site RoadTripsGenerator (rtg).")
return Reponse('<span class="ok">✓ Réglages enregistrés.</span>')
def systeme(self, r):
from rtg.verifier import verifier
tampon = io.StringIO()
with redirect_stdout(tampon):
ok = verifier()
libre = shutil.disk_usage(self.racine)
return page("_systeme.html", lignes=tampon.getvalue().splitlines(), ok=ok,
libre=libre.free, total=libre.total, racine=self.racine)
# --- Traitements
def tache_lancer(self, r):
quoi, rel = r.f("quoi"), r.f("v")
compression = r.f("sans_compression") != "on"
force = r.f("force") == "on"
racine = self.racine
if quoi in ("generer", "compresser"):
chemin = self.chemin_voyage(rel)
if quoi == "generer":
libelle = f"Génération de {rel}" + ("" if compression else " (sans les vidéos)")
def fn(suivi):
return site.generer(racine, [chemin], compression=compression, force=force, suivi=suivi)
elif quoi == "compresser":
libelle = f"Compression des vidéos de {rel}"
def fn(suivi):
cfg = reglages_voyage(chemin, reglages_site(racine))
suivi({"etape": "Vidéos", "voyage": rel})
return video.compresser(chemin, cfg, sources.analyser(chemin, cfg), force=force,
progression=lambda n, e, p, rg: suivi(
{"video": n, "etat": e, "pct": p, "rang": rg}))
elif quoi == "tout":
libelle = "Génération de tout le site" + ("" if compression else " (sans les vidéos)")
def fn(suivi):
return site.generer(racine, None, compression=compression, force=force, suivi=suivi)
elif quoi == "accueil":
libelle = "Page d'accueil et carte globale"
def fn(suivi):
suivi({"etape": "Accueil"})
accueil.generer(racine, reglages_site(racine), r.f("mymaps_maj") == "on")
else:
raise Erreur(400, "Traitement inconnu")
t = taches.lancer(libelle, fn, rel if quoi in ("generer", "compresser") else "")
message = None if t else "Un traitement est déjà en cours : attendez qu'il se termine ou arrêtez-le."
return page("_tache.html", tache=taches.COURANTE, message=message)
def tache_arreter(self, r):
taches.arreter()
return page("_tache.html", tache=taches.COURANTE)
def tache(self, r):
return page("tache.html", **self.contexte())
def tache_fragment(self, r):
t = taches.COURANTE
rep = page("_tache.html", tache=t, complet=r.q("complet") == "1")
if t and not t.en_cours:
# Dernière relève : les autres zones de la page se mettent à jour.
rep.entetes["HX-Trigger"] = "tache-finie"
return rep
def tache_bandeau(self, r):
return page("_bandeau.html", tache=taches.COURANTE)
# --- HTTP ----------------------------------------------------------------------
def _couches(a, jours, maxi=1500):
"""Traces de chaque journée pour la carte (au plus `maxi` points par jour)."""
def coords(pistes):
segments = [s.points for t in pistes for s in t.segments if len(s.points) > 1]
pas = max(1, sum(len(s) for s in segments) // maxi)
res = []
for pts in segments:
seg = [[round(p.latitude, 5), round(p.longitude, 5)] for p in pts[::pas]]
if (len(pts) - 1) % pas:
seg.append([round(pts[-1].latitude, 5), round(pts[-1].longitude, 5)])
res.append(seg)
return res
couches = [{"nom": _date_longue(j["date"]), "km": round(j["km"]), "couleur": j["couleur"],
"segments": coords(a.journees[j["date"]])} for j in jours]
for f, pistes in a.non_datees.items():
couches.append({"nom": f"{os.path.basename(f)} (sans jour)", "couleur": "#555",
"pointille": True, "km": 0, "segments": coords(pistes)})
return couches
def _valeur(d, k, defaut):
v = d.get(k, defaut)
if isinstance(v, list):
return v[0] if v else defaut
return v
class Requete:
def __init__(self, query, form):
self.query, self.form = query, form
def q(self, k, defaut=""):
return _valeur(self.query, k, defaut)
def f(self, k, defaut=""):
return _valeur(self.form, k, defaut)
def creer_gestionnaire(app, port):
hotes = {f"127.0.0.1:{port}", f"localhost:{port}"}
class Gestionnaire(BaseHTTPRequestHandler):
server_version = f"rtg/{__version__}"
def log_message(self, fmt, *args):
pass # le terminal sert au journal des traitements
def do_GET(self):
self.traiter("GET")
def do_HEAD(self):
self.traiter("GET", tete=True)
def do_POST(self):
self.traiter("POST")
def traiter(self, methode, tete=False):
try:
if self.headers.get("Host") not in hotes:
raise Erreur(403, "Hôte refusé")
url = urlsplit(self.path)
chemin = unquote(url.path)
if methode == "GET" and chemin.startswith("/static/"):
return self.fichier(STATIC, chemin[len("/static/"):], tete)
if methode == "GET" and chemin.startswith("/site/"):
return self.fichier(app.racine, chemin[len("/site/"):], tete)
form = {}
if methode == "POST":
if self.headers.get("HX-Request") != "true":
raise Erreur(403, "Requête refusée")
n = int(self.headers.get("Content-Length") or 0)
form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True)
req = Requete(parse_qs(url.query, keep_blank_values=True), form)
for m, motif, fn in app.routes:
if m == methode and motif.match(chemin):
rep = fn(req)
break
else:
raise Erreur(404, "Page introuvable")
except Erreur as e:
rep = Reponse(f'<div class="erreur">{_echapper(e.message)}</div>', code=e.code)
if self.headers.get("HX-Request") == "true":
# htmx n'affiche pas les réponses en erreur : on renvoie 200
# vers la zone de messages.
rep.code = 200
rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
except Exception as e:
import traceback
traceback.print_exc()
rep = Reponse(f'<div class="erreur">Erreur : {_echapper(str(e))}</div>', code=500)
if self.headers.get("HX-Request") == "true":
rep.code = 200
rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
self.envoyer(rep, tete)
def envoyer(self, rep, tete=False):
self.send_response(rep.code)
self.send_header("Content-Type", rep.type)
self.send_header("Content-Length", str(len(rep.corps)))
self.send_header("Cache-Control", "no-store")
for k, v in rep.entetes.items():
self.send_header(k, v)
self.end_headers()
if not tete:
self.wfile.write(rep.corps)
def fichier(self, base, rel, tete):
"""Fichier statique, avec les requêtes partielles (Range) dont
Safari a besoin pour lire les vidéos."""
base = os.path.realpath(base)
chemin = os.path.realpath(os.path.join(base, rel))
if chemin != base and not chemin.startswith(base + os.sep):
raise Erreur(403, "Chemin refusé")
if os.path.isdir(chemin):
if not self.path.split("?")[0].endswith("/"):
self.send_response(301)
self.send_header("Location", self.path.split("?")[0] + "/")
self.end_headers()
return
chemin = os.path.join(chemin, "index.html")
if not os.path.isfile(chemin):
return self.envoyer(Reponse("Fichier introuvable", "text/plain; charset=utf-8", 404), tete)
taille = os.path.getsize(chemin)
type_ = mimetypes.guess_type(chemin)[0] or "application/octet-stream"
if type_.startswith("text/") or type_ in ("application/javascript", "application/json"):
type_ += "; charset=utf-8"
debut, fin, code = 0, taille - 1, 200
m = re.match(r"bytes=(\d*)-(\d*)$", self.headers.get("Range", ""))
if m and taille:
if m.group(1):
debut = int(m.group(1))
fin = min(int(m.group(2)), taille - 1) if m.group(2) else taille - 1
else:
debut = max(0, taille - int(m.group(2) or 0))
if debut > fin:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{taille}")
self.end_headers()
return
code = 206
self.send_response(code)
self.send_header("Content-Type", type_)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(fin - debut + 1 if taille else 0))
if code == 206:
self.send_header("Content-Range", f"bytes {debut}-{fin}/{taille}")
self.end_headers()
if tete or not taille:
return
try:
with open(chemin, "rb") as f:
f.seek(debut)
reste = fin - debut + 1
while reste > 0:
bloc = f.read(min(1 << 20, reste))
if not bloc:
break
self.wfile.write(bloc)
reste -= len(bloc)
except (BrokenPipeError, ConnectionResetError):
pass # le navigateur a interrompu (vidéo)
return Gestionnaire
def _echapper(s):
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def lancer(racine, port=8765, ouvrir=True):
app = Application(racine)
serveur = None
for p in range(port, port + 20):
try:
serveur = ThreadingHTTPServer(("127.0.0.1", p), creer_gestionnaire(app, p))
port = p
break
except OSError:
continue
if serveur is None:
raise SystemExit(f"❌ Aucun port libre entre {port} et {port + 19}.")
serveur.daemon_threads = True
url = f"http://127.0.0.1:{port}/"
print(f"🌐 Interface RTG : {url}")
print(f" Site : {app.racine}")
print(" (Ctrl+C pour arrêter)")
if ouvrir:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try:
serveur.serve_forever()
except KeyboardInterrupt:
taches.arreter()
print("\nInterface arrêtée.")
finally:
serveur.server_close()
+32
View File
@@ -0,0 +1,32 @@
Bibliothèques tierces fournies avec l'interface de RTG
htmx 1.9.12 (htmx.min.js) — https://htmx.org — licence Zero-Clause BSD
Leaflet 1.9.4 (leaflet.js, leaflet.css, images/) — https://leafletjs.com
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+1
View File
@@ -0,0 +1 @@
(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/<body/.test(e)}function l(e){var t=!N(e);var r=A(e);var n=e;if(r==="head"){n=n.replace(S,"")}if(Q.config.useTemplateFragments&&t){var i=s("<body><template>"+n+"</template></body>",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s("<table>"+n+"</table>",1);case"col":return s("<table><colgroup>"+n+"</colgroup></table>",2);case"tr":return s("<table><tbody>"+n+"</tbody></table>",2);case"td":case"th":return s("<table><tbody><tr>"+n+"</tr></tbody></table>",3);case"script":case"style":return s("<div>"+n+"</div>",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r<e.length;r++){t.push(e[r])}}return t}function oe(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function X(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=0}function se(e){ifLine truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
+6
View File
@@ -0,0 +1,6 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
//# sourceMappingURL=leaflet.js.map
+147
View File
@@ -0,0 +1,147 @@
:root {
--fond: #f6f5f2; --carte: #ffffff; --texte: #1d1d1f; --discret: #6b6b70; --bord: #dedcd6;
--accent: #c2410c; --accent-texte: #ffffff; --ok: #15803d; --ok-fond: #e7f6ec;
--attention: #a16207; --attention-fond: #fdf4dc; --echec: #b91c1c; --echec-fond: #fde8e8;
--neutre-fond: #eeede9; --code: #f0efea;
}
@media (prefers-color-scheme: dark) {
:root {
--fond: #17171a; --carte: #222226; --texte: #ececef; --discret: #9a9aa2; --bord: #36363c;
--accent: #f97316; --accent-texte: #1a1a1a; --ok: #4ade80; --ok-fond: #183324;
--attention: #facc15; --attention-fond: #3a3115; --echec: #f87171; --echec-fond: #3b1c1c;
--neutre-fond: #2c2c31; --code: #2c2c31;
}
}
* { box-sizing: border-box; }
body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--fond); color: var(--texte); }
a { color: var(--accent); }
code { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--code);
padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
h1 { font-size: 26px; margin: 4px 0; }
h2 { font-size: 19px; margin: 28px 0 10px; }
h3 { font-size: 16px; margin: 16px 0 8px; }
.barre { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; padding: 10px 24px;
background: var(--carte); border-bottom: 1px solid var(--bord); position: sticky; top: 0; z-index: 1000; }
.logo { font-weight: 800; font-size: 20px; letter-spacing: 1px; text-decoration: none; color: var(--accent); }
.barre nav { display: flex; gap: 16px; flex-wrap: wrap; }
.barre nav a { color: var(--texte); text-decoration: none; }
.barre nav a:hover { color: var(--accent); }
#bandeau { margin-left: auto; font-size: 13px; }
.bandeau { text-decoration: none; padding: 4px 10px; border-radius: 99px; background: var(--neutre-fond); color: var(--texte); }
.bandeau.en-cours { background: var(--attention-fond); }
.bandeau.ok { background: var(--ok-fond); }
.bandeau.echec { background: var(--echec-fond); }
main { max-width: 1100px; margin: 0 auto; padding: 20px 24px 60px; }
footer { text-align: center; color: var(--discret); font-size: 12px; padding: 20px; }
.entete-page { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.actions form { margin: 0; }
.fil { margin: 0; font-size: 13px; color: var(--discret); }
.fil a { color: var(--discret); }
.compte { font-size: 13px; font-weight: normal; color: var(--discret); background: var(--neutre-fond);
padding: 1px 8px; border-radius: 99px; vertical-align: middle; }
.discret { color: var(--discret); font-size: 13px; }
.aide { color: var(--discret); font-size: 13px; }
.vide { color: var(--discret); font-style: italic; }
.chargement { color: var(--discret); }
.chargement::before { content: "⏳ "; }
.ok { color: var(--ok); }
.droite { text-align: right; }
button, .bouton { font: inherit; font-size: 14px; padding: 6px 14px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--bord); background: var(--carte); color: var(--texte); text-decoration: none;
display: inline-block; }
button:hover, .bouton:hover { border-color: var(--accent); }
button.principal, .bouton.principal { background: var(--accent); border-color: var(--accent); color: var(--accent-texte); }
button.danger { color: var(--echec); border-color: var(--echec); }
button.petit { font-size: 12px; padding: 2px 8px; }
button.htmx-request { opacity: .6; cursor: progress; }
input, select, textarea { font: inherit; padding: 6px 8px; border: 1px solid var(--bord); border-radius: 6px;
background: var(--carte); color: var(--texte); max-width: 100%; }
textarea { width: 100%; font: 13px/1.5 ui-monospace, Menlo, monospace; }
table { width: 100%; border-collapse: collapse; background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; overflow: hidden; }
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--bord); vertical-align: top; }
th { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--discret); font-weight: 600; }
tr:last-child td { border-bottom: none; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.pastille { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 99px; white-space: nowrap; background: var(--neutre-fond); }
.pastille.ok { background: var(--ok-fond); color: var(--ok); }
.pastille.attention { background: var(--attention-fond); color: var(--attention); }
.puce { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; }
.onglets { display: flex; gap: 4px; border-bottom: 1px solid var(--bord); margin: 18px 0 16px; overflow-x: auto; }
.onglets a { padding: 8px 14px; text-decoration: none; color: var(--discret); border-bottom: 2px solid transparent; white-space: nowrap; }
.onglets a.actif { color: var(--texte); border-color: var(--accent); font-weight: 600; }
.onglet { display: none; }
.onglet.actif { display: block; }
.encart { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 12px 16px; margin: 16px 0; }
.encart.attention { background: var(--attention-fond); border-color: transparent; }
.encart h3 { margin-top: 4px; }
.encart table { background: transparent; border: none; }
.erreur { background: var(--echec-fond); color: var(--echec); padding: 8px 12px; border-radius: 8px; margin: 8px 0; }
#messages { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 3000; max-width: 90vw; }
#messages:empty { display: none; }
.formulaire { max-width: 640px; }
.formulaire.large { max-width: none; }
.formulaire label { display: block; margin: 12px 0; font-weight: 500; }
.formulaire label > input:not([type=radio]):not([type=checkbox]), .formulaire label > select { display: block; width: 100%; margin-top: 4px; }
.formulaire label > .aide { display: block; font-weight: normal; }
.formulaire label.choix { font-weight: normal; }
.formulaire label.choix .aide { display: inline; }
fieldset { border: 1px solid var(--bord); border-radius: 10px; margin: 16px 0; padding: 4px 16px 10px; background: var(--carte); }
legend { font-weight: 600; padding: 0 6px; }
form.en-ligne { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 4px 0; }
.carte { height: 420px; border-radius: 10px; border: 1px solid var(--bord); margin-bottom: 12px; }
details { margin: 16px 0; }
summary { cursor: pointer; color: var(--accent); }
.sources { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
.bloc-source { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 4px 16px 14px; }
.bloc-source ul { list-style: none; padding: 0; margin: 0 0 10px; }
.bloc-source li { padding: 6px 0; border-bottom: 1px dashed var(--bord); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.bloc-source li code { flex: 1 1 100%; }
.bloc-source li.manque code { text-decoration: line-through; opacity: .7; }
.bloc-source .ajout form input[name=chemin] { flex: 1; min-width: 160px; }
.fenetre-fond { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 2000; display: flex; align-items: center; justify-content: center; padding: 16px; }
.fenetre { background: var(--carte); border-radius: 12px; padding: 16px 20px; width: min(640px, 100%); max-height: 85vh; display: flex; flex-direction: column; }
.fenetre h3 { margin-top: 0; }
.raccourcis { display: flex; gap: 6px; flex-wrap: wrap; }
.liste-dossiers { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; border: 1px solid var(--bord); border-radius: 8px; min-height: 120px; }
.liste-dossiers li a { display: block; padding: 6px 10px; text-decoration: none; color: var(--texte); }
.liste-dossiers li a:hover { background: var(--neutre-fond); }
.liste-dossiers li.vide { padding: 6px 10px; }
.fenetre .pied { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.infos { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-bottom: 12px; }
.infos > div { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 8px 12px; }
.etiquette { display: block; font-size: 12px; color: var(--discret); text-transform: uppercase; letter-spacing: .4px; }
.tache { background: var(--carte); border: 1px solid var(--bord); border-left: 4px solid var(--attention); border-radius: 10px; padding: 10px 14px; margin: 14px 0; }
.tache.ok { border-left-color: var(--ok); }
.tache.echec { border-left-color: var(--echec); }
.tache-tete { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.tache-tete .danger { margin-left: auto; }
.video-cours { margin: 8px 0; font-size: 14px; }
progress { width: 100%; height: 8px; accent-color: var(--accent); }
progress.global { height: 4px; opacity: .7; }
.journal { background: #111; color: #ddd; font: 12px/1.45 ui-monospace, Menlo, monospace; padding: 10px; border-radius: 8px;
max-height: 420px; overflow: auto; white-space: pre-wrap; margin: 8px 0 0; }
.journal.court { max-height: none; }
@media (max-width: 700px) {
main { padding: 14px 16px 40px; }
.barre { padding: 10px 16px; gap: 10px; }
#bandeau { margin-left: 0; }
table.voyages th:nth-child(3), table.voyages td:nth-child(3) { display: none; }
th, td { padding: 6px 8px; }
.carte { height: 300px; }
}
+92
View File
@@ -0,0 +1,92 @@
// Onglets, cartes Leaflet, fenêtre de choix de dossier, journal des traitements.
(function () {
"use strict";
// --- Onglets (#journees, #sources…) --------------------------------------
function afficherOnglet() {
const liens = document.querySelectorAll(".onglets a");
if (!liens.length) return;
const nom = (location.hash || liens[0].getAttribute("href")).slice(1);
let trouve = false;
liens.forEach(a => {
const actif = a.getAttribute("href") === "#" + nom;
a.classList.toggle("actif", actif);
trouve = trouve || actif;
});
if (!trouve) { liens[0].classList.add("actif"); }
document.querySelectorAll(".onglet").forEach(s => {
s.classList.toggle("actif", s.id === "onglet-" + (trouve ? nom : liens[0].getAttribute("href").slice(1)));
});
document.querySelectorAll(".onglet.actif .carte").forEach(cadrer);
}
window.addEventListener("hashchange", afficherOnglet);
document.addEventListener("DOMContentLoaded", afficherOnglet);
// --- Cartes -----------------------------------------------------------------
// Une carte préparée dans un onglet caché n'a pas de taille : on la recadre
// la première fois qu'elle devient visible.
function cadrer(el) {
if (!el._carte || !el.offsetWidth) return;
el._carte.invalidateSize();
if (!el._cadree) {
el._cadree = true;
if (el._bornes) el._carte.fitBounds(el._bornes, { padding: [20, 20] });
else el._carte.setView([46.5, 2.5], 5);
}
}
function initCartes(racine) {
if (!window.L) return;
racine.querySelectorAll(".carte[data-couches]").forEach(el => {
if (el._carte) return;
const carte = L.map(el, { scrollWheelZoom: false });
el._carte = carte;
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18, attribution: "© OpenStreetMap"
}).addTo(carte);
const groupe = L.featureGroup().addTo(carte);
JSON.parse(el.dataset.couches).forEach(c => {
c.segments.forEach(s => {
L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, dashArray: c.pointille ? "6 8" : null })
.bindTooltip(c.nom + (c.km ? " · " + c.km + " km" : ""), { sticky: true })
.addTo(groupe);
});
});
el._bornes = groupe.getLayers().length ? groupe.getBounds() : null;
carte.setView([46.5, 2.5], 5);
cadrer(el);
// La molette ne zoome qu'après un clic dans la carte (pas en faisant défiler la page).
carte.on("click", () => carte.scrollWheelZoom.enable());
carte.on("mouseout", () => carte.scrollWheelZoom.disable());
});
}
// --- Après chaque échange htmx --------------------------------------------
document.addEventListener("htmx:afterSettle", e => {
initCartes(e.detail.elt.parentElement || document);
document.querySelectorAll(".journal").forEach(j => { j.scrollTop = j.scrollHeight; });
});
document.addEventListener("DOMContentLoaded", () => {
initCartes(document);
document.querySelectorAll(".journal").forEach(j => { j.scrollTop = j.scrollHeight; });
});
// --- Fenêtre (choix de dossier) -------------------------------------------
function fermer() { document.getElementById("fenetre").innerHTML = ""; }
document.addEventListener("click", e => {
if (e.target.matches("[data-fermer-fond]")) fermer();
if (e.target.matches("[data-fermer]") && !e.target.hasAttribute("hx-post")) fermer();
});
document.addEventListener("htmx:afterRequest", e => {
if (e.detail.elt.matches && e.detail.elt.matches("[data-fermer]") && e.detail.successful) fermer();
});
document.addEventListener("keydown", e => { if (e.key === "Escape") fermer(); });
// Les messages (erreurs) s'effacent d'eux-mêmes.
document.addEventListener("htmx:afterSwap", e => {
if (e.detail.target.id === "messages") {
clearTimeout(window._rtgMessage);
window._rtgMessage = setTimeout(() => { e.detail.target.innerHTML = ""; }, 6000);
}
});
})();
+125
View File
@@ -0,0 +1,125 @@
"""Un traitement à la fois (génération, compression…) dans un fil à part.
Tout ce que la chaîne affiche est recopié dans le journal de la tâche, en
plus du terminal ; l'interface relit ce journal et l'avancement chaque seconde.
"""
import sys
import threading
import time
import traceback
from collections import deque
from rtg.outils import ARRET, Interruption
_verrou = threading.Lock()
COURANTE = None
class Tache:
def __init__(self, libelle, voyage=""):
self.libelle = libelle
self.voyage = voyage # chemin relatif, "" pour tout le site
self.lignes = deque(maxlen=3000)
self.etape = ""
self.etape_voyage = "" # voyage en cours quand tout le site est généré
self.video = None # {"nom", "pct", "rang": (i, n)}
self.debut = time.time()
self.fin = None
self.ok = None
self.arretee = False
@property
def en_cours(self):
return self.fin is None
@property
def duree(self):
s = int((self.fin or time.time()) - self.debut)
return f"{s // 60} min {s % 60:02d} s" if s >= 60 else f"{s} s"
def suivi(self, ev):
if "etape" in ev:
self.etape = ev["etape"]
self.etape_voyage = ev.get("voyage", "") if ev.get("voyage") != self.voyage else ""
if ev["etape"] != "Vidéos":
self.video = None
elif "video" in ev:
if ev["etat"] == "en_cours":
rang = ev.get("rang") or (self.video or {}).get("rang")
self.video = {"nom": ev["video"], "pct": ev["pct"] or 0, "rang": rang}
elif ev.get("rang"):
self.video = {"nom": ev["video"], "pct": 100, "rang": ev["rang"]}
class _Recopie:
"""Remplace sys.stdout pendant une tâche : écrit dans le terminal et dans
le journal de la tâche (ligne par ligne ; un « \\r » remplace la ligne)."""
def __init__(self, original, tache):
self.original, self.tache, self.tampon = original, tache, ""
def write(self, s):
self.original.write(s)
self.tampon += s
while "\n" in self.tampon:
ligne, self.tampon = self.tampon.split("\n", 1)
self.tache.lignes.append(ligne.rsplit("\r", 1)[-1])
return len(s)
def flush(self):
self.original.flush()
def isatty(self):
return False
def __getattr__(self, nom):
return getattr(self.original, nom)
def lancer(libelle, fonction, voyage=""):
"""Démarre `fonction(suivi)` dans un fil. Renvoie la tâche, ou None si une
autre tâche tourne encore."""
global COURANTE
with _verrou:
if COURANTE and COURANTE.en_cours:
return None
t = Tache(libelle, voyage)
COURANTE = t
ARRET.clear()
def executer():
original = sys.stdout
sys.stdout = _Recopie(original, t)
try:
res = fonction(t.suivi)
t.ok = res is not False
except Interruption:
t.ok, t.arretee = False, True
print("⏹ Traitement arrêté.")
except SystemExit as e:
t.ok = False
if e.code not in (None, 0):
print(str(e.code))
except Exception:
t.ok = False
print("❌ Erreur inattendue :")
print(traceback.format_exc())
finally:
if ARRET.is_set():
t.arretee = True
sys.stdout.flush()
sys.stdout = original
t.fin = time.time()
t.etape = "Arrêté" if t.arretee else ("Terminé" if t.ok else "Terminé avec des erreurs")
t.video = None
t.etape_voyage = ""
threading.Thread(target=executer, daemon=True, name="rtg-tache").start()
return t
def arreter():
if COURANTE and COURANTE.en_cours:
ARRET.set()
return True
return False