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

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

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

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

No files matched your search

+120 -16
View File
@@ -11,10 +11,14 @@ Les vidéos ne sont pas copiées : la compression lit directement les sources.
Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/
(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]