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

+22 -1
View File
@@ -114,6 +114,26 @@ Dans le récit `voyage.md`, deux raccourcis évitent les liens écrits en dur (q
[La descente](media:videos/GH011272.mp4) [La descente](media:videos/GH011272.mp4)
``` ```
## L'interface
```
cd /Volumes/CLEROADTRIPS/Road-Trips
rtg interface
```
Le navigateur s'ouvre sur l'interface de RTG (adresse `http://127.0.0.1:8765/`, visible de cet ordinateur seulement). Laisser la fenêtre de Terminal ouverte pendant l'utilisation ; Ctrl+C l'arrête.
- **Mes voyages** : tous les voyages par rubrique, avec leurs dates et l'avancement des vidéos, des photos et des pages. Le bouton « Nouveau voyage » crée le dossier et son `voyage.toml`.
- **Un voyage**, en quatre onglets :
- *Journées* : la carte des traces, jour par jour, et les kilomètres. Une trace sans heures et sans date dans son nom (un itinéraire préparé, par exemple) se rattache ici à un jour. RTG propose le jour où des photos ou des vidéos ont été prises le long de la trace. On peut aussi répartir les traces à la suite, un jour par trace.
- *Sources* : où sont les traces, les vidéos et les photos d'origine, choisies dans une fenêtre de dossiers ou collées. Le nombre de fichiers trouvés s'affiche, et un disque débranché est signalé.
- *Infos et récit* : titre, moto et récit (`voyage.md`).
- *Générer* : tailles des rushs et de ce qui reste à encoder, espace libre, puis lancement de la génération ou de l'encodage seul. L'avancement de chaque vidéo s'affiche ; « Arrêter » interrompt proprement, sans perdre les vidéos déjà finies.
- **Réglages** : les réglages de `site.toml` (qualité vidéo, taille des photos, fuseau, cartes My Maps) et la vérification des outils installés.
- **Voir le site** : le site généré, servi tel quel, vidéos comprises.
Tout ce que fait l'interface s'écrit dans `site.toml` et les `voyage.toml`. On peut toujours utiliser les commandes ci-dessous ou modifier ces fichiers à la main. Une réécriture par l'interface conserve les valeurs mais pas les commentaires.
## Utilisation : la commande `rtg` ## Utilisation : la commande `rtg`
Depuis le dossier du site (`Road-Trips`) : Depuis le dossier du site (`Road-Trips`) :
@@ -125,6 +145,7 @@ rtg generer Balades/Vexin-10-2026 # un voyage, puis l'accueil
rtg generer --sans-compression # tous les voyages, sans encoder de vidéo rtg generer --sans-compression # tous les voyages, sans encoder de vidéo
rtg analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources rtg analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources
rtg verifier # tout est-il installé ? rtg verifier # tout est-il installé ?
rtg interface # l'interface dans le navigateur
``` ```
Les sources d'un voyage peuvent rester où elles sont, nommées n'importe comment : on les indique dans son `voyage.toml`. Les sources d'un voyage peuvent rester où elles sont, nommées n'importe comment : on les indique dans son `voyage.toml`.
@@ -139,7 +160,7 @@ videos = ["/Volumes/LaCie/Perso/Gopro/Gopro-2026/Vexin"]
photos = ["~/Pictures/Export/Vexin"] photos = ["~/Pictures/Export/Vexin"]
``` ```
RTG range alors lui-même les traces par journée (`routes/AAAA-MM-JJ.gpx`, d'après leurs horodatages ou, à défaut, la date du nom de fichier) et copie les photos dans `photos/` (les HEIC de l'iPhone sont converties en JPEG). Une photo sans GPS reçoit la position de la trace à son heure, quand la trace est horodatée. Les réglages communs (qualité vidéo, taille des photos, fuseau horaire, cartes My Maps…) sont dans `site.toml`. RTG range alors lui-même les traces par journée (`routes/AAAA-MM-JJ.gpx`, d'après leurs horodatages, à défaut la date du nom de fichier, ou le jour choisi dans l'interface et noté dans `[dates_traces]`) et copie les photos dans `photos/` (les HEIC de l'iPhone sont converties en JPEG). Une photo sans GPS reçoit la position de la trace à son heure, quand la trace est horodatée. Les réglages communs (qualité vidéo, taille des photos, fuseau horaire, cartes My Maps…) sont dans `site.toml`.
Les scripts de `Scripts/` restent disponibles pendant la transition ; ils produisent le même site. Ils demandent en plus d'installer à la main les modules Python : `pip3 install pandas folium gpxpy markdown2 numpy pillow`. Les scripts de `Scripts/` restent disponibles pendant la transition ; ils produisent le même site. Ils demandent en plus d'installer à la main les modules Python : `pip3 install pandas folium gpxpy markdown2 numpy pillow`.
+2 -1
View File
@@ -30,7 +30,8 @@ dev = ["pytest>=7"]
rtg = "rtg.cli:main" rtg = "rtg.cli:main"
[tool.setuptools] [tool.setuptools]
packages = ["rtg"] packages = ["rtg", "rtg.interface"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
rtg = ["templates/*"] rtg = ["templates/*"]
"rtg.interface" = ["modeles/*", "static/*", "static/images/*"]
+1 -1
View File
@@ -93,7 +93,7 @@ def fiche(racine, chemin):
quand = date(int(m.group(0)), 1, 1) if m else date.min quand = date(int(m.group(0)), 1, 1) if m else date.min
return { return {
'dossier': chemin, 'dossier': chemin,
'titre': nom.replace('-', ' ').replace('_', ' '), 'titre': reglages_voyage(chemin).get('titre') or nom.replace('-', ' ').replace('_', ' '),
'chemin': quote(os.path.relpath(chemin, racine).replace(os.sep, '/')), 'chemin': quote(os.path.relpath(chemin, racine).replace(os.sep, '/')),
'periode': periode(dates), 'periode': periode(dates),
'moto': reglages_voyage(chemin).get('moto', ''), 'moto': reglages_voyage(chemin).get('moto', ''),
+8
View File
@@ -7,6 +7,7 @@
rtg accueil seulement la page d'accueil et la carte globale rtg accueil seulement la page d'accueil et la carte globale
rtg migrer écrire site.toml et les voyage.toml depuis l'existant rtg migrer écrire site.toml et les voyage.toml depuis l'existant
rtg verifier tout ce dont RTG a besoin est-il installé ? rtg verifier tout ce dont RTG a besoin est-il installé ?
rtg interface ouvrir l'interface dans le navigateur
La racine du site est le premier dossier, en remontant depuis le dossier La racine du site est le premier dossier, en remontant depuis le dossier
courant, qui contient site.toml (sinon le dossier courant) ; --racine la force. courant, qui contient site.toml (sinon le dossier courant) ; --racine la force.
@@ -45,6 +46,10 @@ def main(argv=None):
sp.add_parser("migrer", help="écrire site.toml et voyage.toml depuis l'organisation actuelle") sp.add_parser("migrer", help="écrire site.toml et voyage.toml depuis l'organisation actuelle")
sp.add_parser("verifier", help="contrôler que Python, exiftool et ffmpeg sont bien installés") sp.add_parser("verifier", help="contrôler que Python, exiftool et ffmpeg sont bien installés")
i = sp.add_parser("interface", help="ouvrir l'interface dans le navigateur")
i.add_argument("--port", type=int, default=8765)
i.add_argument("--sans-navigateur", action="store_true", help="ne pas ouvrir le navigateur")
args = p.parse_args(argv) args = p.parse_args(argv)
if args.commande == "verifier": if args.commande == "verifier":
@@ -73,6 +78,9 @@ def main(argv=None):
accueil.generer(racine, reglages_site(racine), args.mymaps_maj) accueil.generer(racine, reglages_site(racine), args.mymaps_maj)
elif args.commande == "migrer": elif args.commande == "migrer":
site.migrer(racine) site.migrer(racine)
elif args.commande == "interface":
from rtg.interface.serveur import lancer
lancer(racine, args.port, ouvrir=not args.sans_navigateur)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nInterrompu.") print("\nInterrompu.")
sys.exit(130) sys.exit(130)
+41 -2
View File
@@ -37,6 +37,7 @@ DEFAUTS = {
"moto": "", "moto": "",
"sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]}, "sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]},
"exclusions": {"fichiers": []}, "exclusions": {"fichiers": []},
"dates_traces": {}, # trace sans heures → jour imposé (AAAA-MM-JJ)
} }
# Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages # Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages
@@ -130,18 +131,56 @@ def _toml_valeur(v):
return f'"{s}"' return f'"{s}"'
def _toml_cle(k):
return k if re.fullmatch(r"[A-Za-z0-9_-]+", k) else _toml_valeur(k)
def ecrire_toml(chemin, donnees, entete=""): def ecrire_toml(chemin, donnees, entete=""):
"""Écrit un TOML simple (valeurs, listes, une profondeur de tables).""" """Écrit un TOML simple (valeurs, listes, une profondeur de tables)."""
lignes = [f"# {l}" for l in entete.splitlines()] + ([""] if entete else []) lignes = [f"# {l}" for l in entete.splitlines()] + ([""] if entete else [])
for k, v in donnees.items(): for k, v in donnees.items():
if not isinstance(v, dict): if not isinstance(v, dict):
lignes.append(f"{k} = {_toml_valeur(v)}") lignes.append(f"{_toml_cle(k)} = {_toml_valeur(v)}")
for k, v in donnees.items(): for k, v in donnees.items():
if isinstance(v, dict): if isinstance(v, dict):
lignes += ["", f"[{k}]"] + [f"{kk} = {_toml_valeur(vv)}" for kk, vv in v.items()] lignes += ["", f"[{_toml_cle(k)}]"] + [f"{_toml_cle(kk)} = {_toml_valeur(vv)}"
for kk, vv in v.items()]
with open(chemin, "w", encoding="utf-8") as f: with open(chemin, "w", encoding="utf-8") as f:
f.write("\n".join(lignes) + "\n") f.write("\n".join(lignes) + "\n")
def modifier_toml(chemin, changements, entete=""):
"""Applique `changements` à un fichier TOML existant (ou nouveau) et le
réécrit. Une valeur None retire la clé ; une table est fusionnée sur un
niveau. Le commentaire d'en-tête est conservé, les autres sont perdus."""
donnees = lire_toml(chemin)
if os.path.exists(chemin):
with open(chemin, encoding="utf-8") as f:
tete = []
for l in f:
if not l.startswith("#"):
break
tete.append(l[1:].strip())
entete = "\n".join(tete) or entete
for k, v in changements.items():
if v is None:
donnees.pop(k, None)
elif isinstance(v, dict):
table = dict(donnees.get(k, {}))
for kk, vv in v.items():
if vv is None:
table.pop(kk, None)
else:
table[kk] = vv
if table:
donnees[k] = table
else:
donnees.pop(k, None)
else:
donnees[k] = v
ecrire_toml(chemin, donnees, entete)
return donnees
def nom_lisible(nom): def nom_lisible(nom):
return re.sub(r"[-_]+", " ", nom).strip() return re.sub(r"[-_]+", " ", nom).strip()
+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
+13
View File
@@ -3,10 +3,23 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading
EXT_PHOTOS = (".jpg", ".jpeg", ".heic", ".heif") EXT_PHOTOS = (".jpg", ".jpeg", ".heic", ".heif")
EXT_VIDEOS = (".mp4", ".mov") EXT_VIDEOS = (".mp4", ".mov")
# Demande d'arrêt d'un traitement en cours (bouton « Arrêter » de l'interface).
ARRET = threading.Event()
class Interruption(Exception):
pass
def verifier_arret():
if ARRET.is_set():
raise Interruption()
def dire(message=""): def dire(message=""):
print(message, flush=True) print(message, flush=True)
+92 -28
View File
@@ -4,7 +4,7 @@ import os
from rtg import accueil, extraction, pages, photos, sources, video from rtg import accueil, extraction, pages, photos, sources, video
from rtg.config import (SITE_TOML, VOYAGE_TOML, ecrire_toml, nom_lisible, from rtg.config import (SITE_TOML, VOYAGE_TOML, ecrire_toml, nom_lisible,
reglages_site, reglages_voyage) reglages_site, reglages_voyage)
from rtg.outils import dire, est_cache from rtg.outils import dire, est_cache, verifier_arret
IGNORES = {"Scripts", "RoadTripsGenerator", "html", "assets", "videos", "photos", "photos_web", IGNORES = {"Scripts", "RoadTripsGenerator", "html", "assets", "videos", "photos", "photos_web",
"apercus", "rushs", "routes"} "apercus", "rushs", "routes"}
@@ -46,19 +46,28 @@ def _sources_disponibles(voyage, liste):
return True return True
def generer_voyage(voyage, racine, site_cfg, compression=True, force=False): def generer_voyage(voyage, racine, site_cfg, compression=True, force=False, suivi=None):
"""Toute la chaîne pour un voyage. `suivi(evenement)` (interface) reçoit
{"etape": …} au début de chaque étape et {"video": …} pendant la compression."""
cfg = reglages_voyage(voyage, site_cfg) cfg = reglages_voyage(voyage, site_cfg)
nom = os.path.relpath(voyage, racine) nom = os.path.relpath(voyage, racine)
dire("=" * 50) dire("=" * 50)
dire(f"▶ {nom}") dire(f"▶ {nom}")
dire("=" * 50) dire("=" * 50)
def etape(libelle):
verifier_arret()
if suivi:
suivi({"etape": libelle, "voyage": nom})
etape("Sources")
a = sources.analyser(voyage, cfg) a = sources.analyser(voyage, cfg)
for m in a.a_verifier: for m in a.a_verifier:
dire(f"⚠️ {m}") dire(f"⚠️ {m}")
for r in sources.ranger(voyage, cfg, a): for r in sources.ranger(voyage, cfg, a):
dire(r) dire(r)
etape("Positions")
extraction.extraire_photos(voyage, cfg, a) extraction.extraire_photos(voyage, cfg, a)
if _sources_disponibles(voyage, cfg["sources"]["videos"]): if _sources_disponibles(voyage, cfg["sources"]["videos"]):
extraction.extraire_videos(voyage, a) extraction.extraire_videos(voyage, a)
@@ -66,17 +75,24 @@ def generer_voyage(voyage, racine, site_cfg, compression=True, force=False):
dire("⏭️ Vidéos sources inaccessibles : positions reprises du dernier export.") dire("⏭️ Vidéos sources inaccessibles : positions reprises du dernier export.")
if compression and a.videos: if compression and a.videos:
video.compresser(voyage, cfg, a, force=force) etape("Vidéos")
progression = None
if suivi:
def progression(fichier, etat, pct, rang):
suivi({"video": fichier, "etat": etat, "pct": pct, "rang": rang})
video.compresser(voyage, cfg, a, force=force, progression=progression)
elif not compression: elif not compression:
dire("⏭️ Compression sautée.") dire("⏭️ Compression sautée.")
etape("Photos")
photos.preparer(voyage, cfg, force=force) photos.preparer(voyage, cfg, force=force)
etape("Pages")
ok = pages.generer(voyage, racine, cfg["adresse_publique"]) ok = pages.generer(voyage, racine, cfg["adresse_publique"])
dire() dire()
return ok return ok
def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False): def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False, suivi=None):
racine = os.path.abspath(racine) racine = os.path.abspath(racine)
cfg = reglages_site(racine) cfg = reglages_site(racine)
liste = [os.path.abspath(v) for v in voyages] if voyages else decouvrir(racine) liste = [os.path.abspath(v) for v in voyages] if voyages else decouvrir(racine)
@@ -84,7 +100,10 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
dire("❌ Aucun voyage trouvé (dossiers avec routes/, photos/, rushs/ ou voyage.toml).") dire("❌ Aucun voyage trouvé (dossiers avec routes/, photos/, rushs/ ou voyage.toml).")
return False return False
dire(f"📁 {len(liste)} voyage(s) : {', '.join(os.path.relpath(v, racine) for v in liste)}\n") dire(f"📁 {len(liste)} voyage(s) : {', '.join(os.path.relpath(v, racine) for v in liste)}\n")
echecs = [v for v in liste if not generer_voyage(v, racine, cfg, compression, force)] echecs = [v for v in liste if not generer_voyage(v, racine, cfg, compression, force, suivi)]
verifier_arret()
if suivi:
suivi({"etape": "Accueil", "voyage": ""})
dire("=" * 50) dire("=" * 50)
dire("▶ Accueil") dire("▶ Accueil")
dire("=" * 50) dire("=" * 50)
@@ -94,32 +113,58 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
return not echecs return not echecs
def etat(racine): def rubrique_de(voyage, racine):
"""Tableau de l'avancement de chaque voyage.""" parent = os.path.dirname(os.path.abspath(voyage))
return "Voyages" if parent == os.path.abspath(racine) else nom_lisible(os.path.basename(parent))
def etat_voyage(v, racine, site_cfg):
"""Avancement d'un voyage (dictionnaire), sans rien lire de lourd."""
cfg = reglages_voyage(v, site_cfg)
a = sources.analyser(v, cfg, leger=True)
dossier = os.path.join(v, "videos")
faites_web = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0
if a.videos:
faites = sum(1 for s in a.videos if video.deja_faite(v, s, rapide=True))
vid = f"{faites}/{len(a.videos)}"
else:
faites = None
vid = f"{faites_web} (sources absentes)" if faites_web else "—"
ph = os.path.join(v, "photos")
nb_ph = len([f for f in os.listdir(ph) if f.lower().endswith((".jpg", ".jpeg"))]) if os.path.isdir(ph) else 0
pw = os.path.join(v, "photos_web")
nb_pw = len([f for f in os.listdir(pw) if not est_cache(f)]) if os.path.isdir(pw) else 0
index = os.path.join(v, "index.html")
genere = os.path.exists(index)
pages_ok = genere and all(
os.path.getmtime(index) >= os.path.getmtime(os.path.join(v, c))
for c in ("export_photos.csv", "export_videos.csv", "voyage.md")
if os.path.exists(os.path.join(v, c)))
jours = sorted(a.journees)
return {
"chemin": os.path.relpath(v, racine).replace(os.sep, "/"), "dossier": v,
"titre": cfg["titre"], "moto": cfg.get("moto", ""), "rubrique": rubrique_de(v, racine),
"debut": jours[0] if jours else None, "fin": jours[-1] if jours else None,
"jours": len(jours), "videos": vid, "videos_sources": len(a.videos),
"videos_faites": faites, "videos_web": faites_web,
"photos": nb_ph, "photos_web": nb_pw, "genere": genere, "pages_a_jour": pages_ok,
"a_verifier": a.a_verifier,
}
def tableau_etat(racine):
racine = os.path.abspath(racine) racine = os.path.abspath(racine)
site_cfg = reglages_site(racine) site_cfg = reglages_site(racine)
return [etat_voyage(v, racine, site_cfg) for v in decouvrir(racine)]
def etat(racine):
"""Tableau de l'avancement de chaque voyage."""
lignes = [("Voyage", "Jours", "Vidéos web", "Photos web", "Pages", "À vérifier")] lignes = [("Voyage", "Jours", "Vidéos web", "Photos web", "Pages", "À vérifier")]
for v in decouvrir(racine): for e in tableau_etat(racine):
cfg = reglages_voyage(v, site_cfg) lignes.append((e["chemin"], str(e["jours"]), e["videos"],
a = sources.analyser(v, cfg, leger=True) f"{e['photos_web']}/{e['photos']}" if e["photos"] else "—",
if a.videos: "à jour" if e["pages_a_jour"] else "à refaire", str(len(e["a_verifier"]))))
faites = sum(1 for s in a.videos if video.deja_faite(v, s, rapide=True))
vid = f"{faites}/{len(a.videos)}"
else:
dossier = os.path.join(v, "videos")
n = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0
vid = f"{n} (sources absentes)" if n else "—"
ph = os.path.join(v, "photos")
nb_ph = len([f for f in os.listdir(ph) if f.lower().endswith((".jpg", ".jpeg"))]) if os.path.isdir(ph) else 0
pw = os.path.join(v, "photos_web")
nb_pw = len([f for f in os.listdir(pw) if not est_cache(f)]) if os.path.isdir(pw) else 0
index = os.path.join(v, "index.html")
pages_ok = "à jour" if os.path.exists(index) and all(
os.path.getmtime(index) >= os.path.getmtime(os.path.join(v, c))
for c in ("export_photos.csv", "export_videos.csv", "voyage.md")
if os.path.exists(os.path.join(v, c))) else "à refaire"
lignes.append((os.path.relpath(v, racine), str(len(a.journees)), vid,
f"{nb_pw}/{nb_ph}" if nb_ph else "—", pages_ok, str(len(a.a_verifier)) or "0"))
largeurs = [max(len(l[i]) for l in lignes) for i in range(len(lignes[0]))] largeurs = [max(len(l[i]) for l in lignes) for i in range(len(lignes[0]))]
for k, l in enumerate(lignes): for k, l in enumerate(lignes):
dire(" ".join(c.ljust(w) for c, w in zip(l, largeurs))) dire(" ".join(c.ljust(w) for c, w in zip(l, largeurs)))
@@ -143,6 +188,25 @@ def analyser(voyage, racine):
dire(f" ⚠️ {m}") dire(f" ⚠️ {m}")
def creer_voyage(racine, dossier, titre, groupe="", moto=""):
"""Nouveau voyage : <racine>/[<groupe>/]<dossier>/voyage.toml, sans source.
Renvoie le chemin du dossier créé."""
for nom in (dossier, groupe):
if nom and (os.sep in nom or "/" in nom or nom.startswith((".", "_")) or nom in IGNORES):
raise ValueError(f"Nom de dossier refusé : {nom}")
chemin = os.path.join(racine, groupe, dossier) if groupe else os.path.join(racine, dossier)
if os.path.exists(chemin):
raise ValueError(f"Le dossier {os.path.relpath(chemin, racine)} existe déjà")
os.makedirs(chemin)
donnees = {"titre": titre or nom_lisible(dossier)}
if moto:
donnees["moto"] = moto
donnees["sources"] = {"traces": [], "videos": [], "photos": []}
ecrire_toml(os.path.join(chemin, VOYAGE_TOML), donnees,
entete="Voyage RoadTripsGenerator : sources, infos et réglages propres.")
return chemin
def migrer(racine): def migrer(racine):
"""Écrit site.toml et un voyage.toml par voyage à partir de l'existant """Écrit site.toml et un voyage.toml par voyage à partir de l'existant
(mymaps.txt, moto.txt). Ne remplace jamais un fichier déjà présent.""" (mymaps.txt, moto.txt). Ne remplace jamais un fichier déjà présent."""
+120 -16
View File
@@ -11,10 +11,14 @@ Les vidéos ne sont pas copiées : la compression lit directement les sources.
Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/ Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/
(organisation historique) n'est pas touché. (organisation historique) n'est pas touché.
""" """
import csv
import math
import os import os
import re import re
import shutil import shutil
from datetime import datetime, timedelta, timezone import subprocess
from collections import Counter
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import gpxpy import gpxpy
@@ -33,6 +37,8 @@ class Analyse:
self.photos = [] # chemins des photos sources self.photos = [] # chemins des photos sources
self.videos = [] # chemins des vidéos sources self.videos = [] # chemins des vidéos sources
self.a_verifier = [] # messages pour l'utilisateur self.a_verifier = [] # messages pour l'utilisateur
self.details_traces = [] # un dict par fichier GPX (pour l'interface)
self.non_datees = {} # fichier → [GPXTrack] des traces sans jour
def km(self, jour): def km(self, jour):
total = 0.0 total = 0.0
@@ -65,21 +71,36 @@ def date_du_nom(nom):
return None return None
def decouper_par_journee(fichier, tz, coupure_h): def jour_texte(valeur):
"""« 2025-08-04 » (ou date TOML) → date, sinon None."""
try:
return valeur if isinstance(valeur, date) else date.fromisoformat(str(valeur).strip())
except ValueError:
return None
def decouper_par_journee(fichier, tz, coupure_h, jour_impose=None, pistes_sans_jour=None, infos=None):
"""Pistes d'un GPX réparties par journée locale. """Pistes d'un GPX réparties par journée locale.
Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est
trouvée. Avec horodatages : date locale dans le fuseau du voyage, avec une trouvée. `jour_impose` (choisi dans l'interface) place tout le fichier sur
coupure à `coupure_h` heures (une étape finie à 1 h reste sur la veille). ce jour. Sinon, avec horodatages : date locale dans le fuseau du voyage,
Sans horodatage : la date du nom de fichier vaut pour tout le fichier. avec une coupure à `coupure_h` heures (une étape finie à 1 h reste sur la
veille) ; sans horodatage : la date du nom de fichier. Les pistes d'un
fichier qu'aucune date ne place sont ajoutées à `pistes_sans_jour`. `infos`
(dictionnaire) reçoit "datable" : le fichier se date-t-il sans aide ?
""" """
with open(fichier, encoding="utf-8") as f: with open(fichier, encoding="utf-8") as f:
gpx = gpxpy.parse(f) gpx = gpxpy.parse(f)
a_des_heures = any(p.time for t in gpx.tracks for s in t.segments for p in s.points) a_des_heures = any(p.time for t in gpx.tracks for s in t.segments for p in s.points)
if infos is not None:
infos["datable"] = a_des_heures or date_du_nom(os.path.basename(fichier)) is not None
res = {} res = {}
if not a_des_heures: if jour_impose or not a_des_heures:
jour = date_du_nom(os.path.basename(fichier)) jour = jour_impose or date_du_nom(os.path.basename(fichier))
if jour is None: if jour is None:
if pistes_sans_jour is not None:
pistes_sans_jour.extend(gpx.tracks)
return {}, "trace sans heures et sans date dans son nom" return {}, "trace sans heures et sans date dans son nom"
for t in gpx.tracks: for t in gpx.tracks:
res.setdefault(jour, []).append(t) res.setdefault(jour, []).append(t)
@@ -120,21 +141,41 @@ def analyser(voyage, cfg, leger=False):
tz = ZoneInfo(cfg["journees"]["fuseau"]) tz = ZoneInfo(cfg["journees"]["fuseau"])
coupure = int(cfg["journees"]["coupure_heure"]) coupure = int(cfg["journees"]["coupure_heure"])
a.traces = [f for f in lister_fichiers(_sources(voyage, "traces", cfg), (".gpx",), voyage) imposes = {k: jour_texte(v) for k, v in cfg.get("dates_traces", {}).items()}
sources_traces = _sources(voyage, "traces", cfg)
a.traces = [f for f in lister_fichiers(sources_traces, (".gpx",), voyage)
if os.path.basename(f) not in exclus] if os.path.basename(f) not in exclus]
if not _est_dossier_du_voyage(voyage, sources_traces, "routes"):
# Les journées que RTG a lui-même écrites dans routes/ ne sont pas des
# sources : les relire les compterait deux fois.
routes = os.path.realpath(os.path.join(voyage, "routes"))
a.traces = [f for f in a.traces
if os.path.realpath(os.path.dirname(f)) != routes or not ecrite_par_rtg(f)]
for f in a.traces: for f in a.traces:
if leger and date_du_nom(os.path.basename(f)): nom = os.path.basename(f)
a.journees.setdefault(date_du_nom(os.path.basename(f)), []) impose = imposes.get(nom)
if leger and (impose or date_du_nom(nom)):
a.journees.setdefault(impose or date_du_nom(nom), [])
continue continue
sans_jour, infos = [], {}
try: try:
parts, raison = decouper_par_journee(f, tz, coupure) parts, raison = decouper_par_journee(f, tz, coupure, impose, sans_jour, infos)
except Exception as e: except Exception as e:
a.a_verifier.append(f"Trace illisible : {os.path.basename(f)} ({e})") a.a_verifier.append(f"Trace illisible : {nom} ({e})")
a.details_traces.append({"fichier": f, "nom": nom, "jours": [], "km": 0,
"impose": impose, "erreur": str(e)})
continue continue
if raison: if raison:
a.a_verifier.append(f"Trace à rattacher à un jour : {os.path.basename(f)} ({raison})") a.a_verifier.append(f"Trace à rattacher à un jour : {nom} ({raison})")
a.non_datees[f] = sans_jour
km = 0.0
for jour, pistes in parts.items(): for jour, pistes in parts.items():
a.journees.setdefault(jour, []).extend(pistes) a.journees.setdefault(jour, []).extend(pistes)
km += sum((p.length_2d() or 0) for p in pistes) / 1000
if sans_jour:
km = sum((p.length_2d() or 0) for p in sans_jour) / 1000
a.details_traces.append({"fichier": f, "nom": nom, "jours": sorted(parts), "km": km,
"impose": impose, "erreur": None, "datable": infos["datable"]})
a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage) a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage)
if os.path.basename(f) not in exclus] if os.path.basename(f) not in exclus]
@@ -155,6 +196,11 @@ def analyser(voyage, cfg, leger=False):
return a return a
def ecrite_par_rtg(fichier):
with open(fichier, encoding="utf-8", errors="replace") as f:
return f'creator="{CREATEUR}"' in f.read(600)
def ranger(voyage, cfg, a): def ranger(voyage, cfg, a):
"""Range les sources dans le dossier du voyage (routes/, photos/). """Range les sources dans le dossier du voyage (routes/, photos/).
Renvoie un résumé des écritures.""" Renvoie un résumé des écritures."""
@@ -187,9 +233,7 @@ def _ecrire_routes(voyage, a):
chemin = os.path.join(dossier, nom) chemin = os.path.join(dossier, nom)
if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"): if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"):
continue continue
with open(chemin, encoding="utf-8", errors="replace") as f: if ecrite_par_rtg(chemin):
debut = f.read(600)
if f'creator="{CREATEUR}"' in debut:
os.remove(chemin) os.remove(chemin)
retires += 1 retires += 1
return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "") return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "")
@@ -200,6 +244,8 @@ def _copier_photos(voyage, a):
os.makedirs(dossier, exist_ok=True) os.makedirs(dossier, exist_ok=True)
copiees = converties = 0 copiees = converties = 0
for src in a.photos: for src in a.photos:
if os.path.realpath(os.path.dirname(src)) == os.path.realpath(dossier):
continue # déjà dans photos/
base, ext = os.path.splitext(os.path.basename(src)) base, ext = os.path.splitext(os.path.basename(src))
heic = ext.lower() in (".heic", ".heif") heic = ext.lower() in (".heic", ".heif")
dest = os.path.join(dossier, base + (".jpeg" if heic else ext)) dest = os.path.join(dossier, base + (".jpeg" if heic else ext))
@@ -225,3 +271,61 @@ def convertir_heic(src, dest):
exif = im.info.get("exif") exif = im.info.get("exif")
im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"") im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"")
os.replace(dest + ".tmp", dest) os.replace(dest + ".tmp", dest)
# --- Suggestion d'un jour pour une trace sans heures ---------------------------
def positions_medias(voyage, a):
"""[(date, lat, lon)] des photos et vidéos du voyage : exports CSV s'ils
existent, sinon lecture rapide des photos sources par exiftool."""
res = []
for nom in ("export_photos.csv", "export_videos.csv"):
chemin = os.path.join(voyage, nom)
if not os.path.exists(chemin):
continue
with open(chemin, encoding="utf-8", errors="replace") as f:
for ligne in csv.reader(f):
try:
res.append((date(*map(int, ligne[1][:10].replace("-", ":").split(":"))),
float(ligne[2]), float(ligne[3])))
except (ValueError, IndexError):
pass
if res or not a.photos or not shutil.which("exiftool"):
return res
r = subprocess.run(["exiftool", "-n", "-q", "-p", "$DateTimeOriginal,$GPSLatitude,$GPSLongitude"]
+ a.photos[:2000], capture_output=True, text=True, errors="replace")
for ligne in r.stdout.splitlines():
try:
d, lat, lon = ligne.split(",")
res.append((date(*map(int, d[:10].split(":"))), float(lat), float(lon)))
except ValueError:
pass
return res
def suggerer_jour(pistes, medias, rayon_km=1.5):
"""Jour le plus probable pour une trace sans heures : celui dont le plus de
photos et vidéos ont été prises à moins de `rayon_km` de la trace.
Renvoie (date, nombre de médias) ou (None, 0)."""
pts = [(p.latitude, p.longitude) for t in pistes for s in t.segments for p in s.points]
if not pts or not medias:
return None, 0
pas = max(1, len(pts) // 2000)
pts = pts[::pas]
# Grille d'environ 1 km : on ne compare un média qu'aux points voisins.
cellule = 0.01
grille = {}
for lat, lon in pts:
grille.setdefault((math.floor(lat / cellule), math.floor(lon / cellule)), []).append((lat, lon))
portee = int(rayon_km / 1.1 / (cellule * 100)) + 1
compte = Counter()
for jour, lat, lon in medias:
i, j = math.floor(lat / cellule), math.floor(lon / cellule)
cos = math.cos(math.radians(lat))
proche = any(math.hypot((la - lat) * 111.2, (lo - lon) * 111.2 * cos) <= rayon_km
for di in range(-portee, portee + 1) for dj in range(-portee, portee + 1)
for la, lo in grille.get((i + di, j + dj), ()))
if proche:
compte[jour] += 1
if not compte:
return None, 0
return compte.most_common(1)[0]
+20 -8
View File
@@ -16,7 +16,7 @@ import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from rtg.config import parametres_video from rtg.config import parametres_video
from rtg.outils import coeurs_performance, dire, exiger, lisible, taille_lisible from rtg.outils import ARRET, coeurs_performance, dire, exiger, lisible, taille_lisible
def _encodeurs_disponibles(): def _encodeurs_disponibles():
@@ -126,8 +126,9 @@ def deja_faite(voyage, src, rapide=False):
def compresser(voyage, cfg, analyse, force=False, progression=None): def compresser(voyage, cfg, analyse, force=False, progression=None):
"""Compresse les rushs du voyage. `progression(nom, etat, pourcentage)` est """Compresse les rushs du voyage. `progression(nom, etat, pourcentage, rang)`
appelée à chaque changement (pour l'interface). Renvoie True si tout a réussi.""" est appelée à chaque changement (pour l'interface) ; rang = (i, total).
Renvoie True si tout a réussi."""
videos = analyse.videos videos = analyse.videos
os.makedirs(os.path.join(voyage, "videos"), exist_ok=True) os.makedirs(os.path.join(voyage, "videos"), exist_ok=True)
p = parametres_video(cfg) p = parametres_video(cfg)
@@ -148,9 +149,9 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
verrou = threading.Lock() verrou = threading.Lock()
bilan = {"faits": 0, "sautes": 0, "echecs": 0, "octets_src": 0, "octets_web": 0} bilan = {"faits": 0, "sautes": 0, "echecs": 0, "octets_src": 0, "octets_web": 0}
def signaler(nom, etat, detail=None): def signaler(nom, etat, detail=None, rang=None):
if progression: if progression:
progression(nom, etat, detail) progression(nom, etat, detail, rang)
total = len(videos) total = len(videos)
en_direct = jobs == 1 and sys.stdout.isatty() en_direct = jobs == 1 and sys.stdout.isatty()
@@ -160,15 +161,17 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
nom = os.path.basename(src) nom = os.path.basename(src)
rang = f"[{i:3d}/{total}]" rang = f"[{i:3d}/{total}]"
hd, sd = sorties(voyage, src) hd, sd = sorties(voyage, src)
if ARRET.is_set():
return False
if not force and lisible(hd) and lisible(sd): if not force and lisible(hd) and lisible(sd):
with verrou: with verrou:
bilan["sautes"] += 1 bilan["sautes"] += 1
dire(f"⏭️ {rang} {nom} — déjà faite") dire(f"⏭️ {rang} {nom} — déjà faite")
signaler(nom, "deja_faite") signaler(nom, "deja_faite", rang=(i, total))
return True return True
h, duree = _hauteur(src), _duree(src) h, duree = _hauteur(src), _duree(src)
dire(f"🎬 {rang} {nom} ({h}p, {int(duree) // 60} min {int(duree) % 60:02d} s) — encodage…") dire(f"🎬 {rang} {nom} ({h}p, {int(duree) // 60} min {int(duree) % 60:02d} s) — encodage…")
signaler(nom, "en_cours", 0) signaler(nom, "en_cours", 0, rang=(i, total))
tmp_hd = os.path.join(os.path.dirname(hd), ".tmp-" + os.path.basename(hd)) tmp_hd = os.path.join(os.path.dirname(hd), ".tmp-" + os.path.basename(hd))
tmp_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd)) tmp_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd))
avancement = {"hd": 0.0, "sd": 0.0} avancement = {"hd": 0.0, "sd": 0.0}
@@ -183,11 +186,14 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
dernier = -1 dernier = -1
while any(pr.poll() is None for pr in procs): while any(pr.poll() is None for pr in procs):
time.sleep(0.5) time.sleep(0.5)
if ARRET.is_set():
for pr in procs:
pr.terminate()
moyenne = int((avancement["hd"] + avancement["sd"]) / 2) moyenne = int((avancement["hd"] + avancement["sd"]) / 2)
if en_direct: if en_direct:
print(f"\r 1080p {avancement['hd']:3.0f} % · 720p {avancement['sd']:3.0f} % ", print(f"\r 1080p {avancement['hd']:3.0f} % · 720p {avancement['sd']:3.0f} % ",
end="", flush=True) end="", flush=True)
elif moyenne // 25 > dernier // 25 and moyenne < 100: elif 0 < moyenne < 100 and moyenne // 25 > max(dernier, 0) // 25:
dire(f" {nom} : {moyenne} %") dire(f" {nom} : {moyenne} %")
if moyenne != dernier: if moyenne != dernier:
signaler(nom, "en_cours", moyenne) signaler(nom, "en_cours", moyenne)
@@ -211,6 +217,10 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
for t in (tmp_hd, tmp_sd): for t in (tmp_hd, tmp_sd):
if os.path.exists(t): if os.path.exists(t):
os.remove(t) os.remove(t)
if ARRET.is_set():
dire(f" ⏹ {nom} : arrêtée")
signaler(nom, "arretee")
return False
with verrou: with verrou:
bilan["echecs"] += 1 bilan["echecs"] += 1
dire(f" ❌ Échec sur {nom}") dire(f" ❌ Échec sur {nom}")
@@ -235,4 +245,6 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
dire(f"ℹ️ Aucun nouvel encodage ({bilan['sautes']} déjà faite(s))") dire(f"ℹ️ Aucun nouvel encodage ({bilan['sautes']} déjà faite(s))")
if bilan["echecs"]: if bilan["echecs"]:
dire(f"⚠️ {bilan['echecs']} échec(s)") dire(f"⚠️ {bilan['echecs']} échec(s)")
if ARRET.is_set():
dire("⏹ Compression arrêtée (les vidéos finies sont gardées).")
return all(resultats) return all(resultats)
+100
View File
@@ -0,0 +1,100 @@
"""Interface : jours imposés aux traces sans heures, suggestion d'après les
photos, et quelques échanges avec le serveur local."""
import os
import threading
import urllib.parse
import urllib.request
from datetime import date
from http.server import ThreadingHTTPServer
import pytest
from rtg import config, sources
from rtg.interface import serveur
from test_chaine import _photo, pytestmark # noqa: F401 (mêmes prérequis)
def _etape(chemin, lat0, lon0):
pts = "".join(f'<trkpt lat="{lat0 + i * 0.01:.5f}" lon="{lon0 + i * 0.012:.5f}"></trkpt>' for i in range(50))
with open(chemin, "w") as f:
f.write(f'<?xml version="1.0"?><gpx version="1.1" creator="68degres.fr" '
f'xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>{pts}</trkseg></trk></gpx>')
@pytest.fixture
def voyage_sans_heures(tmp_path):
racine = tmp_path / "site"
voyage = racine / "Ecosse-2027"
gpx, photos = tmp_path / "gpx", tmp_path / "photos"
for d in (voyage, gpx, photos):
d.mkdir(parents=True)
_etape(gpx / "Etape 1.gpx", 55.9, 3.2)
_etape(gpx / "Etape 2.gpx", 56.7, 2.24)
_photo(str(photos / "P1.jpg"), "2027:06:11 10:00:00", gps=(56.75, 2.30))
config.ecrire_toml(str(racine / "site.toml"), {"titre": "Test"})
config.ecrire_toml(str(voyage / "voyage.toml"), {
"titre": "Écosse", "sources": {"traces": [str(gpx)], "videos": [], "photos": [str(photos)]}})
return racine, voyage
def _cfg(racine, voyage):
return config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
def test_jour_impose_et_suggestion(voyage_sans_heures):
racine, voyage = voyage_sans_heures
a = sources.analyser(str(voyage), _cfg(racine, voyage))
assert not a.journees and len(a.non_datees) == 2
medias = sources.positions_medias(str(voyage), a)
par_nom = {os.path.basename(f): p for f, p in a.non_datees.items()}
assert sources.suggerer_jour(par_nom["Etape 2.gpx"], medias) == (date(2027, 6, 11), 1)
assert sources.suggerer_jour(par_nom["Etape 1.gpx"], medias) == (None, 0)
config.modifier_toml(str(voyage / "voyage.toml"), {"dates_traces": {"Etape 1.gpx": "2027-06-10"}})
a = sources.analyser(str(voyage), _cfg(racine, voyage))
assert sorted(a.journees) == [date(2027, 6, 10)]
assert [os.path.basename(f) for f in a.non_datees] == ["Etape 2.gpx"]
# Le titre et les sources ont survécu à la réécriture du fichier
assert config.lire_toml(str(voyage / "voyage.toml"))["titre"] == "Écosse"
@pytest.fixture
def client(voyage_sans_heures):
racine, voyage = voyage_sans_heures
app = serveur.Application(str(racine))
srv = ThreadingHTTPServer(("127.0.0.1", 0), None)
port = srv.server_address[1]
srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port)
threading.Thread(target=srv.serve_forever, daemon=True).start()
def appel(chemin, donnees=None, htmx=True):
req = urllib.request.Request(f"http://127.0.0.1:{port}{chemin}",
data=urllib.parse.urlencode(donnees).encode() if donnees else None)
if htmx:
req.add_header("HX-Request", "true")
try:
with urllib.request.urlopen(req) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
yield appel, racine, voyage
srv.shutdown()
def test_serveur(client):
appel, racine, voyage = client
code, html = appel("/")
assert code == 200 and "Écosse" in html
code, html = appel("/voyage/analyse?v=Ecosse-2027")
assert "2 traces à rattacher" in html and "11 juin 2027" in html
# Répartir : un jour par trace dans l'ordre des noms
code, html = appel("/voyage/dates-suite", {"v": "Ecosse-2027", "debut": "2027-06-10"})
assert "2 journées" in html
assert config.lire_toml(str(voyage / "voyage.toml"))["dates_traces"] == {
"Etape 1.gpx": "2027-06-10", "Etape 2.gpx": "2027-06-11"}
# Requête de modification sans l'en-tête htmx : refusée
code, _ = appel("/voyage/date", {"v": "Ecosse-2027", "fichier": "Etape 1.gpx", "jour": ""}, htmx=False)
assert code == 403
# Voyage hors du site : refusé
code, html = appel("/voyage/analyse?v=../../etc")
assert "introuvable" in html.lower()