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:
1 parent
608334b9d8
commit
06153a227e
37 files changed
+2618
-57
No files matched your search
@@ -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)
|
||||
```
|
||||
|
||||
## 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`
|
||||
|
||||
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 analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources
|
||||
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`.
|
||||
@@ -139,7 +160,7 @@ videos = ["/Volumes/LaCie/Perso/Gopro/Gopro-2026/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`.
|
||||
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ dev = ["pytest>=7"]
|
||||
rtg = "rtg.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["rtg"]
|
||||
packages = ["rtg", "rtg.interface"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
rtg = ["templates/*"]
|
||||
"rtg.interface" = ["modeles/*", "static/*", "static/images/*"]
|
||||
+1
-1
@@ -93,7 +93,7 @@ def fiche(racine, chemin):
|
||||
quand = date(int(m.group(0)), 1, 1) if m else date.min
|
||||
return {
|
||||
'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, '/')),
|
||||
'periode': periode(dates),
|
||||
'moto': reglages_voyage(chemin).get('moto', ''),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
rtg accueil seulement la page d'accueil et la carte globale
|
||||
rtg migrer écrire site.toml et les voyage.toml depuis l'existant
|
||||
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
|
||||
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("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)
|
||||
|
||||
if args.commande == "verifier":
|
||||
@@ -73,6 +78,9 @@ def main(argv=None):
|
||||
accueil.generer(racine, reglages_site(racine), args.mymaps_maj)
|
||||
elif args.commande == "migrer":
|
||||
site.migrer(racine)
|
||||
elif args.commande == "interface":
|
||||
from rtg.interface.serveur import lancer
|
||||
lancer(racine, args.port, ouvrir=not args.sans_navigateur)
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrompu.")
|
||||
sys.exit(130)
|
||||
+41
-2
@@ -37,6 +37,7 @@ DEFAUTS = {
|
||||
"moto": "",
|
||||
"sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]},
|
||||
"exclusions": {"fichiers": []},
|
||||
"dates_traces": {}, # trace sans heures → jour imposé (AAAA-MM-JJ)
|
||||
}
|
||||
|
||||
# Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages
|
||||
@@ -130,18 +131,56 @@ def _toml_valeur(v):
|
||||
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=""):
|
||||
"""Écrit un TOML simple (valeurs, listes, une profondeur de tables)."""
|
||||
lignes = [f"# {l}" for l in entete.splitlines()] + ([""] if entete else [])
|
||||
for k, v in donnees.items():
|
||||
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():
|
||||
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:
|
||||
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):
|
||||
return re.sub(r"[-_]+", " ", nom).strip()
|
||||
@@ -0,0 +1 @@
|
||||
"""Interface locale de RTG (`rtg interface`)."""
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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()
|
||||
@@ -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.
|
||||
Vendored
+1
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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
|
||||
@@ -3,10 +3,23 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
EXT_PHOTOS = (".jpg", ".jpeg", ".heic", ".heif")
|
||||
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=""):
|
||||
print(message, flush=True)
|
||||
|
||||
+92
-28
@@ -4,7 +4,7 @@ import os
|
||||
from rtg import accueil, extraction, pages, photos, sources, video
|
||||
from rtg.config import (SITE_TOML, VOYAGE_TOML, ecrire_toml, nom_lisible,
|
||||
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",
|
||||
"apercus", "rushs", "routes"}
|
||||
@@ -46,19 +46,28 @@ def _sources_disponibles(voyage, liste):
|
||||
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)
|
||||
nom = os.path.relpath(voyage, racine)
|
||||
dire("=" * 50)
|
||||
dire(f"▶ {nom}")
|
||||
dire("=" * 50)
|
||||
|
||||
def etape(libelle):
|
||||
verifier_arret()
|
||||
if suivi:
|
||||
suivi({"etape": libelle, "voyage": nom})
|
||||
|
||||
etape("Sources")
|
||||
a = sources.analyser(voyage, cfg)
|
||||
for m in a.a_verifier:
|
||||
dire(f"⚠️ {m}")
|
||||
for r in sources.ranger(voyage, cfg, a):
|
||||
dire(r)
|
||||
|
||||
etape("Positions")
|
||||
extraction.extraire_photos(voyage, cfg, a)
|
||||
if _sources_disponibles(voyage, cfg["sources"]["videos"]):
|
||||
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.")
|
||||
|
||||
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:
|
||||
dire("⏭️ Compression sautée.")
|
||||
|
||||
etape("Photos")
|
||||
photos.preparer(voyage, cfg, force=force)
|
||||
etape("Pages")
|
||||
ok = pages.generer(voyage, racine, cfg["adresse_publique"])
|
||||
dire()
|
||||
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)
|
||||
cfg = reglages_site(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).")
|
||||
return False
|
||||
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("▶ Accueil")
|
||||
dire("=" * 50)
|
||||
@@ -94,32 +113,58 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
|
||||
return not echecs
|
||||
|
||||
|
||||
def etat(racine):
|
||||
"""Tableau de l'avancement de chaque voyage."""
|
||||
def rubrique_de(voyage, racine):
|
||||
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)
|
||||
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")]
|
||||
for v in decouvrir(racine):
|
||||
cfg = reglages_voyage(v, site_cfg)
|
||||
a = sources.analyser(v, cfg, leger=True)
|
||||
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:
|
||||
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"))
|
||||
for e in tableau_etat(racine):
|
||||
lignes.append((e["chemin"], str(e["jours"]), e["videos"],
|
||||
f"{e['photos_web']}/{e['photos']}" if e["photos"] else "—",
|
||||
"à jour" if e["pages_a_jour"] else "à refaire", str(len(e["a_verifier"]))))
|
||||
largeurs = [max(len(l[i]) for l in lignes) for i in range(len(lignes[0]))]
|
||||
for k, l in enumerate(lignes):
|
||||
dire(" ".join(c.ljust(w) for c, w in zip(l, largeurs)))
|
||||
@@ -143,6 +188,25 @@ def analyser(voyage, racine):
|
||||
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):
|
||||
"""É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."""
|
||||
|
||||
+120
-16
@@ -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/
|
||||
(organisation historique) n'est pas touché.
|
||||
"""
|
||||
import csv
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
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
|
||||
|
||||
import gpxpy
|
||||
@@ -33,6 +37,8 @@ class Analyse:
|
||||
self.photos = [] # chemins des photos sources
|
||||
self.videos = [] # chemins des vidéos sources
|
||||
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):
|
||||
total = 0.0
|
||||
@@ -65,21 +71,36 @@ def date_du_nom(nom):
|
||||
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.
|
||||
|
||||
Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est
|
||||
trouvée. Avec horodatages : date locale dans le fuseau du voyage, avec une
|
||||
coupure à `coupure_h` heures (une étape finie à 1 h reste sur la veille).
|
||||
Sans horodatage : la date du nom de fichier vaut pour tout le fichier.
|
||||
trouvée. `jour_impose` (choisi dans l'interface) place tout le fichier sur
|
||||
ce jour. Sinon, avec horodatages : date locale dans le fuseau du voyage,
|
||||
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:
|
||||
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)
|
||||
if infos is not None:
|
||||
infos["datable"] = a_des_heures or date_du_nom(os.path.basename(fichier)) is not None
|
||||
res = {}
|
||||
if not a_des_heures:
|
||||
jour = date_du_nom(os.path.basename(fichier))
|
||||
if jour_impose or not a_des_heures:
|
||||
jour = jour_impose or date_du_nom(os.path.basename(fichier))
|
||||
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"
|
||||
for t in gpx.tracks:
|
||||
res.setdefault(jour, []).append(t)
|
||||
@@ -120,21 +141,41 @@ def analyser(voyage, cfg, leger=False):
|
||||
tz = ZoneInfo(cfg["journees"]["fuseau"])
|
||||
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 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:
|
||||
if leger and date_du_nom(os.path.basename(f)):
|
||||
a.journees.setdefault(date_du_nom(os.path.basename(f)), [])
|
||||
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
|
||||
sans_jour, infos = [], {}
|
||||
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:
|
||||
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
|
||||
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():
|
||||
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)
|
||||
if os.path.basename(f) not in exclus]
|
||||
@@ -155,6 +196,11 @@ def analyser(voyage, cfg, leger=False):
|
||||
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):
|
||||
"""Range les sources dans le dossier du voyage (routes/, photos/).
|
||||
Renvoie un résumé des écritures."""
|
||||
@@ -187,9 +233,7 @@ def _ecrire_routes(voyage, a):
|
||||
chemin = os.path.join(dossier, nom)
|
||||
if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"):
|
||||
continue
|
||||
with open(chemin, encoding="utf-8", errors="replace") as f:
|
||||
debut = f.read(600)
|
||||
if f'creator="{CREATEUR}"' in debut:
|
||||
if ecrite_par_rtg(chemin):
|
||||
os.remove(chemin)
|
||||
retires += 1
|
||||
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)
|
||||
copiees = converties = 0
|
||||
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))
|
||||
heic = ext.lower() in (".heic", ".heif")
|
||||
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")
|
||||
im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"")
|
||||
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
@@ -16,7 +16,7 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
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():
|
||||
@@ -126,8 +126,9 @@ def deja_faite(voyage, src, rapide=False):
|
||||
|
||||
|
||||
def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||
"""Compresse les rushs du voyage. `progression(nom, etat, pourcentage)` est
|
||||
appelée à chaque changement (pour l'interface). Renvoie True si tout a réussi."""
|
||||
"""Compresse les rushs du voyage. `progression(nom, etat, pourcentage, rang)`
|
||||
est appelée à chaque changement (pour l'interface) ; rang = (i, total).
|
||||
Renvoie True si tout a réussi."""
|
||||
videos = analyse.videos
|
||||
os.makedirs(os.path.join(voyage, "videos"), exist_ok=True)
|
||||
p = parametres_video(cfg)
|
||||
@@ -148,9 +149,9 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||
verrou = threading.Lock()
|
||||
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:
|
||||
progression(nom, etat, detail)
|
||||
progression(nom, etat, detail, rang)
|
||||
|
||||
total = len(videos)
|
||||
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)
|
||||
rang = f"[{i:3d}/{total}]"
|
||||
hd, sd = sorties(voyage, src)
|
||||
if ARRET.is_set():
|
||||
return False
|
||||
if not force and lisible(hd) and lisible(sd):
|
||||
with verrou:
|
||||
bilan["sautes"] += 1
|
||||
dire(f"⏭️ {rang} {nom} — déjà faite")
|
||||
signaler(nom, "deja_faite")
|
||||
signaler(nom, "deja_faite", rang=(i, total))
|
||||
return True
|
||||
h, duree = _hauteur(src), _duree(src)
|
||||
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_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd))
|
||||
avancement = {"hd": 0.0, "sd": 0.0}
|
||||
@@ -183,11 +186,14 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
|
||||
dernier = -1
|
||||
while any(pr.poll() is None for pr in procs):
|
||||
time.sleep(0.5)
|
||||
if ARRET.is_set():
|
||||
for pr in procs:
|
||||
pr.terminate()
|
||||
moyenne = int((avancement["hd"] + avancement["sd"]) / 2)
|
||||
if en_direct:
|
||||
print(f"\r 1080p {avancement['hd']:3.0f} % · 720p {avancement['sd']:3.0f} % ",
|
||||
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} %")
|
||||
if moyenne != dernier:
|
||||
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):
|
||||
if os.path.exists(t):
|
||||
os.remove(t)
|
||||
if ARRET.is_set():
|
||||
dire(f" ⏹ {nom} : arrêtée")
|
||||
signaler(nom, "arretee")
|
||||
return False
|
||||
with verrou:
|
||||
bilan["echecs"] += 1
|
||||
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))")
|
||||
if bilan["echecs"]:
|
||||
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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user