Compare commits

..
16 Commits
Author SHA1 Message Date
FabriceandClaude Opus 5.5 b54ae69b19 Docker : /Volumes en lecture seule sauf le site, MACHD masqué, mot de passe à côté du compose
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-10 08:54:04 +00:00
FabriceandClaude Opus 5.5 ff7e13ad28 Docker : montages limités (site, rushs en lecture seule, mot de passe) et admin.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-10 08:29:17 +00:00
FabriceandClaude Opus 5.5 b29a066b36 Docker : partager /Volumes dans Docker Desktop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-10 08:23:53 +00:00
FabriceandClaude Opus 5.5 daaee62373 Administration Docker à la demande : profil admin, arrêt après 2 h d'inactivité, page « Administration fermée »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-10 07:58:04 +00:00
FabriceandClaude Opus 5.5 02473fafbf Administration dans un conteneur Docker (Dockerfile, exemples compose et nginx) ; arrêt propre sur SIGTERM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 16:59:27 +00:00
FabriceandClaude Opus 5.5 4852361ea6 Connexion : formulaire utilisable sans JavaScript, refus expliqués dans le Terminal, rtg mot-de-passe --tester ; nginx : location ^~ /admin/
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 16:43:26 +00:00
FabriceandClaude Opus 5.5 2c2530a3dc Exemple nginx : /admin vers RTG quand Traefik sert le site par un conteneur nginx
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 15:22:36 +00:00
FabriceandClaude Opus 5.5 d3a8394bc1 Traefik : exemple de route /admin vers l'interface ; option --ecoute
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 15:01:09 +00:00
FabriceandClaude Opus 5.5 4366db94ed Administration depuis le site : bouton Connexion sur l'accueil
site.toml [administration] adresse : l'accueil affiche un bouton
« Connexion » vers l'interface. Celle-ci peut tourner derrière le serveur
web du site (rtg interface --proxy --prefixe /admin) : adresses
préfixées, mot de passe obligatoire, visiteur identifié par
X-Forwarded-For, cookie Secure limité au préfixe quand le site est en
HTTPS. Exemples nginx et Apache dans le README. Le bouton Arrêter ne
remplace plus le bloc de la tâche en même temps que la relève.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 14:49:47 +00:00
FabriceandClaude Opus 5.5 71779f6f53 Interface : corrections à la souris et accès protégé
Onglet « Carte et corrections » : glisser une photo ou une vidéo, placer
les médias sans position, masquer des zones de trace (pour un voyage, ou
zone privée pour tous). Corrections notées dans voyage.toml ([positions],
[corrections]) ou site.toml ([confidentialite]), appliquées par les pages ;
les originaux ne changent pas. L'extraction garde les médias datés sans
position pour qu'on puisse les placer.

Accès : clé d'accès aléatoire à chaque lancement, session par cookie
HttpOnly/SameSite=Strict, mot de passe facultatif (rtg mot-de-passe,
empreinte scrypt hors du site, blocage après 5 échecs), accès réseau
seulement avec mot de passe (--distant), HTTPS possible (--certificat,
--cle), Content-Security-Policy et interdiction des cadres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 14:35:43 +00:00
FabriceandClaude Opus 5.5 06153a227e 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
2026-10-09 14:07:47 +00:00
FabriceandClaude Opus 5.5 608334b9d8 rtg compresser : une ligne par vidéo et pourcentage en direct ; bannière x265 masquée
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 13:36:38 +00:00
FabriceandClaude Opus 5.5 96b1e53a2f README : nouvelle fenêtre de Terminal après pipx ensurepath (zsh)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 13:28:56 +00:00
FabriceandClaude Opus 5.5 d40e963019 Installation documentée par plateforme ; commande rtg verifier
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 11:53:47 +00:00
FabriceandClaude Opus 5.5 b9cb4b8b47 rtg etat : lecture légère des traces datées par leur nom
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 11:49:00 +00:00
FabriceandClaude Opus 5.5 315246cb39 rtg etat : vérification rapide des vidéos (existence) au lieu de ffprobe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 11:48:01 +00:00
52 changed files with 4259 additions and 101 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.git
Scripts
tests
exemples
**/__pycache__
*.egg-info
build
dist
+25
View File
@@ -0,0 +1,25 @@
# Interface RTG en conteneur, derrière le nginx du site
# (README, « L'administration dans Docker »).
FROM python:3.12-slim
# ffmpeg (libx265, libx264), exiftool, fuseaux horaires
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md ./
COPY rtg ./rtg
RUN pip install --no-cache-dir . && rtg verifier
# Mot de passe (acces.toml) : /config/rtg, à monter depuis le Mac.
# Le conteneur voit les disques du Mac sous /Volumes, aux mêmes chemins que
# le Mac : les sources notées dans les voyage.toml restent valables.
ENV XDG_CONFIG_HOME=/config TZ=Europe/Paris PYTHONUNBUFFERED=1 \
RTG_RACINE=/Volumes/CLEROADTRIPS/Road-Trips RTG_PREFIXE=/admin \
RTG_ARRET_INACTIF=120
EXPOSE 8765
# Aucun port publié : seul le nginx du site (même réseau Docker) le joint.
CMD exec rtg --racine "$RTG_RACINE" interface --proxy --prefixe "$RTG_PREFIXE" \
--ecoute 0.0.0.0 --sans-navigateur --arret-inactif "$RTG_ARRET_INACTIF"
+151 -18
View File
@@ -1,4 +1,4 @@
*RoadTripsGenerator* est un ensemble de scripts (bash et Python) qui génèrent un site web statique de road-trips : une carte interactive par voyage et par jour, avec les photos, les vidéos, les traces GPS et le récit du voyage. *RoadTripsGenerator* (RTG) est un logiciel qui génère un site web statique de road-trips : une carte interactive par voyage et par jour, avec les photos, les vidéos, les traces GPS et le récit du voyage.
C'est sans prétention et il faudrait probablement des développements bien plus sérieux pour obtenir un logiciel « presse-bouton », mais si cela peut donner des idées… C'est sans prétention et il faudrait probablement des développements bien plus sérieux pour obtenir un logiciel « presse-bouton », mais si cela peut donner des idées…
@@ -18,22 +18,74 @@ Ils préparent aussi les médias pour le web :
Les originaux ne sont jamais modifiés. Le site produit est statique : une simple copie suffit pour le publier ou le transporter. Les originaux ne sont jamais modifiés. Le site produit est statique : une simple copie suffit pour le publier ou le transporter.
## Les prérequis ## Installation
Les scripts fonctionnent sous macOS et Linux. RTG fonctionne sous macOS et Linux ; Windows devrait fonctionner mais n'a pas encore été testé. Il lui faut :
* obligatoirement : | Élément | Rôle | Version |
* Python 3 et les paquets `pandas folium gpxpy markdown2 numpy` (Jinja2, utilisé pour la mise en page, est installé avec folium) | --- | --- | --- |
* `exiftool` | Python | le logiciel lui-même | 3.10 ou plus récent |
* conseillé : | pipx | installe RTG et ses modules Python dans un espace à part | — |
* `ffmpeg` (compression des vidéos et images d'aperçu) | exiftool | lit les dates et positions GPS des photos et des vidéos | toute version récente |
* `pillow` pour les photos allégées (sous macOS, l'outil `sips` du système prend le relais s'il est absent) | ffmpeg (avec libx265 et libx264) | compresse les vidéos, tire les images d'aperçu | 5 ou plus récent |
Les modules Python (folium, pandas, gpxpy, Pillow, pillow-heif…) sont installés automatiquement avec RTG.
### macOS
1. Installer [Homebrew](https://brew.sh) s'il n'est pas déjà là :
```
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
2. Installer les outils, puis rendre pipx accessible :
```
brew install python pipx exiftool ffmpeg
pipx ensurepath
```
3. Ouvrir une nouvelle fenêtre de Terminal (ou taper `source ~/.zshrc`, zsh étant le shell par défaut de macOS) : `pipx ensurepath` ajoute `~/.local/bin` au chemin des commandes, mais une fenêtre déjà ouverte ne le voit pas. Si `rtg` reste introuvable, c'est ce point.
Le ffmpeg de Homebrew contient libx265, libx264 et l'encodeur matériel VideoToolbox du Mac.
### Linux (Debian, Ubuntu et dérivées)
``` ```
brew install exiftool ffmpeg sudo apt install python3 pipx ffmpeg libimage-exiftool-perl
pip3 install pandas folium gpxpy markdown2 numpy pillow pipx ensurepath
``` ```
Puis fermer et rouvrir le terminal. Sur Fedora : `sudo dnf install pipx perl-Image-ExifTool` et ffmpeg depuis RPM Fusion.
### Windows (non testé)
Dans PowerShell :
```
winget install Python.Python.3.12 Gyan.FFmpeg OliverBetz.ExifTool
py -m pip install --user pipx
py -m pipx ensurepath
```
Puis fermer et rouvrir PowerShell.
### Installer RTG
Récupérer le dépôt (ou le copier, par exemple sur un disque externe) :
```
git clone https://git.ouaibe.fr/Korrigan/RoadTripsGenerator.git
pipx install --editable ./RoadTripsGenerator
```
Avec `--editable`, la commande `rtg` utilise directement le code du dossier : une mise à jour du dépôt (`git pull`) est prise en compte sans réinstaller. Le dossier doit alors rester accessible (disque branché).
### Vérifier
```
rtg verifier
```
La commande contrôle Python, les modules, exiftool, ffmpeg et ses encodeurs, et donne pour chaque élément manquant la commande à taper sur votre système.
## Organisation des dossiers ## Organisation des dossiers
Le dépôt et le site sont deux dossiers voisins. Un voyage est un dossier qui contient au moins un des sous-dossiers `photos`, `rushs` ou `routes`. Les voyages rangés à la racine du site forment la rubrique « Voyages » de la page d'accueil. Ceux rangés un niveau plus bas, dans un dossier de regroupement (ici `Balades`), forment une rubrique du nom de ce dossier. L'accueil montre les 2 plus récents de chaque rubrique (`ACCUEIL_NB=3` pour en montrer plus), avec un lien vers la liste complète. Le dépôt et le site sont deux dossiers voisins. Un voyage est un dossier qui contient au moins un des sous-dossiers `photos`, `rushs` ou `routes`. Les voyages rangés à la racine du site forment la rubrique « Voyages » de la page d'accueil. Ceux rangés un niveau plus bas, dans un dossier de regroupement (ici `Balades`), forment une rubrique du nom de ce dossier. L'accueil montre les 2 plus récents de chaque rubrique (`ACCUEIL_NB=3` pour en montrer plus), avec un lien vers la liste complète.
@@ -62,15 +114,92 @@ Dans le récit `voyage.md`, deux raccourcis évitent les liens écrits en dur (q
[La descente](media:videos/GH011272.mp4) [La descente](media:videos/GH011272.mp4)
``` ```
## Installation et utilisation : la commande `rtg` ## L'interface
RTG s'installe comme un logiciel, avec une seule commande `rtg` utilisable depuis n'importe quel dossier :
``` ```
brew install exiftool ffmpeg # ou l'équivalent sous Linux / Windows cd /Volumes/CLEROADTRIPS/Road-Trips
pipx install ./RoadTripsGenerator # depuis le dossier du dépôt 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 cinq 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.
- *Carte et corrections* : glisser une photo ou une vidéo mal placée, placer celles qui n'ont pas de position (cliquer son nom puis la carte), et masquer des zones de trace : autour de chez soi, ou sur une erreur du GPS. Une zone peut valoir pour tous les voyages (zone privée). Fond plan ou satellite. Les corrections sont notées dans `voyage.toml` (`[positions]`, `[corrections]`) ou `site.toml` (`[confidentialite]`) et appliquées à la génération suivante ; les fichiers d'origine ne sont pas modifiés.
- *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.
### Accès à l'interface
L'interface permet de modifier le site et de lire les fichiers de l'ordinateur : elle est protégée.
- **Sur l'ordinateur où elle tourne**, l'adresse ouverte au lancement contient une clé d'accès tirée au hasard (`…/?jeton=…`), affichée aussi dans le Terminal. Sans elle, l'interface demande de se connecter. La clé change à chaque lancement ; le navigateur garde ensuite une session de 12 heures, perdue quand l'interface s'arrête. « Se déconnecter » la ferme tout de suite.
- **Mot de passe** : `rtg mot-de-passe` le définit (10 caractères au moins). Seule une empreinte est enregistrée, hors du site : `~/.config/rtg/acces.toml` (`%APPDATA%\rtg\acces.toml` sous Windows). `rtg mot-de-passe --supprimer` l'efface. Après 5 essais faux, l'adresse IP doit attendre une minute, puis deux, quatre… jusqu'à une heure.
- **Depuis une autre machine** (le serveur qui héberge le site, par exemple) : `rtg interface --distant`, qui exige le mot de passe. Sans HTTPS, ce mot de passe circulerait en clair. Trois solutions :
- servir l'interface derrière le serveur web du site, en HTTPS, sur une adresse à part ;
- `--certificat cert.pem --cle cle.pem` ;
- le plus simple : un tunnel SSH. Sur le serveur, lancer `rtg interface --sans-navigateur` (sans `--distant`). Sur le Mac, ouvrir `ssh -L 8765:127.0.0.1:8765 serveur`, puis l'adresse avec la clé affichée sur le serveur.
- **Bouton « Connexion » sur l'accueil du site** : voir ci-dessous.
- Les fichiers de réglages (`site.toml`, `voyage.toml`) sont dans les dossiers du site. Si le serveur web publie ce dossier tel quel, ils sont lisibles par tous, avec les chemins de tes disques. Rien de secret n'y est écrit, mais on peut les refuser au serveur web (par exemple `location ~ \.toml$ { deny all; }` pour nginx, ou `<FilesMatch "\.toml$"> Require all denied </FilesMatch>` pour Apache).
### Administration depuis le site
Pour administrer le site depuis son adresse publique (bouton « Connexion » en haut à droite de l'accueil, qui mène à `https://roadtrips.ouaibe.fr/admin/`), l'interface tourne sur la machine qui sert le site, derrière son serveur web en HTTPS. Les sources (rushs, traces, photos) doivent alors être accessibles depuis cette machine pour encoder et générer.
1. Sur cette machine, une fois : `rtg mot-de-passe`.
2. Lancer l'interface, en permanence de préférence (service, `tmux`…) :
```
rtg --racine /chemin/vers/Road-Trips interface --proxy --prefixe /admin --port 8765
```
Elle n'écoute que sur 127.0.0.1 : seul le serveur web peut la joindre. Le mot de passe est obligatoire.
3. Transmettre `/admin/` à l'interface depuis le serveur web.
- Traefik qui envoie le site à un conteneur nginx (docker compose) : rien à changer dans Traefik. Ajouter le bloc de `exemples/nginx-admin.conf` dans le `nginx.conf` du conteneur, et `extra_hosts: ["host.docker.internal:host-gateway"]` au service dans `docker-compose.yml`, puis `docker compose up -d`.
- Traefik seul (fichier de configuration dynamique) : copier `exemples/traefik-rtg-admin.yml` dans le dossier de configuration dynamique de Traefik, en y mettant les mêmes `entryPoints` et `certResolver` que pour le site. Le fichier indique l'adresse à mettre selon que Traefik tourne dans Docker sur le Mac, directement sur le Mac, ou sur une autre machine (dans ce dernier cas, lancer RTG avec `--ecoute 0.0.0.0` en plus).
- nginx, dans le bloc `server` du site en HTTPS :
```nginx
location ^~ /admin/ {
proxy_pass http://127.0.0.1:8765;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
location ~ \.toml$ { deny all; }
```
- Apache (modules `proxy`, `proxy_http`, `headers`) :
```apache
ProxyPass /admin/ http://127.0.0.1:8765/admin/
ProxyPassReverse /admin/ http://127.0.0.1:8765/admin/
RequestHeader set X-Forwarded-Proto "https"
<FilesMatch "\.toml$">
Require all denied
</FilesMatch>
```
4. Dans l'interface, Réglages → « Administration depuis le site » : adresse `/admin/`. Après « Refaire l'accueil », le bouton « Connexion » apparaît sur la page d'accueil.
### L'administration dans Docker
Plutôt que de laisser `rtg interface` ouvert dans un Terminal, on peut le faire tourner dans un conteneur, à côté du nginx du site. Le `Dockerfile` est à la racine de RoadTripsGenerator (Python, ffmpeg, exiftool).
1. Définir le mot de passe sur le Mac, une fois : `rtg mot-de-passe`, puis en copier l'empreinte à côté du `docker-compose.yml` : `mkdir -p rtg-acces && cp ~/.config/rtg/acces.toml rtg-acces/`.
2. Ajouter le service `rtg` au `docker-compose.yml` du site (voir `exemples/docker-compose.yml`) et copier `exemples/admin.sh` à côté. Le conteneur voit les disques de `/Volumes` aux mêmes chemins que sur le Mac, ce qui garde valables les sources notées dans les `voyage.toml`. Tous sont en lecture seule, sauf le dossier du site. Le disque interne (ici `MACHD`) est masqué par un dossier vide ; quant au lien `/Volumes/Macintosh HD`, il pointe dans le conteneur vers le système du conteneur, pas vers celui du Mac. Docker Desktop doit partager `/Volumes` (Réglages → Resources → File sharing) ; ce réglage permet seulement aux conteneurs de le demander, et seul `rtg` le fait.
3. Dans `nginx.conf`, faire pointer `/admin/` vers ce conteneur : voir `exemples/nginx-admin.conf`. Le site reste en ligne même si le conteneur RTG est arrêté.
4. `docker compose build rtg`, puis `docker compose up -d` (le site ; l'administration ne démarre pas avec lui).
L'administration est **à la demande** : `./admin.sh` l'ouvre, `./admin.sh stop` la ferme, et elle se ferme d'elle-même après 2 heures sans activité (variable `RTG_ARRET_INACTIF`, en minutes). Fermée, `/admin/` affiche « Administration fermée » et le site reste en ligne.
Après une mise à jour de RTG : `docker compose build rtg`. Disque des rushs branché après l'ouverture : relancer `./admin.sh`. L'encodage utilise les processeurs attribués à Docker (réglages de Docker Desktop, « Resources ») ; l'encodeur matériel VideoToolbox du Mac n'est pas disponible dans le conteneur.
Le bouton n'est qu'un lien : la protection est celle de l'interface (mot de passe, blocage après plusieurs essais faux, cookie de session limité à `/admin/`, envoyé seulement en HTTPS).
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`) : Depuis le dossier du site (`Road-Trips`) :
``` ```
@@ -79,6 +208,10 @@ rtg etat # avancement de chaque voyage
rtg generer Balades/Vexin-10-2026 # un voyage, puis l'accueil rtg generer Balades/Vexin-10-2026 # un voyage, puis l'accueil
rtg generer --sans-compression # tous les voyages, sans encoder de vidéo rtg generer --sans-compression # tous les voyages, sans encoder de vidéo
rtg analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources rtg analyser Balades/Vexin-10-2026 # ce que RTG trouve dans les sources
rtg verifier # tout est-il installé ?
rtg interface # l'interface dans le navigateur
rtg mot-de-passe # mot de passe de l'interface (accès distant)
rtg interface --proxy --prefixe /admin # derrière le serveur web du site (bouton Connexion)
``` ```
Les sources d'un voyage peuvent rester où elles sont, nommées n'importe comment : on les indique dans son `voyage.toml`. Les sources d'un voyage peuvent rester où elles sont, nommées n'importe comment : on les indique dans son `voyage.toml`.
@@ -93,9 +226,9 @@ videos = ["/Volumes/LaCie/Perso/Gopro/Gopro-2026/Vexin"]
photos = ["~/Pictures/Export/Vexin"] photos = ["~/Pictures/Export/Vexin"]
``` ```
RTG range alors lui-même les traces par journée (`routes/AAAA-MM-JJ.gpx`, d'après leurs horodatages ou, à défaut, la date du nom de fichier) et copie les photos dans `photos/` (les HEIC de l'iPhone sont converties en JPEG). Une photo sans GPS reçoit la position de la trace à son heure, quand la trace est horodatée. Les réglages communs (qualité vidéo, taille des photos, fuseau horaire, cartes My Maps…) sont dans `site.toml`. RTG range alors lui-même les traces par journée (`routes/AAAA-MM-JJ.gpx`, d'après leurs horodatages, à défaut la date du nom de fichier, ou le jour choisi dans l'interface et noté dans `[dates_traces]`) et copie les photos dans `photos/` (les HEIC de l'iPhone sont converties en JPEG). Une photo sans GPS reçoit la position de la trace à son heure, quand la trace est horodatée. Les réglages communs (qualité vidéo, taille des photos, fuseau horaire, cartes My Maps…) sont dans `site.toml`.
Les scripts de `Scripts/` restent disponibles pendant la transition ; ils produisent le même site. 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`.
## Mode d'emploi (anciens scripts) ## Mode d'emploi (anciens scripts)
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Ouvre ou ferme l'administration RTG du site.
# ./admin.sh ouvrir
# ./admin.sh stop fermer
# À placer à côté du docker-compose.yml du site.
cd "$(dirname "$0")" || exit 1
if [ "$1" = "stop" ]; then
docker compose stop rtg && echo "Administration fermée."
exit
fi
[ -d /Volumes/LaCie ] || echo "LaCie débranché : les rushs ne seront pas accessibles (pas d'encodage vidéo)."
# --force-recreate : le conteneur voit les disques branchés au moment de l'ouverture.
docker compose up -d --force-recreate rtg \
&& echo "Administration ouverte : https://roadtrips.ouaibe.fr/admin/ (se ferme seule après 2 h sans activité)"
+40
View File
@@ -0,0 +1,40 @@
# docker-compose.yml du site, avec l'administration RTG dans son propre conteneur.
# Après une mise à jour de RTG : docker compose build rtg
services:
roadtrip:
image: nginx:alpine
restart: unless-stopped
networks:
- ouaibe_network
volumes:
- /Volumes/CLEROADTRIPS/Road-Trips:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
labels:
- traefik.enable=true
- traefik.docker.network=ouaibe_network
- traefik.http.routers.ouaibe_roadtrip.rule=Host(`roadtrip.ouaibe.fr`) || Host(`roadtrips.ouaibe.fr`)
- traefik.http.routers.ouaibe_roadtrip.entrypoints=websecure
# Administration à la demande : elle ne démarre pas avec le reste.
# ouvrir : ./admin.sh fermer : ./admin.sh stop
# Mot de passe, une fois : mkdir -p rtg-acces && cp ~/.config/rtg/acces.toml rtg-acces/
# Elle se ferme d'elle-même après 2 h sans activité (RTG_ARRET_INACTIF, en minutes).
rtg:
build: /Volumes/CLEROADTRIPS/RoadTripsGenerator
profiles: ["admin"]
restart: "no"
networks:
- ouaibe_network
# Disques du Mac aux mêmes chemins que sur le Mac (les sources notées dans
# les voyage.toml restent valables), tous en lecture seule sauf le site.
# Docker Desktop doit partager /Volumes (Réglages → Resources → File sharing).
volumes:
- /Volumes:/Volumes:ro # tous les disques, lecture seule
- /Volumes/CLEROADTRIPS/Road-Trips:/Volumes/CLEROADTRIPS/Road-Trips # le site, lecture et écriture
- type: tmpfs # MACHD masqué : vide dans le conteneur
target: /Volumes/MACHD
- ./rtg-acces:/config/rtg:ro # mot de passe (copie de acces.toml)
networks:
ouaibe_network:
external: true
+32
View File
@@ -0,0 +1,32 @@
# Bloc à ajouter dans nginx.conf, à l'intérieur de « server { … } », quand le
# site est servi par un conteneur nginx derrière Traefik (Traefik → nginx → RTG).
# « ^~ » : sinon une règle du type « location ~* \.(js|css|png)$ » déjà
# présente dans nginx.conf prendrait les fichiers de l'interface pour ceux du site.
location ^~ /admin/ {
# RTG dans le conteneur « rtg » (exemples/docker-compose.yml). Passer par
# une variable laisse nginx démarrer même si ce conteneur est arrêté.
resolver 127.0.0.11 valid=30s;
set $rtg http://rtg:8765;
proxy_pass $rtg;
# RTG lancé à la main sur le Mac à la place : remplacer les trois lignes
# ci-dessus par « proxy_pass http://host.docker.internal:8765; ».
proxy_set_header Host $host;
# Traefik a déjà noté l'adresse du visiteur et le https : on les transmet tels quels.
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_buffering off;
proxy_read_timeout 300s;
# Conteneur RTG arrêté : une page claire plutôt qu'une erreur 502.
error_page 502 504 = @admin_fermee;
}
location @admin_fermee {
default_type "text/html; charset=utf-8";
add_header Cache-Control no-store always;
return 503 "<!doctype html><html lang=fr><meta charset=utf-8><meta name=viewport content='width=device-width'><title>Administration fermée</title><body style='font-family:-apple-system,sans-serif;text-align:center;margin-top:18vh;color:#444'><h1>Administration fermée</h1><p>Elle n'est ouverte que pendant les mises à jour du site.</p><p><a href='/'>Retour au site</a></p></body></html>";
}
# Réglages de RTG : inutile de les publier.
location ~ \.toml$ { deny all; }
+27
View File
@@ -0,0 +1,27 @@
# Traefik : envoie https://roadtrips.ouaibe.fr/admin/ vers l'interface RTG.
#
# 1. Copier ce fichier dans le dossier de configuration « dynamique » de
# Traefik (celui de providers.file), à côté de la configuration du site.
# 2. Mettre ci-dessous les mêmes entryPoints et certResolver que ceux du site.
# 3. Sur le Mac, laisser tourner :
# rtg --racine /Volumes/CLEROADTRIPS/Road-Trips interface --proxy --prefixe /admin
#
# Traefik transmet lui-même l'adresse du visiteur et le « https » à RTG
# (X-Forwarded-For, X-Forwarded-Proto) : rien d'autre à régler.
http:
routers:
rtg-admin:
rule: "Host(`roadtrips.ouaibe.fr`) && PathPrefix(`/admin`)"
entryPoints: [websecure] # même nom que pour le site
service: rtg-admin
tls:
certResolver: letsencrypt # même nom que pour le site
services:
rtg-admin:
loadBalancer:
servers:
# Traefik dans Docker sur ce Mac :
- url: "http://host.docker.internal:8765"
# Traefik installé directement sur ce Mac : "http://127.0.0.1:8765"
# Traefik sur une autre machine : "http://<adresse du Mac>:8765",
# et lancer RTG avec --ecoute 0.0.0.0 en plus.
+2 -1
View File
@@ -30,7 +30,8 @@ dev = ["pytest>=7"]
rtg = "rtg.cli:main" rtg = "rtg.cli:main"
[tool.setuptools] [tool.setuptools]
packages = ["rtg"] packages = ["rtg", "rtg.interface"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
rtg = ["templates/*"] rtg = ["templates/*"]
"rtg.interface" = ["modeles/*", "static/*", "static/images/*"]
+76
View File
@@ -0,0 +1,76 @@
"""Mot de passe de l'interface (`rtg mot-de-passe`).
Il est rangé hors du site (le dossier du site est souvent servi tel quel par
le serveur web) : ~/.config/rtg/acces.toml sous macOS et Linux,
%APPDATA%\\rtg\\acces.toml sous Windows. Seule une empreinte scrypt salée y
est écrite, jamais le mot de passe.
"""
import base64
import hashlib
import hmac
import os
import secrets
import unicodedata
from rtg.config import ecrire_toml, lire_toml
LONGUEUR_MIN = 10
SCRYPT = {"n": 2 ** 15, "r": 8, "p": 1}
def chemin_acces():
if os.name == "nt":
base = os.environ.get("APPDATA") or os.path.expanduser("~")
else:
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
return os.path.join(base, "rtg", "acces.toml")
def _empreinte(mdp, sel, n, r, p):
# NFC : un « é » tapé dans le Terminal ou dans le navigateur donne les mêmes octets.
mdp = unicodedata.normalize("NFC", mdp)
return hashlib.scrypt(mdp.encode("utf-8"), salt=sel, n=n, r=r, p=p,
maxmem=128 * r * n * 2, dklen=32)
def definir(mdp):
if len(mdp) < LONGUEUR_MIN:
raise ValueError(f"Le mot de passe doit faire au moins {LONGUEUR_MIN} caractères.")
sel = secrets.token_bytes(16)
e = _empreinte(mdp, sel, **SCRYPT)
chemin = chemin_acces()
os.makedirs(os.path.dirname(chemin), exist_ok=True)
ecrire_toml(chemin, {"mot_de_passe": "scrypt${n}${r}${p}${sel}${e}".format(
**SCRYPT, sel=base64.b64encode(sel).decode(), e=base64.b64encode(e).decode())},
entete="Accès à l'interface RTG (empreinte du mot de passe, pas le mot de passe).")
try:
os.chmod(chemin, 0o600)
except OSError:
pass
return chemin
def defini():
return bool(lire_toml(chemin_acces()).get("mot_de_passe"))
def supprimer():
chemin = chemin_acces()
if os.path.exists(chemin):
os.remove(chemin)
return True
return False
def verifier(mdp):
"""Vrai si `mdp` est le mot de passe enregistré (comparaison à temps constant)."""
valeur = lire_toml(chemin_acces()).get("mot_de_passe", "")
try:
algo, n, r, p, sel, e = valeur.split("$")
if algo != "scrypt":
return False
attendu = base64.b64decode(e)
calcule = _empreinte(mdp, base64.b64decode(sel), int(n), int(r), int(p))
except (ValueError, TypeError):
return False
return hmac.compare_digest(attendu, calcule)
+2 -1
View File
@@ -93,7 +93,7 @@ def fiche(racine, chemin):
quand = date(int(m.group(0)), 1, 1) if m else date.min quand = date(int(m.group(0)), 1, 1) if m else date.min
return { return {
'dossier': chemin, 'dossier': chemin,
'titre': nom.replace('-', ' ').replace('_', ' '), 'titre': reglages_voyage(chemin).get('titre') or nom.replace('-', ' ').replace('_', ' '),
'chemin': quote(os.path.relpath(chemin, racine).replace(os.sep, '/')), 'chemin': quote(os.path.relpath(chemin, racine).replace(os.sep, '/')),
'periode': periode(dates), 'periode': periode(dates),
'moto': reglages_voyage(chemin).get('moto', ''), 'moto': reglages_voyage(chemin).get('moto', ''),
@@ -150,6 +150,7 @@ def generer(racine=".", cfg=None, mettre_a_jour_mymaps=False):
ecrire("index.html", env.get_template("accueil.html").render( ecrire("index.html", env.get_template("accueil.html").render(
titre_site=TITRE_SITE, rubriques=rubriques, nb_recents=NB_RECENTS, titre_site=TITRE_SITE, rubriques=rubriques, nb_recents=NB_RECENTS,
administration=cfg["administration"]["adresse"],
carte_globale=CARTE_GLOBALE if any(r['titre'] == TITRE_SECTION_RACINE for r in rubriques) else None, carte_globale=CARTE_GLOBALE if any(r['titre'] == TITRE_SECTION_RACINE for r in rubriques) else None,
sous_titre=f"{total} voyage{'s' if total > 1 else ''}" if total else "")) sous_titre=f"{total} voyage{'s' if total > 1 else ''}" if total else ""))
for r in rubriques: for r in rubriques:
+56
View File
@@ -6,6 +6,9 @@
rtg compresser VOYAGE seulement les vidéos rtg compresser VOYAGE seulement les vidéos
rtg accueil seulement la page d'accueil et la carte globale rtg accueil seulement la page d'accueil et la carte globale
rtg migrer écrire site.toml et les voyage.toml depuis l'existant rtg migrer écrire site.toml et les voyage.toml depuis l'existant
rtg verifier tout ce dont RTG a besoin est-il installé ?
rtg interface ouvrir l'interface dans le navigateur
rtg mot-de-passe définir le mot de passe de l'interface (accès distant)
La racine du site est le premier dossier, en remontant depuis le dossier La racine du site est le premier dossier, en remontant depuis le dossier
courant, qui contient site.toml (sinon le dossier courant) ; --racine la force. courant, qui contient site.toml (sinon le dossier courant) ; --racine la force.
@@ -42,9 +45,55 @@ def main(argv=None):
ac.add_argument("--mymaps-maj", action="store_true") ac.add_argument("--mymaps-maj", action="store_true")
sp.add_parser("migrer", help="écrire site.toml et voyage.toml depuis l'organisation actuelle") sp.add_parser("migrer", help="écrire site.toml et voyage.toml depuis l'organisation actuelle")
sp.add_parser("verifier", help="contrôler que Python, exiftool et ffmpeg sont bien installés")
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")
i.add_argument("--distant", action="store_true",
help="accepter les connexions depuis le réseau (mot de passe obligatoire)")
i.add_argument("--certificat", help="certificat TLS (PEM) pour servir en HTTPS")
i.add_argument("--cle", help="clé privée du certificat TLS (PEM)")
i.add_argument("--proxy", action="store_true",
help="derrière le serveur web du site (mot de passe obligatoire)")
i.add_argument("--prefixe", default="", help="chemin de l'interface sur le site, par exemple /admin")
i.add_argument("--ecoute", help="adresse d'écoute (défaut 127.0.0.1 ; 0.0.0.0 si le serveur web est ailleurs)")
i.add_argument("--arret-inactif", type=float, default=0, metavar="MINUTES",
help="s'arrêter après MINUTES sans activité (0 = jamais)")
mp = sp.add_parser("mot-de-passe", help="définir ou supprimer le mot de passe de l'interface")
mp.add_argument("--supprimer", action="store_true")
mp.add_argument("--tester", action="store_true", help="vérifier un mot de passe sans le changer")
args = p.parse_args(argv) args = p.parse_args(argv)
if args.commande == "mot-de-passe":
from rtg import acces
if args.supprimer:
print("✅ Mot de passe supprimé." if acces.supprimer() else "ℹ️ Aucun mot de passe défini.")
return
import getpass
if args.tester:
if not acces.defini():
sys.exit(f"❌ Aucun mot de passe défini ({acces.chemin_acces()}).")
ok = acces.verifier(getpass.getpass("Mot de passe à vérifier : "))
print(f"✅ C'est bien le mot de passe enregistré ({acces.chemin_acces()})." if ok
else f"❌ Ce n'est pas le mot de passe enregistré ({acces.chemin_acces()}).")
sys.exit(0 if ok else 1)
mdp = getpass.getpass("Nouveau mot de passe : ")
if mdp != getpass.getpass("Le même, encore une fois : "):
sys.exit("❌ Les deux saisies diffèrent.")
try:
print(f"✅ Mot de passe enregistré ({acces.definir(mdp)}).")
except ValueError as e:
sys.exit(f"❌ {e}")
return
if args.commande == "verifier":
# Avant tout autre import : doit fonctionner même si un module manque.
from rtg.verifier import verifier
sys.exit(0 if verifier() else 1)
from rtg import accueil, site, sources, video from rtg import accueil, site, sources, video
from rtg.config import reglages_site, reglages_voyage, trouver_racine from rtg.config import reglages_site, reglages_voyage, trouver_racine
racine = os.path.abspath(args.racine) if args.racine else trouver_racine(".") racine = os.path.abspath(args.racine) if args.racine else trouver_racine(".")
@@ -66,6 +115,13 @@ def main(argv=None):
accueil.generer(racine, reglages_site(racine), args.mymaps_maj) accueil.generer(racine, reglages_site(racine), args.mymaps_maj)
elif args.commande == "migrer": elif args.commande == "migrer":
site.migrer(racine) site.migrer(racine)
elif args.commande == "interface":
from rtg.interface.serveur import lancer
if bool(args.certificat) != bool(args.cle):
sys.exit("❌ --certificat et --cle vont ensemble.")
lancer(racine, args.port, ouvrir=not (args.sans_navigateur or args.proxy), distant=args.distant,
certificat=args.certificat, cle=args.cle, proxy=args.proxy, prefixe=args.prefixe,
ecoute=args.ecoute, arret_inactif=args.arret_inactif)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nInterrompu.") print("\nInterrompu.")
sys.exit(130) sys.exit(130)
+47 -2
View File
@@ -32,11 +32,18 @@ DEFAUTS = {
"photos": {"grand_cote": 1600, "qualite": 80, "positions_deduites": True}, "photos": {"grand_cote": 1600, "qualite": 80, "positions_deduites": True},
"journees": {"fuseau": "Europe/Paris", "coupure_heure": 4}, "journees": {"fuseau": "Europe/Paris", "coupure_heure": 4},
"carte_globale": {"mymaps": []}, "carte_globale": {"mymaps": []},
# Adresse de l'interface (rtg interface --proxy) : bouton « Connexion »
# sur l'accueil du site. Vide = pas de bouton.
"administration": {"adresse": ""},
# Propres à un voyage # Propres à un voyage
"rubrique": "", "rubrique": "",
"moto": "", "moto": "",
"sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]}, "sources": {"traces": ["routes"], "videos": ["rushs"], "photos": ["photos"]},
"exclusions": {"fichiers": []}, "exclusions": {"fichiers": []},
"dates_traces": {}, # trace sans heures → jour imposé (AAAA-MM-JJ)
"positions": {}, # média placé à la main → [lat, lon]
"corrections": {"masques": []}, # zones retirées des traces : [lat, lon, rayon_m]
"confidentialite": {"zones": []}, # (site) zones retirées de tous les voyages
} }
# Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages # Qualité « humaine » → paramètres d'encodeur. « equilibree » = réglages
@@ -130,18 +137,56 @@ def _toml_valeur(v):
return f'"{s}"' return f'"{s}"'
def _toml_cle(k):
return k if re.fullmatch(r"[A-Za-z0-9_-]+", k) else _toml_valeur(k)
def ecrire_toml(chemin, donnees, entete=""): def ecrire_toml(chemin, donnees, entete=""):
"""Écrit un TOML simple (valeurs, listes, une profondeur de tables).""" """Écrit un TOML simple (valeurs, listes, une profondeur de tables)."""
lignes = [f"# {l}" for l in entete.splitlines()] + ([""] if entete else []) lignes = [f"# {l}" for l in entete.splitlines()] + ([""] if entete else [])
for k, v in donnees.items(): for k, v in donnees.items():
if not isinstance(v, dict): if not isinstance(v, dict):
lignes.append(f"{k} = {_toml_valeur(v)}") lignes.append(f"{_toml_cle(k)} = {_toml_valeur(v)}")
for k, v in donnees.items(): for k, v in donnees.items():
if isinstance(v, dict): if isinstance(v, dict):
lignes += ["", f"[{k}]"] + [f"{kk} = {_toml_valeur(vv)}" for kk, vv in v.items()] lignes += ["", f"[{_toml_cle(k)}]"] + [f"{_toml_cle(kk)} = {_toml_valeur(vv)}"
for kk, vv in v.items()]
with open(chemin, "w", encoding="utf-8") as f: with open(chemin, "w", encoding="utf-8") as f:
f.write("\n".join(lignes) + "\n") f.write("\n".join(lignes) + "\n")
def modifier_toml(chemin, changements, entete=""):
"""Applique `changements` à un fichier TOML existant (ou nouveau) et le
réécrit. Une valeur None retire la clé ; une table est fusionnée sur un
niveau. Le commentaire d'en-tête est conservé, les autres sont perdus."""
donnees = lire_toml(chemin)
if os.path.exists(chemin):
with open(chemin, encoding="utf-8") as f:
tete = []
for l in f:
if not l.startswith("#"):
break
tete.append(l[1:].strip())
entete = "\n".join(tete) or entete
for k, v in changements.items():
if v is None:
donnees.pop(k, None)
elif isinstance(v, dict):
table = dict(donnees.get(k, {}))
for kk, vv in v.items():
if vv is None:
table.pop(kk, None)
else:
table[kk] = vv
if table:
donnees[k] = table
else:
donnees.pop(k, None)
else:
donnees[k] = v
ecrire_toml(chemin, donnees, entete)
return donnees
def nom_lisible(nom): def nom_lisible(nom):
return re.sub(r"[-_]+", " ", nom).strip() return re.sub(r"[-_]+", " ", nom).strip()
+107
View File
@@ -0,0 +1,107 @@
"""Corrections faites à la main (interface, onglet « Carte ») et appliquées à
la génération des pages ; les fichiers d'origine ne sont jamais modifiés.
voyage.toml
[positions] média déplacé ou placé à la main
"IMG_4831.jpeg" = [48.85, 2.35]
[corrections]
masques = [[48.85, 2.35, 300]] zones (centre, rayon en m) retirées des traces
site.toml
[confidentialite]
zones = [[48.80, 2.10, 500]] zones retirées des traces de tous les voyages
(autour de chez soi, par exemple)
"""
import csv
import math
import os
import gpxpy.gpx
def masques(cfg):
"""[(lat, lon, rayon_m)] : zones du voyage et zones privées du site."""
res = []
for liste in (cfg.get("corrections", {}).get("masques", []),
cfg.get("confidentialite", {}).get("zones", [])):
for m in liste:
try:
res.append((float(m[0]), float(m[1]), float(m[2])))
except (TypeError, ValueError, IndexError):
pass
return res
def positions(cfg):
"""{nom de fichier: (lat, lon)} des médias placés à la main."""
res = {}
for nom, pos in cfg.get("positions", {}).items():
try:
res[nom] = (float(pos[0]), float(pos[1]))
except (TypeError, ValueError, IndexError):
pass
return res
def distance_m(lat1, lon1, lat2, lon2):
x = math.radians(lon2 - lon1) * math.cos(math.radians((lat1 + lat2) / 2))
y = math.radians(lat2 - lat1)
return 6371000 * math.hypot(x, y)
def dans_un_masque(lat, lon, zones):
return any(distance_m(lat, lon, m[0], m[1]) <= m[2] for m in zones)
def filtrer_piste(piste, zones):
"""Retire d'une piste GPX les points situés dans les zones ; un segment
traversé par une zone est coupé en deux. Renvoie le nombre de points retirés."""
if not zones:
return 0
retires = 0
nouveaux = []
for seg in piste.segments:
courant = gpxpy.gpx.GPXTrackSegment()
for p in seg.points:
if dans_un_masque(p.latitude, p.longitude, zones):
retires += 1
if courant.points:
nouveaux.append(courant)
courant = gpxpy.gpx.GPXTrackSegment()
else:
courant.points.append(p)
if courant.points:
nouveaux.append(courant)
piste.segments = [s for s in nouveaux if len(s.points) > 1]
return retires
def lire_medias(voyage, cfg):
"""Photos et vidéos des exports CSV, positions corrigées comprises.
Renvoie une liste de dicts {fichier, type, date, lat, lon, deplace} ; lat et
lon valent None pour un média sans position."""
corr = positions(cfg)
res = []
for nom_csv, type_ in (("export_photos.csv", "photo"), ("export_videos.csv", "video")):
chemin = os.path.join(voyage, nom_csv)
if not os.path.exists(chemin):
continue
vus = set()
with open(chemin, encoding="utf-8", errors="replace") as f:
for ligne in csv.reader(f):
if len(ligne) < 4 or ligne[0] in vus:
continue
vus.add(ligne[0])
try:
lat, lon = float(ligne[2]), float(ligne[3])
if lat == 0 and lon == 0:
lat = lon = None
except ValueError:
lat = lon = None
m = {"fichier": ligne[0], "type": type_, "date": ligne[1], "lat": lat, "lon": lon,
"deplace": ligne[0] in corr}
if m["deplace"]:
m["lat"], m["lon"] = corr[ligne[0]]
res.append(m)
return res
+27 -5
View File
@@ -13,6 +13,7 @@ import subprocess
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from rtg import corrections
from rtg.outils import dire, exiger from rtg.outils import dire, exiger
FMT_PHOTOS = '"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude' FMT_PHOTOS = '"$FileName",$DateTimeOriginal,$GPSLatitude,$GPSLongitude,$GPSAltitude'
@@ -56,14 +57,29 @@ def extraire_photos(voyage, cfg, analyse):
if pos: if pos:
lignes.append(f"{nom},{date},{pos[0]},{pos[1]},") lignes.append(f"{nom},{date},{pos[0]},{pos[1]},")
deduites += 1 deduites += 1
# Photos datées restées sans position : lignes sans coordonnées, ignorées
# par les pages mais listées par l'interface pour être placées à la main.
vus = {l.split(",")[0] for l in lignes}
sans_position = [l.split(",", 1) for l in _exiftool(commun + ["-p", FMT_PHOTOS_DATE, dossier])
if l.split(",")[0] not in vus]
lignes += [f"{nom},{date},,," for nom, date in sans_position]
with open(csv, "w", encoding="utf-8") as f: with open(csv, "w", encoding="utf-8") as f:
f.write("".join(l + "\n" for l in lignes)) f.write("".join(l + "\n" for l in lignes))
msg = f"📸 Photos : {len(lignes)} ligne(s)" localisees = len(lignes) - len(sans_position)
if len(lignes) > len(p1) + deduites: msg = f"📸 Photos : {localisees} localisée(s)"
msg += f" (dont {len(lignes) - len(p1) - deduites} sans altitude)" if localisees > len(p1) + deduites:
msg += f" (dont {localisees - len(p1) - deduites} sans altitude)"
if deduites: if deduites:
msg += f", {deduites} position(s) déduite(s) de la trace" msg += f", {deduites} position(s) déduite(s) de la trace"
if sans_position:
placees = corrections.positions(cfg)
a_la_main = sum(1 for nom, _ in sans_position if nom.strip('"') in placees)
reste = len(sans_position) - a_la_main
if a_la_main:
msg += f", {a_la_main} placée(s) à la main"
if reste:
msg += f", {reste} sans position (à placer dans l'interface)"
dire(msg) dire(msg)
@@ -141,6 +157,8 @@ def _premier_point(video):
date = dt or cd date = dt or cd
if premier and date: if premier and date:
return date, premier[0], premier[1] return date, premier[0], premier[1]
if date:
return date, None, None # datée mais sans GPS : à placer à la main
return None return None
@@ -169,15 +187,19 @@ def extraire_videos(voyage, analyse):
reprises += 1 reprises += 1
continue continue
r = _premier_point(v) r = _premier_point(v)
if r: if r and r[1] is not None:
lignes.append(f'"{nom}",{r[0]},{r[1]},{r[2]},,') lignes.append(f'"{nom}",{r[0]},{r[1]},{r[2]},,')
etat = "✓" etat = "✓"
else: else:
sans += 1 sans += 1
etat = "— pas de GPS" etat = "— pas de GPS"
if r:
lignes.append(f'"{nom}",{r[0]},,,,')
dire(f" [{i:3d}/{len(analyse.videos):3d}] {nom:<28} {etat}") dire(f" [{i:3d}/{len(analyse.videos):3d}] {nom:<28} {etat}")
if reprises: if reprises:
dire(f" {reprises} rush(s) inchangé(s), positions reprises du dernier export") dire(f" {reprises} rush(s) inchangé(s), positions reprises du dernier export")
with open(csv, "w", encoding="utf-8") as f: with open(csv, "w", encoding="utf-8") as f:
f.write("".join(l + "\n" for l in lignes)) f.write("".join(l + "\n" for l in lignes))
dire(f"🎬 Vidéos : {len(lignes)} rush(s) localisé(s)" + (f", {sans} sans GPS" if sans else "")) sans_pos = sum(1 for l in lignes if l.split(",")[2:4] == ["", ""])
dire(f"🎬 Vidéos : {len(lignes) - sans_pos} rush(s) localisé(s)"
+ (f", {sans_pos} sans GPS (à placer dans l'interface)" if sans_pos else ""))
+1
View File
@@ -0,0 +1 @@
"""Interface locale de RTG (`rtg interface`)."""
+94
View File
@@ -0,0 +1,94 @@
<div class="analyse">
{% if not a.details_traces %}
<p class="vide">Aucune trace GPS. Indiquez où elles se trouvent dans l'onglet <a href="#sources">Sources</a>.</p>
{% else %}
<div class="carte" data-couches='{{ couches|tojson }}'></div>
{% if non_datees %}
<div class="encart attention">
<h3>{{ non_datees|length }} trace{{ 's' if non_datees|length > 1 }} à rattacher à un jour</h3>
<p class="aide">Ces traces n'ont pas d'heures (itinéraire préparé, par exemple) et leur nom ne contient pas de date au format AAAA-MM-JJ.
Choisissez le jour de chacune ; les traces en pointillé sur la carte sont celles-ci.</p>
<table>
<tbody>
{% for nom in non_datees %}
{% set d = a.details_traces|selectattr("nom", "equalto", nom)|first %}
<tr>
<td><code>{{ nom }}</code> <span class="discret">{{ d.km|round|int }} km</span></td>
<td>
<form class="en-ligne" hx-post="{{ base }}/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="{{ base }}/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="{{ base }}/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="{{ base }}/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="{{ base }}/voyage/date" hx-target="#analyse"
hx-vals='{{ {"v": v.chemin, "fichier": d.nom, "jour": ""}|tojson }}'>Annuler</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% set autres = a.a_verifier|reject("commence_par", "Trace à rattacher")|list %}
{% if autres %}
<div class="encart attention">
<h3>À vérifier</h3>
<ul>{% for m in autres %}<li>{{ m }}</li>{% endfor %}</ul>
</div>
{% endif %}
<p class="discret">Photos trouvées : {{ a.photos|length }} · Vidéos trouvées : {{ a.videos|length }}</p>
</div>
+5
View File
@@ -0,0 +1,5 @@
{% if tache and tache.en_cours %}
<a class="bandeau en-cours" href="{{ base }}/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="{{ base }}/tache">{{ "✓" if tache.ok else "⚠️" }} {{ tache.libelle }} : {{ tache.etape|lower }}</a>
{% endif %}
+27
View File
@@ -0,0 +1,27 @@
<div class="fenetre-fond" data-fermer-fond>
<div class="fenetre" role="dialog" aria-label="Choisir un dossier">
<h3>Choisir un dossier de {{ libelle }}</h3>
<div class="raccourcis">
{% for nom, c in raccourcis %}
<button class="petit" hx-get="{{ base }}/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="{{ base }}/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="{{ base }}/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="{{ base }}/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
hx-vals='{{ {"v": v, "cle": cle, "action": "ajouter", "chemin": chemin}|tojson }}'>Choisir ce dossier</button>
</span>
</div>
</div>
</div>
+12
View File
@@ -0,0 +1,12 @@
<div class="infos">
<div><span class="etiquette">Rushs (sources)</span><strong>{{ i.nb_rushs }}</strong> vidéo{{ 's' if i.nb_rushs > 1 }} · {{ i.rushs|taille }}</div>
<div><span class="etiquette">Reste à encoder</span>
{% if i.a_encoder %}<strong>{{ i.a_encoder }}</strong> · {{ i.a_encoder_octets|taille }}{% else %}<strong>rien</strong>{% endif %}</div>
<div><span class="etiquette">Vidéos web</span>{{ i.videos_web|taille }}{% if i.apercus %} <span class="discret">+ aperçus {{ i.apercus|taille }}</span>{% endif %}</div>
<div><span class="etiquette">Photos</span>{{ i.photos|taille }} <span class="discret">→ web {{ i.photos_web|taille }}</span></div>
<div><span class="etiquette">Espace libre (site)</span>{{ i.libre|taille }}</div>
<div><span class="etiquette">Encodage</span>
{{ noms_qualites.get(cfg.video.qualite, cfg.video.qualite) }} · {{ "matériel" if i.encodage.encodeur == "vt" else "processeur" }}
<span class="discret">(HEVC CRF {{ i.encodage.crf_hevc }} / H.264 CRF {{ i.encodage.crf_h264 }})</span>
<a href="{{ base }}/reglages#video">changer</a></div>
</div>
+31
View File
@@ -0,0 +1,31 @@
<div id="sources" class="sources">
{% for cle, s in sources.items() %}
<div class="bloc-source">
<h3>{{ s.libelle }}</h3>
<ul>
{% for l in s.lignes %}
<li class="{{ '' if l.existe else 'manque' }}">
<code title="{{ l.absolu }}">{{ l.source }}</code>
{% if l.existe %}<span class="pastille {{ 'ok' if l.nb else 'neutre' }}">{{ l.nb }} fichier{{ 's' if l.nb > 1 }}</span>
{% elif l.lien_casse %}<span class="pastille attention">disque débranché ?</span>
{% else %}<span class="pastille attention">introuvable</span>{% endif %}
<button class="petit" hx-post="{{ base }}/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="{{ base }}/dossiers?cle={{ cle }}&v={{ v.chemin|url }}" hx-target="#fenetre">Choisir un dossier…</button>
<form hx-post="{{ base }}/voyage/sources" hx-target="#sources" hx-swap="outerHTML" class="en-ligne">
<input type="hidden" name="v" value="{{ v.chemin }}">
<input type="hidden" name="cle" value="{{ cle }}">
<input type="hidden" name="action" value="ajouter">
<input name="chemin" placeholder="ou collez un chemin (dossier ou fichier)">
<button>Ajouter</button>
</form>
</div>
</div>
{% endfor %}
</div>
+2
View File
@@ -0,0 +1,2 @@
<pre class="journal court">{{ lignes|join("\n") }}</pre>
<p class="discret">Disque du site : {{ libre|taille }} libres sur {{ total|taille }} · <code>{{ racine }}</code></p>
+24
View File
@@ -0,0 +1,24 @@
{% if message %}<div class="erreur">{{ message }}</div>{% endif %}
{% if tache %}
<div class="tache {{ 'en-cours' if tache.en_cours else ('ok' if tache.ok else 'echec') }}"
{% if tache.en_cours %}hx-get="{{ base }}/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="{{ base }}/tache/arreter" hx-swap="none">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 %}
+32
View File
@@ -0,0 +1,32 @@
<!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="{{ base }}/static/rtg.css">
{% block tete %}{% endblock %}
<script src="{{ base }}/static/htmx.min.js"></script>
</head>
<body data-base="{{ base }}">
<header class="barre">
<a class="logo" href="{{ base }}/">RTG</a>
<nav>
<a href="{{ base }}/">Mes voyages</a>
<a href="{{ base }}/reglages">Réglages</a>
<a href="{{ base }}/tache">Traitement</a>
<a href="{{ lien_site }}" target="_blank">Voir le site ↗</a>
</nav>
<div id="bandeau" hx-get="{{ base }}/tache/bandeau" hx-trigger="load, every 2s"></div>
<button class="petit discret-bouton" hx-post="{{ base }}/deconnexion" title="Fermer la session de ce navigateur">Se déconnecter</button>
</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="{{ base }}/static/rtg.js"></script>
{% block fin %}{% endblock %}
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion · RTG</title>
<link rel="stylesheet" href="{{ base }}/static/rtg.css">
<script src="{{ base }}/static/htmx.min.js"></script>
</head>
<body class="page-connexion">
<main class="connexion">
<p class="logo">RTG</p>
{% if connecte %}
<p>Vous êtes connecté.</p>
<p><a class="bouton principal" href="{{ base }}/">Ouvrir l'interface</a></p>
{% else %}
<h1>Accès à l'interface</h1>
{% if not distant %}
<p class="aide">Ouvrez l'adresse complète affichée dans le Terminal où « rtg interface » a été lancé :
elle contient la clé d'accès de cette session{% if mdp %}, ou entrez le mot de passe{% endif %}.</p>
{% endif %}
{% if mdp %}
<form method="post" action="{{ base }}/connexion" hx-post="{{ base }}/connexion" hx-target="#retour-connexion">
<label>Mot de passe
<input type="password" name="mot_de_passe" autocomplete="current-password" required autofocus>
</label>
<button class="principal">Entrer</button>
<div id="retour-connexion">{% if message %}<div class="erreur">{{ message }}</div>{% elif attente %}<div class="erreur">Trop d'essais : réessayez dans {{ attente }} s.</div>{% endif %}</div>
</form>
{% elif not distant %}
<p class="aide">Pour entrer aussi avec un mot de passe (depuis un autre navigateur ou une autre machine) :
<code>rtg mot-de-passe</code>.</p>
{% endif %}
{% endif %}
{% if site %}<p class="aide"><a href="{{ site }}">← Retour au site</a></p>{% endif %}
</main>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block titre %}Nouveau voyage{% endblock %}
{% block contenu %}
<h1>Nouveau voyage</h1>
<form id="formulaire" class="formulaire" hx-post="{{ base }}/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="{{ base }}/">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 %}
+74
View File
@@ -0,0 +1,74 @@
{% 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="{{ base }}/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>
<fieldset>
<legend>Administration depuis le site</legend>
<label>Adresse de l'interface <input name="administration" value="{{ cfg.administration.adresse }}" placeholder="/admin/">
<span class="aide">Si elle est remplie, l'accueil du site affiche un bouton « Connexion » vers cette adresse.
L'interface doit alors tourner sur le serveur du site (<code>rtg interface --proxy --prefixe /admin</code>), derrière le serveur web en HTTPS : voir le README.</span></label>
</fieldset>
<p><button class="principal">Enregistrer</button> <span id="retour-reglages"></span></p>
</form>
<h2>Système</h2>
<div hx-get="{{ base }}/systeme" hx-trigger="load"><p class="chargement">Vérification des outils…</p></div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block titre %}Traitement{% endblock %}
{% block contenu %}
<h1>Traitement</h1>
{% if tache %}
{% if tache.voyage %}<p><a href="{{ base }}/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 %}
+105
View File
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block titre %}{{ v.titre }}{% endblock %}
{% block tete %}<link rel="stylesheet" href="{{ base }}/static/leaflet.css"><script src="{{ base }}/static/leaflet.js"></script>{% endblock %}
{% block fin %}<script src="{{ base }}/static/editeur.js"></script>{% endblock %}
{% block contenu %}
<div class="entete-page">
<div>
<p class="fil"><a href="{{ base }}/">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="{{ base }}/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="#carte">Carte et corrections</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="{{ base }}/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-carte">
<div id="editeur" class="editeur" data-voyage="{{ v.chemin }}">
<aside class="editeur-panneau">
<label>Jour
<select id="ed-jour"><option value="">Tous les jours</option></select>
</label>
<div class="ed-cases">
<label class="choix"><input type="checkbox" id="ed-photos" checked> <span class="mk photo"></span> Photos</label>
<label class="choix"><input type="checkbox" id="ed-videos" checked> <span class="mk video"></span> Vidéos</label>
</div>
<p class="aide">Glissez un marqueur pour corriger sa position ; un clic l'affiche.
<span class="mk photo deplace"></span> = position corrigée à la main.</p>
<div id="ed-mode" class="ed-mode" hidden></div>
<h4>Zones masquées</h4>
<p class="aide">Les points de trace situés dans ces zones sont retirés des pages : autour de chez soi, ou sur une erreur du GPS.</p>
<button id="ed-nouvelle-zone">+ Masquer une zone</button>
<div id="ed-brouillon" class="ed-brouillon" hidden>
<label>Rayon : <strong id="ed-rayon-texte">200 m</strong>
<input type="range" id="ed-rayon" min="20" max="3000" step="10" value="200">
</label>
<label class="choix"><input type="checkbox" id="ed-portee-site"> Zone privée : la retirer de tous les voyages</label>
<p><button class="principal" id="ed-zone-ok">Enregistrer la zone</button> <button id="ed-zone-annuler">Annuler</button></p>
</div>
<ul id="ed-zones" class="ed-liste"></ul>
<h4>Médias sans position <span id="ed-nb-sans" class="compte"></span></h4>
<p class="aide">Cliquez sur un nom puis sur la carte pour le placer.</p>
<ul id="ed-sans" class="ed-liste"></ul>
<p id="ed-etat" class="aide"></p>
</aside>
<div id="ed-carte" class="editeur-carte"><p class="chargement">Chargement de la carte…</p></div>
</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="{{ base }}/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="{{ base }}/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="{{ base }}/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="{{ base }}/tache/lancer" hx-target="#tache-zone">
<input type="hidden" name="v" value="{{ v.chemin }}">
<label class="choix"><input type="checkbox" name="sans_compression"> Sans encoder les vidéos <span class="aide">(photos, positions et pages seulement)</span></label>
<label class="choix"><input type="checkbox" name="force"> Tout refaire, même ce qui est déjà à jour</label>
<p>
<button class="principal" name="quoi" value="generer">Générer le voyage</button>
<button name="quoi" value="compresser">Encoder les vidéos seulement</button>
</p>
</form>
<div id="tache-zone">{% if tache and tache.voyage == v.chemin %}{% include "_tache.html" %}{% endif %}</div>
</section>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block titre %}Mes voyages{% endblock %}
{% block contenu %}
{% if not site_existe %}
<div class="encart">
<p><strong>Ce dossier n'est pas encore un site RTG.</strong> Il n'y a pas de <code>site.toml</code> dans <code>{{ racine }}</code>.</p>
<p><button class="principal" hx-post="{{ base }}/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="{{ base }}/nouveau">+ Nouveau voyage</a>
<form hx-post="{{ base }}/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="{{ base }}/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="{{ base }}/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 %}
+94
View File
@@ -0,0 +1,94 @@
"""Contrôle d'accès à l'interface.
Deux façons d'entrer :
- la clé d'accès (jeton) tirée au hasard à chaque lancement et ajoutée à
l'adresse que RTG ouvre dans le navigateur (et affiche dans le Terminal) :
seul celui qui voit ce Terminal peut entrer ;
- le mot de passe défini par `rtg mot-de-passe`, indispensable pour un accès
depuis une autre machine (`rtg interface --distant`).
Une fois entré, le navigateur garde un cookie de session (HttpOnly,
SameSite=Strict), valable 12 heures après la dernière requête et perdu à
l'arrêt de l'interface. Après 5 mots de passe faux, une adresse IP est
bloquée une minute, puis deux, quatre… jusqu'à une heure.
"""
import hmac
import secrets
import threading
import time
from rtg import acces
DUREE_SESSION = 12 * 3600
ESSAIS_LIBRES = 5
class Gardien:
def __init__(self, port, distant=False, https=False, proxy=False, prefixe=""):
self.jeton = secrets.token_urlsafe(24)
self.cookie = f"rtg_{port}"
self.distant, self.https, self.proxy = distant, https, proxy
self.chemin_cookie = prefixe + "/" if prefixe else "/"
self._sessions = {}
self._echecs = {} # ip → (nombre d'échecs, heure du dernier)
self._verrou = threading.Lock()
# --- sessions
def ouvrir_session(self):
sid = secrets.token_urlsafe(32)
with self._verrou:
self._sessions[sid] = time.time() + DUREE_SESSION
return sid
def session_valide(self, sid):
if not sid:
return False
maintenant = time.time()
with self._verrou:
fin = self._sessions.get(sid)
if not fin or fin < maintenant:
self._sessions.pop(sid, None)
return False
self._sessions[sid] = maintenant + DUREE_SESSION
return True
def fermer_session(self, sid):
with self._verrou:
self._sessions.pop(sid, None)
def entete_cookie(self, sid, https=None, effacer=False):
valeur = f"{self.cookie}={'' if effacer else sid}; Path={self.chemin_cookie}; HttpOnly; SameSite=Strict"
if effacer:
valeur += "; Max-Age=0"
if self.https if https is None else https:
valeur += "; Secure"
return valeur
# --- clé d'accès et mot de passe
def jeton_valide(self, jeton):
return bool(jeton) and hmac.compare_digest(jeton.encode(), self.jeton.encode())
def attente(self, ip):
"""Secondes à attendre avant un nouvel essai (0 si libre)."""
with self._verrou:
n, dernier = self._echecs.get(ip, (0, 0))
if n < ESSAIS_LIBRES:
return 0
blocage = min(60 * 2 ** (n - ESSAIS_LIBRES), 3600)
return max(0, int(dernier + blocage - time.time()))
def essayer(self, ip, mdp):
"""Vérifie le mot de passe. Renvoie (ok, message d'erreur)."""
reste = self.attente(ip)
if reste:
return False, f"Trop d'essais : réessayez dans {reste} s."
if not acces.defini():
return False, "Aucun mot de passe n'est défini (commande rtg mot-de-passe)."
if acces.verifier(mdp):
with self._verrou:
self._echecs.pop(ip, None)
return True, ""
with self._verrou:
n, _ = self._echecs.get(ip, (0, 0))
self._echecs[ip] = (n + 1, time.time())
return False, "Mot de passe incorrect."
+946
View File
@@ -0,0 +1,946 @@
"""Interface de RTG : un petit serveur web, 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/. Par défaut le serveur n'écoute que sur 127.0.0.1 ;
`--distant` l'ouvre au réseau, avec mot de passe obligatoire.
Sécurité (voir aussi securite.py) :
- il faut la clé d'accès du lancement ou le mot de passe pour entrer ;
- 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) ;
- en local, l'en-tête Host doit être le nôtre (contre le « DNS rebinding ») ;
- les pages de l'interface interdisent leur affichage dans un cadre et
n'exécutent que les scripts servis par RTG (Content-Security-Policy).
"""
import io
import json
import mimetypes
import os
import platform
import re
import shutil
import signal
import ssl
import string
import threading
import time
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__, acces, accueil, corrections, 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.interface.securite import Gardien
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, prefixe=""):
self.racine = os.path.abspath(racine)
# Préfixe d'adresse quand l'interface est servie sous le site
# (https://…/admin/ derrière le serveur web) : « /admin ».
self.prefixe = ("/" + prefixe.strip("/")) if prefixe.strip("/") else ""
ENV.globals["base"] = self.prefixe
self._cache = {} # voyage → (empreinte des sources, analyse)
self.derniere_activite = time.time()
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"/voyage/carte.json", self.voyage_carte),
("POST", r"/voyage/position", self.voyage_position),
("POST", r"/voyage/masque", self.voyage_masque),
("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": self.prefixe + "/"})
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"{self.prefixe}/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 ("export_photos.csv", "export_videos.csv")]
# Seuls les réglages qui changent l'analyse comptent : déplacer une
# photo sur la carte ne doit pas faire relire toutes les traces.
reglages = json.dumps([cfg["sources"], cfg.get("dates_traces", {}), cfg["journees"],
cfg.get("exclusions", {})], sort_keys=True, default=str)
return tuple(etat(f) for f in fichiers + autres) + (reglages,)
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)
# --- Carte et corrections
def _media_json(self, chemin, rel, m):
nom = m["fichier"]
if m["type"] == "photo":
dossier = "photos_web" if os.path.exists(os.path.join(chemin, "photos_web", nom)) else "photos"
vignette = f"{self.prefixe}/site/{quote(rel)}/{dossier}/{quote(nom)}"
else:
base = os.path.splitext(nom)[0]
ap = os.path.join(chemin, "apercus", base + ".jpg")
vignette = f"{self.prefixe}/site/{quote(rel)}/apercus/{quote(base)}.jpg" if os.path.exists(ap) else None
d = m["date"][:10].replace(":", "-")
return dict(m, jour=d, heure=m["date"][11:16], vignette=vignette)
def _masques_json(self, cfg):
return {"masques": cfg.get("corrections", {}).get("masques", []),
"zones": cfg.get("confidentialite", {}).get("zones", [])}
def voyage_carte(self, r):
rel = r.q("v")
chemin = self.chemin_voyage(rel)
cfg, a, jours, _ = self.analyse(chemin)
cfg = self.cfg_voyage(chemin) # positions et masques à jour
medias = [self._media_json(chemin, rel, m) for m in corrections.lire_medias(chemin, cfg)]
return json_rep(dict(
self._masques_json(cfg),
couches=[dict(c, jour=j["date"].isoformat()) for c, j in zip(_couches(a, jours), jours)],
jours=self._jours_carte(jours, medias),
medias=medias))
@staticmethod
def _jours_carte(jours, medias):
"""Jours des traces, plus ceux où seuls des médias existent."""
res = {j["date"].isoformat(): {"jour": j["date"].isoformat(), "nom": _date_longue(j["date"])}
for j in jours}
for m in medias:
if m["jour"] not in res:
try:
d = date.fromisoformat(m["jour"])
except ValueError:
continue
res[m["jour"]] = {"jour": m["jour"], "nom": _date_longue(d) + " (sans trace)"}
return [res[k] for k in sorted(res)]
def voyage_position(self, r):
rel = r.f("v")
chemin = self.chemin_voyage(rel)
nom = r.f("fichier")
if not nom:
raise Erreur(400, "Média non précisé")
if r.f("lat") and r.f("lon"):
try:
lat, lon = float(r.f("lat")), float(r.f("lon"))
except ValueError:
raise Erreur(400, "Position invalide")
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
raise Erreur(400, "Position invalide")
self.toml_voyage(chemin, {"positions": {nom: [round(lat, 6), round(lon, 6)]}})
else:
self.toml_voyage(chemin, {"positions": {nom: None}})
cfg = self.cfg_voyage(chemin)
m = next((m for m in corrections.lire_medias(chemin, cfg) if m["fichier"] == nom), None)
return json_rep({"media": self._media_json(chemin, rel, m) if m else None})
def voyage_masque(self, r):
chemin = self.chemin_voyage(r.f("v"))
portee = "site" if r.f("portee") == "site" else "voyage"
cfg = self.cfg_voyage(chemin)
liste = list(cfg["confidentialite"]["zones"] if portee == "site" else cfg["corrections"]["masques"])
if r.f("action") == "ajouter":
try:
lat, lon, rayon = float(r.f("lat")), float(r.f("lon")), int(float(r.f("rayon")))
except ValueError:
raise Erreur(400, "Zone invalide")
liste.append([round(lat, 6), round(lon, 6), max(10, min(rayon, 20000))])
elif r.f("action") == "supprimer":
try:
del liste[int(r.f("index"))]
except (ValueError, IndexError):
raise Erreur(400, "Zone introuvable")
if portee == "site":
modifier_toml(os.path.join(self.racine, SITE_TOML), {"confidentialite": {"zones": liste}},
entete="Réglages du site RoadTripsGenerator (rtg).")
else:
self.toml_voyage(chemin, {"corrections": {"masques": liste}})
return json_rep(self._masques_json(self.cfg_voyage(chemin)))
# --- 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"))))
# /Volumes : disques du Mac, y compris vus depuis le conteneur Docker.
if 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},
"administration": {"adresse": r.f("administration").strip()},
}, 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):
# La relève suivante (chaque seconde) affichera l'arrêt : remplacer le
# bloc ici en même temps qu'elle ferait échouer l'une des deux.
taches.arreter()
return Reponse("")
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)
# Pages de l'interface : scripts et styles de RTG seulement, tuiles des fonds
# de carte, aucun affichage dans un cadre.
CSP = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https://tile.openstreetmap.org https://server.arcgisonline.com; "
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
PUBLIQUES = {"/connexion"}
def creer_gestionnaire(app, port, gardien=None):
gardien = gardien or Gardien(port, prefixe=app.prefixe)
hotes = {f"127.0.0.1:{port}", f"localhost:{port}"}
P = app.prefixe
# « Voir le site » : derrière le serveur web, le vrai site ; sinon le site servi par RTG.
ENV.globals["lien_site"] = (P.rsplit("/", 1)[0] + "/") if gardien.proxy else P + "/site/"
class Gestionnaire(BaseHTTPRequestHandler):
server_version = f"rtg/{__version__}"
sys_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 session(self):
for morceau in (self.headers.get("Cookie") or "").split(";"):
nom, _, valeur = morceau.strip().partition("=")
if nom == gardien.cookie:
return valeur
return ""
def rediriger(self, cible, cookie=None):
self.send_response(303)
self.send_header("Location", cible)
if cookie:
self.send_header("Set-Cookie", cookie)
self.send_header("Content-Length", "0")
self.end_headers()
def ip(self):
"""Adresse du visiteur ; derrière le serveur web, celle qu'il transmet."""
if gardien.proxy:
transmise = (self.headers.get("X-Forwarded-For") or "").split(",")[-1].strip()
if transmise:
return transmise
return self.client_address[0]
def traiter(self, methode, tete=False):
# Activité, pour l'arrêt automatique : les relèves périodiques d'un
# onglet resté ouvert ne comptent pas.
if not self.path.split("?")[0].endswith(("/tache/bandeau", "/tache/fragment")):
app.derniere_activite = time.time()
try:
if not (gardien.distant or gardien.proxy) and self.headers.get("Host") not in hotes:
raise Erreur(403, "Hôte refusé")
url = urlsplit(self.path)
chemin = unquote(url.path)
if P:
if chemin == P:
return self.rediriger(P + "/")
if not chemin.startswith(P + "/"):
raise Erreur(404, "Page introuvable")
chemin = chemin[len(P):]
query = parse_qs(url.query, keep_blank_values=True)
if methode == "GET" and chemin.startswith("/static/"):
return self.fichier(STATIC, chemin[len("/static/"):], tete)
# Adresse ouverte au lancement : la clé ouvre une session, puis
# on la retire de l'adresse (historique, copier-coller).
if "jeton" in query:
if gardien.jeton_valide(_valeur(query, "jeton", "")):
sid = gardien.ouvrir_session()
return self.rediriger(P + chemin, gardien.entete_cookie(sid, self.https()))
return self.rediriger(P + "/connexion")
connecte = gardien.session_valide(self.session())
form = {}
if methode == "POST":
# La connexion marche aussi sans JavaScript (formulaire classique),
# à condition de venir d'une page de ce site.
if self.headers.get("HX-Request") != "true" and not (
chemin == "/connexion" and self.meme_origine()):
raise Erreur(403, "Requête refusée")
n = int(self.headers.get("Content-Length") or 0)
if n > 2_000_000:
raise Erreur(413, "Requête trop grosse")
form = parse_qs(self.rfile.read(n).decode("utf-8"), keep_blank_values=True)
if chemin in PUBLIQUES:
rep = self.connexion(methode, form, connecte)
return self.envoyer(rep, tete) if rep else None
if not connecte:
if self.headers.get("HX-Request") == "true":
return self.envoyer(Reponse("", entetes={"HX-Redirect": P + "/connexion"}), tete)
return self.rediriger(P + "/connexion")
if chemin == "/deconnexion" and methode == "POST":
gardien.fermer_session(self.session())
return self.envoyer(Reponse("", entetes={
"HX-Redirect": P + "/connexion",
"Set-Cookie": gardien.entete_cookie("", self.https(), effacer=True)}), tete)
if methode == "GET" and chemin.startswith("/site/"):
return self.fichier(app.racine, chemin[len("/site/"):], tete)
req = Requete(query, 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 https(self):
return gardien.https or (gardien.proxy and self.headers.get("X-Forwarded-Proto") == "https")
def meme_origine(self):
origine = self.headers.get("Origin")
if not origine:
return True # navigateurs anciens : pas d'en-tête Origin
hote = self.headers.get("X-Forwarded-Host") if gardien.proxy else None
return urlsplit(origine).netloc in {hote or self.headers.get("Host", ""), *hotes}
def connexion(self, methode, form, connecte):
ip = self.ip()
htmx = self.headers.get("HX-Request") == "true"
message = ""
if methode == "POST":
mdp = _valeur(form, "mot_de_passe", "")
ok, message = gardien.essayer(ip, mdp)
if ok:
print(f"🔓 Connexion à l'interface depuis {ip}")
sid = gardien.ouvrir_session()
cookie = gardien.entete_cookie(sid, self.https())
if htmx:
return Reponse("", entetes={"HX-Redirect": P + "/", "Set-Cookie": cookie})
self.rediriger(P + "/", cookie)
return None
# Dans le Terminal, pour comprendre un refus (jamais le mot de passe).
raison = "champ vide (formulaire mal transmis ?)" if not mdp else message
print(f"🔒 Connexion refusée depuis {ip} : {raison}")
if htmx:
return Reponse(f'<div class="erreur">{_echapper(message)}</div>')
return page("connexion.html", mdp=acces.defini(), connecte=connecte,
distant=gardien.distant or gardien.proxy, attente=gardien.attente(ip),
site=self.adresse_site(), message=message)
def adresse_site(self):
"""Lien « Retour au site » de la page de connexion, derrière le serveur web."""
return (P.rsplit("/", 1)[0] or "") + "/" if gardien.proxy else ""
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")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", CSP)
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
for k, v in rep.entetes.items():
self.send_header(k, v)
self.end_headers()
if not tete:
self.wfile.write(rep.corps)
def fichier(self, base, rel, tete):
"""Fichier statique, avec les requêtes partielles (Range) dont
Safari a besoin pour lire les vidéos."""
base = os.path.realpath(base)
chemin = os.path.realpath(os.path.join(base, rel))
if chemin != base and not chemin.startswith(base + os.sep):
raise Erreur(403, "Chemin refusé")
if os.path.isdir(chemin):
if not self.path.split("?")[0].endswith("/"):
self.send_response(301)
self.send_header("Location", self.path.split("?")[0] + "/")
self.end_headers()
return
chemin = os.path.join(chemin, "index.html")
if not os.path.isfile(chemin):
return self.envoyer(Reponse("Fichier introuvable", "text/plain; charset=utf-8", 404), tete)
taille = os.path.getsize(chemin)
type_ = mimetypes.guess_type(chemin)[0] or "application/octet-stream"
if type_.startswith("text/") or type_ in ("application/javascript", "application/json"):
type_ += "; charset=utf-8"
debut, fin, code = 0, taille - 1, 200
m = re.match(r"bytes=(\d*)-(\d*)$", self.headers.get("Range", ""))
if m and taille:
if m.group(1):
debut = int(m.group(1))
fin = min(int(m.group(2)), taille - 1) if m.group(2) else taille - 1
else:
debut = max(0, taille - int(m.group(2) or 0))
if debut > fin:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{taille}")
self.end_headers()
return
code = 206
self.send_response(code)
self.send_header("Content-Type", type_)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(fin - debut + 1 if taille else 0))
if code == 206:
self.send_header("Content-Range", f"bytes {debut}-{fin}/{taille}")
self.end_headers()
if tete or not taille:
return
try:
with open(chemin, "rb") as f:
f.seek(debut)
reste = fin - debut + 1
while reste > 0:
bloc = f.read(min(1 << 20, reste))
if not bloc:
break
self.wfile.write(bloc)
reste -= len(bloc)
except (BrokenPipeError, ConnectionResetError):
pass # le navigateur a interrompu (vidéo)
return Gestionnaire
def _echapper(s):
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None,
proxy=False, prefixe="", ecoute=None, arret_inactif=0):
"""Lance l'interface.
distant : écoute sur toutes les interfaces réseau, mot de passe obligatoire ;
certificat, cle : HTTPS ;
proxy : derrière le serveur web du site (qui transmet https://…/admin/ à
127.0.0.1:port), mot de passe obligatoire ; prefixe : « /admin » ;
ecoute : adresse d'écoute imposée (« 0.0.0.0 » si le serveur web est sur
une autre machine) ; arret_inactif : minutes sans activité (ni traitement en
cours) au bout desquelles l'interface s'arrête d'elle-même, 0 = jamais."""
hors_machine = ecoute and ecoute not in ("127.0.0.1", "localhost", "::1")
if (distant or proxy or hors_machine) and not acces.defini():
raise SystemExit("❌ L'accès depuis le réseau demande un mot de passe : lancez d'abord « rtg mot-de-passe ».")
contexte_tls = None
if certificat:
contexte_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
contexte_tls.minimum_version = ssl.TLSVersion.TLSv1_2
contexte_tls.load_cert_chain(certificat, cle)
app = Application(racine, prefixe)
serveur = None
# Derrière le serveur web, le port doit être exactement celui de sa configuration.
for p in ([port] if proxy else range(port, port + 20)):
try:
gardien = Gardien(p, distant=distant, https=bool(contexte_tls), proxy=proxy, prefixe=app.prefixe)
adresse = ecoute or ("0.0.0.0" if distant else "127.0.0.1")
serveur = ThreadingHTTPServer((adresse, p),
creer_gestionnaire(app, p, gardien))
port = p
break
except OSError:
continue
if serveur is None:
raise SystemExit(f"❌ Port {port} occupé." if proxy else f"❌ Aucun port libre entre {port} et {port + 19}.")
if contexte_tls:
serveur.socket = contexte_tls.wrap_socket(serveur.socket, server_side=True)
serveur.daemon_threads = True
schema = "https" if contexte_tls else "http"
url = f"{schema}://127.0.0.1:{port}{app.prefixe}/?jeton={gardien.jeton}"
print(f"🌐 Interface RTG : {url}")
print(" (cette adresse contient la clé d'accès de cette session : ne la partagez pas)")
if proxy:
print(f" Derrière le serveur web : https://<votre site>{app.prefixe}/ (mot de passe)")
if distant:
print(f" Accès depuis le réseau : {schema}://<nom ou adresse de cette machine>:{port}/ (mot de passe)")
if not contexte_tls:
print(" ⚠️ Sans HTTPS, le mot de passe circule en clair : placez l'interface derrière votre")
print(" serveur web en HTTPS, utilisez --certificat/--cle, ou un tunnel SSH.")
print(f" Site : {app.racine}")
print(" (Ctrl+C pour arrêter)")
if ouvrir:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
# « docker stop » envoie SIGTERM : on s'arrête aussitôt, comme sur Ctrl+C.
def sur_sigterm(*_):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, sur_sigterm)
if arret_inactif:
print(f" Arrêt automatique après {arret_inactif:g} min sans activité.")
def surveiller():
while True:
time.sleep(min(30, arret_inactif * 30))
t = taches.COURANTE
if t and t.en_cours:
app.derniere_activite = time.time()
elif time.time() - app.derniere_activite > arret_inactif * 60:
print(f"💤 Aucune activité depuis {arret_inactif:g} min : arrêt de l'interface.")
serveur.shutdown()
return
threading.Thread(target=surveiller, daemon=True, name="rtg-veille").start()
try:
serveur.serve_forever()
except KeyboardInterrupt:
taches.arreter()
print("\nInterface arrêtée.")
finally:
serveur.server_close()
+32
View File
@@ -0,0 +1,32 @@
Bibliothèques tierces fournies avec l'interface de RTG
htmx 1.9.12 (htmx.min.js) — https://htmx.org — licence Zero-Clause BSD
Leaflet 1.9.4 (leaflet.js, leaflet.css, images/) — https://leafletjs.com
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+283
View File
@@ -0,0 +1,283 @@
// Onglet « Carte et corrections » : déplacer ou placer les photos et vidéos,
// masquer des zones de trace. Chaque correction est enregistrée aussitôt dans
// voyage.toml (ou site.toml pour une zone privée) ; les pages du site la
// prennent en compte à la génération suivante.
(function () {
"use strict";
const racine = document.getElementById("editeur");
if (!racine || !window.L) return;
const voyage = racine.dataset.voyage;
const base = document.body.dataset.base || ""; // « /admin » derrière le serveur web
const $ = id => document.getElementById(id);
let carte = null, donnees = null, coucheTraces, coucheMedias, coucheZones;
let brouillon = null, modeZone = false, aPlacer = null, cadree = false;
// --- Échanges avec le serveur -------------------------------------------
function message(html) {
const m = $("messages");
m.innerHTML = html;
clearTimeout(window._rtgMessage);
window._rtgMessage = setTimeout(() => { m.innerHTML = ""; }, 6000);
}
function lireReponse(r) {
const redirection = r.headers.get("HX-Redirect");
if (redirection) { location.href = redirection; throw new Error("session"); }
if (!(r.headers.get("Content-Type") || "").includes("json")) {
return r.text().then(t => { message(t); throw new Error("refus"); });
}
return r.json();
}
function poster(url, params) {
return fetch(base + url, {
method: "POST",
headers: { "HX-Request": "true", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(Object.assign({ v: voyage }, params))
}).then(lireReponse);
}
function etat(texte) {
$("ed-etat").textContent = texte;
}
function charger() {
return fetch(base + "/voyage/carte.json?v=" + encodeURIComponent(voyage), { headers: { "HX-Request": "true" } })
.then(lireReponse)
.then(d => { donnees = d; remplirJours(); dessinerTout(); });
}
// --- Carte ----------------------------------------------------------------
function initialiser() {
$("ed-carte").innerHTML = "";
carte = L.map("ed-carte");
const plan = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png",
{ maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(carte);
const satellite = L.tileLayer(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{ maxZoom: 19, attribution: "Esri World Imagery" });
L.control.layers({ "Plan": plan, "Satellite": satellite }).addTo(carte);
carte.setView([46.5, 2.5], 5);
coucheTraces = L.featureGroup().addTo(carte);
coucheZones = L.featureGroup().addTo(carte);
coucheMedias = L.featureGroup().addTo(carte);
carte.on("click", clicCarte);
$("ed-jour").addEventListener("change", () => { cadree = false; dessinerTout(); });
$("ed-photos").addEventListener("change", dessinerMedias);
$("ed-videos").addEventListener("change", dessinerMedias);
$("ed-nouvelle-zone").addEventListener("click", commencerZone);
$("ed-zone-annuler").addEventListener("click", finirZone);
$("ed-zone-ok").addEventListener("click", enregistrerZone);
$("ed-rayon").addEventListener("input", () => {
$("ed-rayon-texte").textContent = $("ed-rayon").value + " m";
if (brouillon) brouillon.setRadius(+$("ed-rayon").value);
});
document.addEventListener("keydown", e => {
if (e.key === "Escape") { aPlacer = null; finirZone(); mode(""); }
});
document.body.addEventListener("tache-finie", () => charger());
charger();
}
function remplirJours() {
const choix = $("ed-jour"), actuel = choix.value;
choix.length = 1;
donnees.jours.forEach(j => choix.add(new Option(j.nom, j.jour)));
choix.value = actuel;
}
function jourChoisi() { return $("ed-jour").value; }
function dessinerTout() {
dessinerTraces();
dessinerZones();
dessinerMedias();
if (!cadree) {
const b = coucheTraces.getLayers().length ? coucheTraces.getBounds()
: (coucheMedias.getLayers().length ? coucheMedias.getBounds() : null);
if (b && b.isValid()) carte.fitBounds(b, { padding: [30, 30] });
cadree = true;
}
}
function dessinerTraces() {
coucheTraces.clearLayers();
const jour = jourChoisi();
donnees.couches.forEach(c => {
if (jour && c.jour !== jour) return;
c.segments.forEach(s => L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, interactive: false })
.addTo(coucheTraces));
});
}
function icone(m) {
return L.divIcon({ className: "", iconSize: [16, 16], iconAnchor: [8, 8],
html: '<span class="mk ' + m.type + (m.deplace ? " deplace" : "") + '"></span>' });
}
function popup(m) {
const d = document.createElement("div");
d.className = "ed-popup";
if (m.vignette) {
const img = document.createElement("img");
img.src = m.vignette; img.alt = ""; img.loading = "lazy";
d.append(img);
}
const t = document.createElement("div");
t.textContent = m.fichier + " · " + m.jour + " " + m.heure;
d.append(t);
if (m.deplace) {
const b = document.createElement("button");
b.className = "petit";
b.textContent = "Revenir à la position d'origine";
b.addEventListener("click", () => { carte.closePopup(); enregistrerPosition(m.fichier, "", ""); });
d.append(b);
}
return d;
}
function dessinerMedias() {
coucheMedias.clearLayers();
const jour = jourChoisi(), photos = $("ed-photos").checked, videos = $("ed-videos").checked;
const sans = [];
donnees.medias.forEach(m => {
if (jour && m.jour !== jour) return;
if ((m.type === "photo" && !photos) || (m.type === "video" && !videos)) return;
if (m.lat === null) { sans.push(m); return; }
L.marker([m.lat, m.lon], { icon: icone(m), draggable: true, title: m.fichier })
.bindPopup(() => popup(m), { minWidth: 200 })
.on("dragend", e => {
const p = e.target.getLatLng();
enregistrerPosition(m.fichier, p.lat.toFixed(6), p.lng.toFixed(6));
})
.addTo(coucheMedias);
});
const liste = $("ed-sans");
liste.innerHTML = "";
$("ed-nb-sans").textContent = sans.length;
sans.forEach(m => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "#carte";
a.textContent = (m.type === "photo" ? "📷 " : "🎬 ") + m.fichier;
a.title = m.jour + " " + m.heure;
a.addEventListener("click", e => {
e.preventDefault();
finirZone();
aPlacer = m.fichier;
mode("Cliquez sur la carte pour placer " + m.fichier + " (Échap pour annuler)");
});
const quand = document.createElement("span");
quand.className = "discret";
quand.textContent = " " + m.jour + " " + m.heure;
li.append(a, quand);
liste.append(li);
});
if (!sans.length) liste.innerHTML = '<li class="vide">Aucun</li>';
}
function enregistrerPosition(fichier, lat, lon) {
poster("/voyage/position", { fichier: fichier, lat: lat, lon: lon }).then(r => {
if (r.media) {
const i = donnees.medias.findIndex(m => m.fichier === fichier);
if (i >= 0) donnees.medias[i] = r.media;
}
dessinerMedias();
etat("✓ " + fichier + (lat ? " placé" : " remis à sa position d'origine")
+ ". Régénérez le voyage pour mettre le site à jour.");
}).catch(() => dessinerMedias());
}
// --- Zones masquées -------------------------------------------------------
function dessinerZones() {
coucheZones.clearLayers();
const liste = $("ed-zones");
liste.innerHTML = "";
const ajouter = (z, i, portee) => {
const site = portee === "site";
const cercle = L.circle([z[0], z[1]], {
radius: z[2], color: site ? "#7c3aed" : "#dc2626", weight: 2, fillOpacity: 0.15,
dashArray: site ? "6 6" : null
}).addTo(coucheZones);
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "#carte";
a.textContent = (site ? "Zone privée" : "Zone") + " · " + z[2] + " m";
a.addEventListener("click", e => { e.preventDefault(); carte.fitBounds(cercle.getBounds(), { maxZoom: 16 }); });
const b = document.createElement("button");
b.className = "petit";
b.textContent = "Supprimer";
b.addEventListener("click", () => {
poster("/voyage/masque", { action: "supprimer", portee: portee, index: i }).then(r => {
donnees.masques = r.masques; donnees.zones = r.zones; dessinerZones();
etat("✓ Zone supprimée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour.");
});
});
li.append(a, " ", b);
if (site) li.title = "Retirée de tous les voyages (site.toml)";
liste.append(li);
};
donnees.masques.forEach((z, i) => ajouter(z, i, "voyage"));
donnees.zones.forEach((z, i) => ajouter(z, i, "site"));
}
function commencerZone() {
aPlacer = null;
modeZone = true;
mode("Cliquez au centre de la zone à masquer ; un nouveau clic la déplace.");
}
function finirZone() {
modeZone = false;
if (brouillon) { brouillon.remove(); brouillon = null; }
$("ed-brouillon").hidden = true;
if (!aPlacer) mode("");
}
function enregistrerZone() {
if (!brouillon) return;
const c = brouillon.getLatLng(), site = $("ed-portee-site").checked;
poster("/voyage/masque", {
action: "ajouter", portee: site ? "site" : "voyage",
lat: c.lat.toFixed(6), lon: c.lng.toFixed(6), rayon: $("ed-rayon").value
}).then(r => {
donnees.masques = r.masques; donnees.zones = r.zones;
finirZone();
dessinerZones();
etat("✓ Zone enregistrée. Régénérez " + (site ? "les voyages" : "le voyage") + " pour mettre le site à jour.");
});
}
function clicCarte(e) {
if (aPlacer) {
const nom = aPlacer;
aPlacer = null;
mode("");
enregistrerPosition(nom, e.latlng.lat.toFixed(6), e.latlng.lng.toFixed(6));
} else if (modeZone) {
if (!brouillon) {
brouillon = L.circle(e.latlng, { radius: +$("ed-rayon").value, color: "#dc2626", weight: 2,
dashArray: "4 4", fillOpacity: 0.2 }).addTo(carte);
} else {
brouillon.setLatLng(e.latlng);
}
$("ed-brouillon").hidden = false;
mode("Ajustez le rayon puis enregistrez ; un clic sur la carte déplace la zone.");
}
}
function mode(texte) {
const m = $("ed-mode");
m.textContent = texte;
m.hidden = !texte;
$("ed-carte").classList.toggle("en-mode", !!texte);
}
// L'onglet peut être caché au chargement : on prépare la carte à sa première ouverture.
document.addEventListener("rtg:onglet", e => {
if (e.detail !== "carte") return;
if (!carte) initialiser();
else carte.invalidateSize();
});
if (location.hash === "#carte") setTimeout(() => { if (!carte) initialiser(); }, 0);
})();
+1
View File
@@ -0,0 +1 @@
(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/<body/.test(e)}function l(e){var t=!N(e);var r=A(e);var n=e;if(r==="head"){n=n.replace(S,"")}if(Q.config.useTemplateFragments&&t){var i=s("<body><template>"+n+"</template></body>",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s("<table>"+n+"</table>",1);case"col":return s("<table><colgroup>"+n+"</colgroup></table>",2);case"tr":return s("<table><tbody>"+n+"</tbody></table>",2);case"td":case"th":return s("<table><tbody><tr>"+n+"</tr></tbody></table>",3);case"script":case"style":return s("<div>"+n+"</div>",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r<e.length;r++){t.push(e[r])}}return t}function oe(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function X(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=0}function se(e){ifLine truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
+6
View File
@@ -0,0 +1,6 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
//# sourceMappingURL=leaflet.js.map
+187
View File
@@ -0,0 +1,187 @@
: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; }
}
/* Connexion */
.page-connexion main.connexion { max-width: 420px; margin: 12vh auto; background: var(--carte); border: 1px solid var(--bord);
border-radius: 14px; padding: 24px 28px; }
.connexion .logo { margin: 0 0 8px; }
.connexion label { display: block; margin: 14px 0 10px; font-weight: 500; }
.connexion input[type=password] { display: block; width: 100%; margin-top: 4px; }
.discret-bouton { color: var(--discret); }
/* Carte et corrections */
.editeur { display: grid; grid-template-columns: 300px 1fr; gap: 14px; align-items: start; }
.editeur-panneau { background: var(--carte); border: 1px solid var(--bord); border-radius: 10px; padding: 8px 14px 12px;
max-height: 74vh; overflow-y: auto; }
.editeur-panneau h4 { margin: 18px 0 4px; font-size: 14px; }
.editeur-panneau label { display: block; margin: 8px 0; }
.editeur-panneau select { width: 100%; margin-top: 4px; }
.editeur-panneau input[type=range] { width: 100%; }
.ed-cases { display: flex; gap: 16px; }
.editeur-carte { height: 74vh; border-radius: 10px; border: 1px solid var(--bord); }
.editeur-carte.en-mode { cursor: crosshair; outline: 3px solid var(--accent); }
.editeur-carte.en-mode .leaflet-container, .editeur-carte.en-mode.leaflet-container { cursor: crosshair; }
.ed-mode { background: var(--attention-fond); color: var(--attention); padding: 6px 10px; border-radius: 8px; font-size: 13px; margin: 8px 0; }
.ed-brouillon { background: var(--neutre-fond); border-radius: 8px; padding: 4px 10px; margin: 8px 0; }
.ed-liste { list-style: none; padding: 0; margin: 4px 0; font-size: 13px; }
.ed-liste li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.ed-liste a { text-decoration: none; overflow-wrap: anywhere; }
.mk { display: inline-block; width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0,0,0,.35); vertical-align: middle; }
.mk.photo { background: #ef4444; }
.mk.video { background: #2563eb; }
.mk.deplace { border-color: #f59e0b; box-shadow: 0 0 0 2px #f59e0b; }
.leaflet-marker-draggable .mk { cursor: grab; }
.ed-popup { max-width: 240px; font-size: 12px; }
.ed-popup img { display: block; max-width: 240px; max-height: 180px; border-radius: 6px; margin-bottom: 4px; }
.ed-popup button { margin-top: 6px; }
@media (max-width: 800px) {
.editeur { grid-template-columns: 1fr; }
.editeur-panneau { max-height: none; }
.editeur-carte { height: 60vh; }
}
+93
View File
@@ -0,0 +1,93 @@
// 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);
document.dispatchEvent(new CustomEvent("rtg:onglet", { detail: trouve ? nom : "" }));
}
window.addEventListener("hashchange", afficherOnglet);
document.addEventListener("DOMContentLoaded", afficherOnglet);
// --- Cartes -----------------------------------------------------------------
// Une carte préparée dans un onglet caché n'a pas de taille : on la recadre
// la première fois qu'elle devient visible.
function cadrer(el) {
if (!el._carte || !el.offsetWidth) return;
el._carte.invalidateSize();
if (!el._cadree) {
el._cadree = true;
if (el._bornes) el._carte.fitBounds(el._bornes, { padding: [20, 20] });
else el._carte.setView([46.5, 2.5], 5);
}
}
function initCartes(racine) {
if (!window.L) return;
racine.querySelectorAll(".carte[data-couches]").forEach(el => {
if (el._carte) return;
const carte = L.map(el, { scrollWheelZoom: false });
el._carte = carte;
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18, attribution: "© OpenStreetMap"
}).addTo(carte);
const groupe = L.featureGroup().addTo(carte);
JSON.parse(el.dataset.couches).forEach(c => {
c.segments.forEach(s => {
L.polyline(s, { color: c.couleur, weight: 4, opacity: 0.85, dashArray: c.pointille ? "6 8" : null })
.bindTooltip(c.nom + (c.km ? " · " + c.km + " km" : ""), { sticky: true })
.addTo(groupe);
});
});
el._bornes = groupe.getLayers().length ? groupe.getBounds() : null;
carte.setView([46.5, 2.5], 5);
cadrer(el);
// La molette ne zoome qu'après un clic dans la carte (pas en faisant défiler la page).
carte.on("click", () => carte.scrollWheelZoom.enable());
carte.on("mouseout", () => carte.scrollWheelZoom.disable());
});
}
// --- Après chaque échange htmx --------------------------------------------
document.addEventListener("htmx:afterSettle", e => {
initCartes(e.detail.elt.parentElement || document);
document.querySelectorAll(".journal").forEach(j => { j.scrollTop = j.scrollHeight; });
});
document.addEventListener("DOMContentLoaded", () => {
initCartes(document);
document.querySelectorAll(".journal").forEach(j => { j.scrollTop = j.scrollHeight; });
});
// --- Fenêtre (choix de dossier) -------------------------------------------
function fermer() { document.getElementById("fenetre").innerHTML = ""; }
document.addEventListener("click", e => {
if (e.target.matches("[data-fermer-fond]")) fermer();
if (e.target.matches("[data-fermer]") && !e.target.hasAttribute("hx-post")) fermer();
});
document.addEventListener("htmx:afterRequest", e => {
if (e.detail.elt.matches && e.detail.elt.matches("[data-fermer]") && e.detail.successful) fermer();
});
document.addEventListener("keydown", e => { if (e.key === "Escape") fermer(); });
// Les messages (erreurs) s'effacent d'eux-mêmes.
document.addEventListener("htmx:afterSwap", e => {
if (e.detail.target.id === "messages") {
clearTimeout(window._rtgMessage);
window._rtgMessage = setTimeout(() => { e.detail.target.innerHTML = ""; }, 6000);
}
});
})();
+125
View File
@@ -0,0 +1,125 @@
"""Un traitement à la fois (génération, compression…) dans un fil à part.
Tout ce que la chaîne affiche est recopié dans le journal de la tâche, en
plus du terminal ; l'interface relit ce journal et l'avancement chaque seconde.
"""
import sys
import threading
import time
import traceback
from collections import deque
from rtg.outils import ARRET, Interruption
_verrou = threading.Lock()
COURANTE = None
class Tache:
def __init__(self, libelle, voyage=""):
self.libelle = libelle
self.voyage = voyage # chemin relatif, "" pour tout le site
self.lignes = deque(maxlen=3000)
self.etape = ""
self.etape_voyage = "" # voyage en cours quand tout le site est généré
self.video = None # {"nom", "pct", "rang": (i, n)}
self.debut = time.time()
self.fin = None
self.ok = None
self.arretee = False
@property
def en_cours(self):
return self.fin is None
@property
def duree(self):
s = int((self.fin or time.time()) - self.debut)
return f"{s // 60} min {s % 60:02d} s" if s >= 60 else f"{s} s"
def suivi(self, ev):
if "etape" in ev:
self.etape = ev["etape"]
self.etape_voyage = ev.get("voyage", "") if ev.get("voyage") != self.voyage else ""
if ev["etape"] != "Vidéos":
self.video = None
elif "video" in ev:
if ev["etat"] == "en_cours":
rang = ev.get("rang") or (self.video or {}).get("rang")
self.video = {"nom": ev["video"], "pct": ev["pct"] or 0, "rang": rang}
elif ev.get("rang"):
self.video = {"nom": ev["video"], "pct": 100, "rang": ev["rang"]}
class _Recopie:
"""Remplace sys.stdout pendant une tâche : écrit dans le terminal et dans
le journal de la tâche (ligne par ligne ; un « \\r » remplace la ligne)."""
def __init__(self, original, tache):
self.original, self.tache, self.tampon = original, tache, ""
def write(self, s):
self.original.write(s)
self.tampon += s
while "\n" in self.tampon:
ligne, self.tampon = self.tampon.split("\n", 1)
self.tache.lignes.append(ligne.rsplit("\r", 1)[-1])
return len(s)
def flush(self):
self.original.flush()
def isatty(self):
return False
def __getattr__(self, nom):
return getattr(self.original, nom)
def lancer(libelle, fonction, voyage=""):
"""Démarre `fonction(suivi)` dans un fil. Renvoie la tâche, ou None si une
autre tâche tourne encore."""
global COURANTE
with _verrou:
if COURANTE and COURANTE.en_cours:
return None
t = Tache(libelle, voyage)
COURANTE = t
ARRET.clear()
def executer():
original = sys.stdout
sys.stdout = _Recopie(original, t)
try:
res = fonction(t.suivi)
t.ok = res is not False
except Interruption:
t.ok, t.arretee = False, True
print("⏹ Traitement arrêté.")
except SystemExit as e:
t.ok = False
if e.code not in (None, 0):
print(str(e.code))
except Exception:
t.ok = False
print("❌ Erreur inattendue :")
print(traceback.format_exc())
finally:
if ARRET.is_set():
t.arretee = True
sys.stdout.flush()
sys.stdout = original
t.fin = time.time()
t.etape = "Arrêté" if t.arretee else ("Terminé" if t.ok else "Terminé avec des erreurs")
t.video = None
t.etape_voyage = ""
threading.Thread(target=executer, daemon=True, name="rtg-tache").start()
return t
def arreter():
if COURANTE and COURANTE.en_cours:
ARRET.set()
return True
return False
+13
View File
@@ -3,10 +3,23 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading
EXT_PHOTOS = (".jpg", ".jpeg", ".heic", ".heif") EXT_PHOTOS = (".jpg", ".jpeg", ".heic", ".heif")
EXT_VIDEOS = (".mp4", ".mov") EXT_VIDEOS = (".mp4", ".mov")
# Demande d'arrêt d'un traitement en cours (bouton « Arrêter » de l'interface).
ARRET = threading.Event()
class Interruption(Exception):
pass
def verifier_arret():
if ARRET.is_set():
raise Interruption()
def dire(message=""): def dire(message=""):
print(message, flush=True) print(message, flush=True)
+23 -8
View File
@@ -35,6 +35,8 @@ from folium import plugins
from folium.plugins import MarkerCluster from folium.plugins import MarkerCluster
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from rtg import corrections
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
# Fixés par generer() : racine du site et adresse publique. # Fixés par generer() : racine du site et adresse publique.
CONTEXTE = {"racine": None, "site_url": "https://roadtrip.ouaibe.fr/"} CONTEXTE = {"racine": None, "site_url": "https://roadtrip.ouaibe.fr/"}
@@ -101,13 +103,18 @@ def bornes(points, marge=0.02, robuste=True):
len(points) - len(gardes)) len(points) - len(gardes))
def lire_csv(chemin): def lire_csv(chemin, positions=None):
"""CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour.""" """CSV d'extraction → DataFrame trié, avec colonnes DT (datetime) et Jour.
`positions` : {fichier: (lat, lon)} placés à la main, qui l'emportent."""
vide = pd.DataFrame(columns=COLONNES + ['DT', 'Jour']) vide = pd.DataFrame(columns=COLONNES + ['DT', 'Jour'])
# Un voyage sans photo produit un CSV de 0 octet, que pandas refuse de lire. # Un voyage sans photo produit un CSV de 0 octet, que pandas refuse de lire.
if not os.path.exists(chemin) or os.path.getsize(chemin) == 0: if not os.path.exists(chemin) or os.path.getsize(chemin) == 0:
return vide return vide
df = pd.read_csv(chemin, names=COLONNES, header=None) df = pd.read_csv(chemin, names=COLONNES, header=None)
for nom, (lat, lon) in (positions or {}).items():
lignes = df['Fichier'].astype(str).str.strip() == nom
df.loc[lignes, 'Latitude'] = lat
df.loc[lignes, 'Longitude'] = lon
df = df[df['Latitude'].notnull() & df['Longitude'].notnull()] df = df[df['Latitude'].notnull() & df['Longitude'].notnull()]
df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)].copy() df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)].copy()
if df.empty: if df.empty:
@@ -312,12 +319,16 @@ def exporter_trace_globale(base_dir, pistes):
class Voyage: class Voyage:
"""Données communes à toutes les pages d'un voyage.""" """Données communes à toutes les pages d'un voyage."""
def __init__(self, base_dir): def __init__(self, base_dir, cfg=None):
self.base_dir = base_dir self.base_dir = base_dir
self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage" self.nom = os.path.basename(base_dir.rstrip('/')) or "voyage"
self.routes_dir = os.path.join(base_dir, "routes") self.routes_dir = os.path.join(base_dir, "routes")
self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv")) cfg = cfg or {}
self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv")) self.zones = corrections.masques(cfg)
pos = corrections.positions(cfg)
self.df_v = lire_csv(os.path.join(base_dir, "export_videos.csv"), pos)
self.df_p = lire_csv(os.path.join(base_dir, "export_photos.csv"), pos)
self.points_masques = 0
jours_gpx = set() jours_gpx = set()
for f in fichiers_gpx(self.routes_dir): for f in fichiers_gpx(self.routes_dir):
m = re.search(r'(\d{4}-\d{2}-\d{2})', f) m = re.search(r'(\d{4}-\d{2}-\d{2})', f)
@@ -343,6 +354,7 @@ class Voyage:
continue continue
self.gpx_du_jour[jour] = gpx_file self.gpx_du_jour[jour] = gpx_file
for track in gpx.tracks: for track in gpx.tracks:
self.points_masques += corrections.filtrer_piste(track, self.zones)
pts = [[p.latitude, p.longitude] for seg in track.segments for p in seg.points] pts = [[p.latitude, p.longitude] for seg in track.segments for p in seg.points]
if pts: if pts:
if not track.name: if not track.name:
@@ -396,19 +408,22 @@ def carte_du_jour(v, jour, version, journal, pre, lien_accueil, bouton_telecharg
return m_day, media_coords return m_day, media_coords
def generer(base_dir, racine=None, site_url=None): def generer(base_dir, racine=None, site_url=None, cfg=None):
"""Pages d'un voyage. `racine` : racine du site (bouton « Retour »).""" """Pages d'un voyage. `racine` : racine du site (bouton « Retour ») ;
`cfg` : réglages du voyage (corrections faites dans l'interface)."""
CONTEXTE["racine"] = racine CONTEXTE["racine"] = racine
if site_url: if site_url:
CONTEXTE["site_url"] = site_url.rstrip('/') + '/' CONTEXTE["site_url"] = site_url.rstrip('/') + '/'
base_dir = os.path.abspath(base_dir) base_dir = os.path.abspath(base_dir)
html_dir = os.path.join(base_dir, "html") html_dir = os.path.join(base_dir, "html")
os.makedirs(html_dir, exist_ok=True) os.makedirs(html_dir, exist_ok=True)
v = Voyage(base_dir) v = Voyage(base_dir, cfg)
if not v.jours: if not v.jours:
print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).") print(f"❌ {v.nom} : aucune date trouvée (ni GPX daté, ni média géolocalisé).")
return False return False
v.lire_traces() v.lire_traces()
if v.points_masques:
print(f" 🙈 {v.points_masques} point(s) de trace retiré(s) par les zones masquées")
version = installer_assets(base_dir) version = installer_assets(base_dir)
journal = Journal(base_dir, v.jours) journal = Journal(base_dir, v.jours)
+93 -29
View File
@@ -4,7 +4,7 @@ import os
from rtg import accueil, extraction, pages, photos, sources, video from rtg import accueil, extraction, pages, photos, sources, video
from rtg.config import (SITE_TOML, VOYAGE_TOML, ecrire_toml, nom_lisible, from rtg.config import (SITE_TOML, VOYAGE_TOML, ecrire_toml, nom_lisible,
reglages_site, reglages_voyage) reglages_site, reglages_voyage)
from rtg.outils import dire, est_cache from rtg.outils import dire, est_cache, verifier_arret
IGNORES = {"Scripts", "RoadTripsGenerator", "html", "assets", "videos", "photos", "photos_web", IGNORES = {"Scripts", "RoadTripsGenerator", "html", "assets", "videos", "photos", "photos_web",
"apercus", "rushs", "routes"} "apercus", "rushs", "routes"}
@@ -46,19 +46,28 @@ def _sources_disponibles(voyage, liste):
return True return True
def generer_voyage(voyage, racine, site_cfg, compression=True, force=False): def generer_voyage(voyage, racine, site_cfg, compression=True, force=False, suivi=None):
"""Toute la chaîne pour un voyage. `suivi(evenement)` (interface) reçoit
{"etape": …} au début de chaque étape et {"video": …} pendant la compression."""
cfg = reglages_voyage(voyage, site_cfg) cfg = reglages_voyage(voyage, site_cfg)
nom = os.path.relpath(voyage, racine) nom = os.path.relpath(voyage, racine)
dire("=" * 50) dire("=" * 50)
dire(f"▶ {nom}") dire(f"▶ {nom}")
dire("=" * 50) dire("=" * 50)
def etape(libelle):
verifier_arret()
if suivi:
suivi({"etape": libelle, "voyage": nom})
etape("Sources")
a = sources.analyser(voyage, cfg) a = sources.analyser(voyage, cfg)
for m in a.a_verifier: for m in a.a_verifier:
dire(f"⚠️ {m}") dire(f"⚠️ {m}")
for r in sources.ranger(voyage, cfg, a): for r in sources.ranger(voyage, cfg, a):
dire(r) dire(r)
etape("Positions")
extraction.extraire_photos(voyage, cfg, a) extraction.extraire_photos(voyage, cfg, a)
if _sources_disponibles(voyage, cfg["sources"]["videos"]): if _sources_disponibles(voyage, cfg["sources"]["videos"]):
extraction.extraire_videos(voyage, a) extraction.extraire_videos(voyage, a)
@@ -66,17 +75,24 @@ def generer_voyage(voyage, racine, site_cfg, compression=True, force=False):
dire("⏭️ Vidéos sources inaccessibles : positions reprises du dernier export.") dire("⏭️ Vidéos sources inaccessibles : positions reprises du dernier export.")
if compression and a.videos: if compression and a.videos:
video.compresser(voyage, cfg, a, force=force) etape("Vidéos")
progression = None
if suivi:
def progression(fichier, etat, pct, rang):
suivi({"video": fichier, "etat": etat, "pct": pct, "rang": rang})
video.compresser(voyage, cfg, a, force=force, progression=progression)
elif not compression: elif not compression:
dire("⏭️ Compression sautée.") dire("⏭️ Compression sautée.")
etape("Photos")
photos.preparer(voyage, cfg, force=force) photos.preparer(voyage, cfg, force=force)
ok = pages.generer(voyage, racine, cfg["adresse_publique"]) etape("Pages")
ok = pages.generer(voyage, racine, cfg["adresse_publique"], cfg)
dire() dire()
return ok return ok
def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False): def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=False, suivi=None):
racine = os.path.abspath(racine) racine = os.path.abspath(racine)
cfg = reglages_site(racine) cfg = reglages_site(racine)
liste = [os.path.abspath(v) for v in voyages] if voyages else decouvrir(racine) liste = [os.path.abspath(v) for v in voyages] if voyages else decouvrir(racine)
@@ -84,7 +100,10 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
dire("❌ Aucun voyage trouvé (dossiers avec routes/, photos/, rushs/ ou voyage.toml).") dire("❌ Aucun voyage trouvé (dossiers avec routes/, photos/, rushs/ ou voyage.toml).")
return False return False
dire(f"📁 {len(liste)} voyage(s) : {', '.join(os.path.relpath(v, racine) for v in liste)}\n") dire(f"📁 {len(liste)} voyage(s) : {', '.join(os.path.relpath(v, racine) for v in liste)}\n")
echecs = [v for v in liste if not generer_voyage(v, racine, cfg, compression, force)] echecs = [v for v in liste if not generer_voyage(v, racine, cfg, compression, force, suivi)]
verifier_arret()
if suivi:
suivi({"etape": "Accueil", "voyage": ""})
dire("=" * 50) dire("=" * 50)
dire("▶ Accueil") dire("▶ Accueil")
dire("=" * 50) dire("=" * 50)
@@ -94,32 +113,58 @@ def generer(racine, voyages=None, compression=True, force=False, mymaps_maj=Fals
return not echecs return not echecs
def etat(racine): def rubrique_de(voyage, racine):
"""Tableau de l'avancement de chaque voyage.""" parent = os.path.dirname(os.path.abspath(voyage))
return "Voyages" if parent == os.path.abspath(racine) else nom_lisible(os.path.basename(parent))
def etat_voyage(v, racine, site_cfg):
"""Avancement d'un voyage (dictionnaire), sans rien lire de lourd."""
cfg = reglages_voyage(v, site_cfg)
a = sources.analyser(v, cfg, leger=True)
dossier = os.path.join(v, "videos")
faites_web = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0
if a.videos:
faites = sum(1 for s in a.videos if video.deja_faite(v, s, rapide=True))
vid = f"{faites}/{len(a.videos)}"
else:
faites = None
vid = f"{faites_web} (sources absentes)" if faites_web else "—"
ph = os.path.join(v, "photos")
nb_ph = len([f for f in os.listdir(ph) if f.lower().endswith((".jpg", ".jpeg"))]) if os.path.isdir(ph) else 0
pw = os.path.join(v, "photos_web")
nb_pw = len([f for f in os.listdir(pw) if not est_cache(f)]) if os.path.isdir(pw) else 0
index = os.path.join(v, "index.html")
genere = os.path.exists(index)
pages_ok = genere and all(
os.path.getmtime(index) >= os.path.getmtime(os.path.join(v, c))
for c in ("export_photos.csv", "export_videos.csv", "voyage.md")
if os.path.exists(os.path.join(v, c)))
jours = sorted(a.journees)
return {
"chemin": os.path.relpath(v, racine).replace(os.sep, "/"), "dossier": v,
"titre": cfg["titre"], "moto": cfg.get("moto", ""), "rubrique": rubrique_de(v, racine),
"debut": jours[0] if jours else None, "fin": jours[-1] if jours else None,
"jours": len(jours), "videos": vid, "videos_sources": len(a.videos),
"videos_faites": faites, "videos_web": faites_web,
"photos": nb_ph, "photos_web": nb_pw, "genere": genere, "pages_a_jour": pages_ok,
"a_verifier": a.a_verifier,
}
def tableau_etat(racine):
racine = os.path.abspath(racine) racine = os.path.abspath(racine)
site_cfg = reglages_site(racine) site_cfg = reglages_site(racine)
return [etat_voyage(v, racine, site_cfg) for v in decouvrir(racine)]
def etat(racine):
"""Tableau de l'avancement de chaque voyage."""
lignes = [("Voyage", "Jours", "Vidéos web", "Photos web", "Pages", "À vérifier")] lignes = [("Voyage", "Jours", "Vidéos web", "Photos web", "Pages", "À vérifier")]
for v in decouvrir(racine): for e in tableau_etat(racine):
cfg = reglages_voyage(v, site_cfg) lignes.append((e["chemin"], str(e["jours"]), e["videos"],
a = sources.analyser(v, cfg) f"{e['photos_web']}/{e['photos']}" if e["photos"] else "—",
if a.videos: "à jour" if e["pages_a_jour"] else "à refaire", str(len(e["a_verifier"]))))
faites = sum(1 for s in a.videos if video.deja_faite(v, s))
vid = f"{faites}/{len(a.videos)}"
else:
dossier = os.path.join(v, "videos")
n = len([f for f in os.listdir(dossier) if f.endswith("_mobile.mp4")]) if os.path.isdir(dossier) else 0
vid = f"{n} (sources absentes)" if n else "—"
ph = os.path.join(v, "photos")
nb_ph = len([f for f in os.listdir(ph) if f.lower().endswith((".jpg", ".jpeg"))]) if os.path.isdir(ph) else 0
pw = os.path.join(v, "photos_web")
nb_pw = len([f for f in os.listdir(pw) if not est_cache(f)]) if os.path.isdir(pw) else 0
index = os.path.join(v, "index.html")
pages_ok = "à jour" if os.path.exists(index) and all(
os.path.getmtime(index) >= os.path.getmtime(os.path.join(v, c))
for c in ("export_photos.csv", "export_videos.csv", "voyage.md")
if os.path.exists(os.path.join(v, c))) else "à refaire"
lignes.append((os.path.relpath(v, racine), str(len(a.journees)), vid,
f"{nb_pw}/{nb_ph}" if nb_ph else "—", pages_ok, str(len(a.a_verifier)) or "0"))
largeurs = [max(len(l[i]) for l in lignes) for i in range(len(lignes[0]))] largeurs = [max(len(l[i]) for l in lignes) for i in range(len(lignes[0]))]
for k, l in enumerate(lignes): for k, l in enumerate(lignes):
dire(" ".join(c.ljust(w) for c, w in zip(l, largeurs))) dire(" ".join(c.ljust(w) for c, w in zip(l, largeurs)))
@@ -143,6 +188,25 @@ def analyser(voyage, racine):
dire(f" ⚠️ {m}") dire(f" ⚠️ {m}")
def creer_voyage(racine, dossier, titre, groupe="", moto=""):
"""Nouveau voyage : <racine>/[<groupe>/]<dossier>/voyage.toml, sans source.
Renvoie le chemin du dossier créé."""
for nom in (dossier, groupe):
if nom and (os.sep in nom or "/" in nom or nom.startswith((".", "_")) or nom in IGNORES):
raise ValueError(f"Nom de dossier refusé : {nom}")
chemin = os.path.join(racine, groupe, dossier) if groupe else os.path.join(racine, dossier)
if os.path.exists(chemin):
raise ValueError(f"Le dossier {os.path.relpath(chemin, racine)} existe déjà")
os.makedirs(chemin)
donnees = {"titre": titre or nom_lisible(dossier)}
if moto:
donnees["moto"] = moto
donnees["sources"] = {"traces": [], "videos": [], "photos": []}
ecrire_toml(os.path.join(chemin, VOYAGE_TOML), donnees,
entete="Voyage RoadTripsGenerator : sources, infos et réglages propres.")
return chemin
def migrer(racine): def migrer(racine):
"""Écrit site.toml et un voyage.toml par voyage à partir de l'existant """Écrit site.toml et un voyage.toml par voyage à partir de l'existant
(mymaps.txt, moto.txt). Ne remplace jamais un fichier déjà présent.""" (mymaps.txt, moto.txt). Ne remplace jamais un fichier déjà présent."""
+126 -16
View File
@@ -11,10 +11,14 @@ Les vidéos ne sont pas copiées : la compression lit directement les sources.
Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/ Un voyage dont les sources sont déjà ses propres dossiers routes/ et photos/
(organisation historique) n'est pas touché. (organisation historique) n'est pas touché.
""" """
import csv
import math
import os import os
import re import re
import shutil import shutil
from datetime import datetime, timedelta, timezone import subprocess
from collections import Counter
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import gpxpy import gpxpy
@@ -33,6 +37,8 @@ class Analyse:
self.photos = [] # chemins des photos sources self.photos = [] # chemins des photos sources
self.videos = [] # chemins des vidéos sources self.videos = [] # chemins des vidéos sources
self.a_verifier = [] # messages pour l'utilisateur self.a_verifier = [] # messages pour l'utilisateur
self.details_traces = [] # un dict par fichier GPX (pour l'interface)
self.non_datees = {} # fichier → [GPXTrack] des traces sans jour
def km(self, jour): def km(self, jour):
total = 0.0 total = 0.0
@@ -65,21 +71,36 @@ def date_du_nom(nom):
return None return None
def decouper_par_journee(fichier, tz, coupure_h): def jour_texte(valeur):
"""« 2025-08-04 » (ou date TOML) → date, sinon None."""
try:
return valeur if isinstance(valeur, date) else date.fromisoformat(str(valeur).strip())
except ValueError:
return None
def decouper_par_journee(fichier, tz, coupure_h, jour_impose=None, pistes_sans_jour=None, infos=None):
"""Pistes d'un GPX réparties par journée locale. """Pistes d'un GPX réparties par journée locale.
Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est Renvoie ({date: [GPXTrack]}, None) ou ({}, raison) si aucune date n'est
trouvée. Avec horodatages : date locale dans le fuseau du voyage, avec une trouvée. `jour_impose` (choisi dans l'interface) place tout le fichier sur
coupure à `coupure_h` heures (une étape finie à 1 h reste sur la veille). ce jour. Sinon, avec horodatages : date locale dans le fuseau du voyage,
Sans horodatage : la date du nom de fichier vaut pour tout le fichier. avec une coupure à `coupure_h` heures (une étape finie à 1 h reste sur la
veille) ; sans horodatage : la date du nom de fichier. Les pistes d'un
fichier qu'aucune date ne place sont ajoutées à `pistes_sans_jour`. `infos`
(dictionnaire) reçoit "datable" : le fichier se date-t-il sans aide ?
""" """
with open(fichier, encoding="utf-8") as f: with open(fichier, encoding="utf-8") as f:
gpx = gpxpy.parse(f) gpx = gpxpy.parse(f)
a_des_heures = any(p.time for t in gpx.tracks for s in t.segments for p in s.points) a_des_heures = any(p.time for t in gpx.tracks for s in t.segments for p in s.points)
if infos is not None:
infos["datable"] = a_des_heures or date_du_nom(os.path.basename(fichier)) is not None
res = {} res = {}
if not a_des_heures: if jour_impose or not a_des_heures:
jour = date_du_nom(os.path.basename(fichier)) jour = jour_impose or date_du_nom(os.path.basename(fichier))
if jour is None: if jour is None:
if pistes_sans_jour is not None:
pistes_sans_jour.extend(gpx.tracks)
return {}, "trace sans heures et sans date dans son nom" return {}, "trace sans heures et sans date dans son nom"
for t in gpx.tracks: for t in gpx.tracks:
res.setdefault(jour, []).append(t) res.setdefault(jour, []).append(t)
@@ -110,25 +131,51 @@ def decouper_par_journee(fichier, tz, coupure_h):
return res, None return res, None
def analyser(voyage, cfg): def analyser(voyage, cfg, leger=False):
"""Inventaire des sources d'un voyage (sans rien écrire).""" """Inventaire des sources d'un voyage (sans rien écrire).
leger=True (pour `rtg etat`) : une trace dont le nom porte la date n'est
pas lue ; sa journée est comptée, sans les points ni les kilomètres."""
a = Analyse() a = Analyse()
exclus = set(cfg.get("exclusions", {}).get("fichiers", [])) exclus = set(cfg.get("exclusions", {}).get("fichiers", []))
tz = ZoneInfo(cfg["journees"]["fuseau"]) tz = ZoneInfo(cfg["journees"]["fuseau"])
coupure = int(cfg["journees"]["coupure_heure"]) coupure = int(cfg["journees"]["coupure_heure"])
a.traces = [f for f in lister_fichiers(_sources(voyage, "traces", cfg), (".gpx",), voyage) imposes = {k: jour_texte(v) for k, v in cfg.get("dates_traces", {}).items()}
sources_traces = _sources(voyage, "traces", cfg)
a.traces = [f for f in lister_fichiers(sources_traces, (".gpx",), voyage)
if os.path.basename(f) not in exclus] if os.path.basename(f) not in exclus]
if not _est_dossier_du_voyage(voyage, sources_traces, "routes"):
# Les journées que RTG a lui-même écrites dans routes/ ne sont pas des
# sources : les relire les compterait deux fois.
routes = os.path.realpath(os.path.join(voyage, "routes"))
a.traces = [f for f in a.traces
if os.path.realpath(os.path.dirname(f)) != routes or not ecrite_par_rtg(f)]
for f in a.traces: for f in a.traces:
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: try:
parts, raison = decouper_par_journee(f, tz, coupure) parts, raison = decouper_par_journee(f, tz, coupure, impose, sans_jour, infos)
except Exception as e: except Exception as e:
a.a_verifier.append(f"Trace illisible : {os.path.basename(f)} ({e})") a.a_verifier.append(f"Trace illisible : {nom} ({e})")
a.details_traces.append({"fichier": f, "nom": nom, "jours": [], "km": 0,
"impose": impose, "erreur": str(e)})
continue continue
if raison: if raison:
a.a_verifier.append(f"Trace à rattacher à un jour : {os.path.basename(f)} ({raison})") a.a_verifier.append(f"Trace à rattacher à un jour : {nom} ({raison})")
a.non_datees[f] = sans_jour
km = 0.0
for jour, pistes in parts.items(): for jour, pistes in parts.items():
a.journees.setdefault(jour, []).extend(pistes) a.journees.setdefault(jour, []).extend(pistes)
km += sum((p.length_2d() or 0) for p in pistes) / 1000
if sans_jour:
km = sum((p.length_2d() or 0) for p in sans_jour) / 1000
a.details_traces.append({"fichier": f, "nom": nom, "jours": sorted(parts), "km": km,
"impose": impose, "erreur": None, "datable": infos["datable"]})
a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage) a.photos = [f for f in lister_fichiers(_sources(voyage, "photos", cfg), EXT_PHOTOS, voyage)
if os.path.basename(f) not in exclus] if os.path.basename(f) not in exclus]
@@ -149,6 +196,11 @@ def analyser(voyage, cfg):
return a return a
def ecrite_par_rtg(fichier):
with open(fichier, encoding="utf-8", errors="replace") as f:
return f'creator="{CREATEUR}"' in f.read(600)
def ranger(voyage, cfg, a): def ranger(voyage, cfg, a):
"""Range les sources dans le dossier du voyage (routes/, photos/). """Range les sources dans le dossier du voyage (routes/, photos/).
Renvoie un résumé des écritures.""" Renvoie un résumé des écritures."""
@@ -181,9 +233,7 @@ def _ecrire_routes(voyage, a):
chemin = os.path.join(dossier, nom) chemin = os.path.join(dossier, nom)
if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"): if nom in attendus or est_cache(nom) or not nom.lower().endswith(".gpx"):
continue continue
with open(chemin, encoding="utf-8", errors="replace") as f: if ecrite_par_rtg(chemin):
debut = f.read(600)
if f'creator="{CREATEUR}"' in debut:
os.remove(chemin) os.remove(chemin)
retires += 1 retires += 1
return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "") return f"🧭 routes/ : {len(attendus)} journée(s) écrite(s)" + (f", {retires} retirée(s)" if retires else "")
@@ -194,6 +244,8 @@ def _copier_photos(voyage, a):
os.makedirs(dossier, exist_ok=True) os.makedirs(dossier, exist_ok=True)
copiees = converties = 0 copiees = converties = 0
for src in a.photos: for src in a.photos:
if os.path.realpath(os.path.dirname(src)) == os.path.realpath(dossier):
continue # déjà dans photos/
base, ext = os.path.splitext(os.path.basename(src)) base, ext = os.path.splitext(os.path.basename(src))
heic = ext.lower() in (".heic", ".heif") heic = ext.lower() in (".heic", ".heif")
dest = os.path.join(dossier, base + (".jpeg" if heic else ext)) dest = os.path.join(dossier, base + (".jpeg" if heic else ext))
@@ -219,3 +271,61 @@ def convertir_heic(src, dest):
exif = im.info.get("exif") exif = im.info.get("exif")
im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"") im.convert("RGB").save(dest + ".tmp", "JPEG", quality=92, exif=exif or b"")
os.replace(dest + ".tmp", dest) os.replace(dest + ".tmp", dest)
# --- Suggestion d'un jour pour une trace sans heures ---------------------------
def positions_medias(voyage, a):
"""[(date, lat, lon)] des photos et vidéos du voyage : exports CSV s'ils
existent, sinon lecture rapide des photos sources par exiftool."""
res = []
for nom in ("export_photos.csv", "export_videos.csv"):
chemin = os.path.join(voyage, nom)
if not os.path.exists(chemin):
continue
with open(chemin, encoding="utf-8", errors="replace") as f:
for ligne in csv.reader(f):
try:
res.append((date(*map(int, ligne[1][:10].replace("-", ":").split(":"))),
float(ligne[2]), float(ligne[3])))
except (ValueError, IndexError):
pass
if res or not a.photos or not shutil.which("exiftool"):
return res
r = subprocess.run(["exiftool", "-n", "-q", "-p", "$DateTimeOriginal,$GPSLatitude,$GPSLongitude"]
+ a.photos[:2000], capture_output=True, text=True, errors="replace")
for ligne in r.stdout.splitlines():
try:
d, lat, lon = ligne.split(",")
res.append((date(*map(int, d[:10].split(":"))), float(lat), float(lon)))
except ValueError:
pass
return res
def suggerer_jour(pistes, medias, rayon_km=1.5):
"""Jour le plus probable pour une trace sans heures : celui dont le plus de
photos et vidéos ont été prises à moins de `rayon_km` de la trace.
Renvoie (date, nombre de médias) ou (None, 0)."""
pts = [(p.latitude, p.longitude) for t in pistes for s in t.segments for p in s.points]
if not pts or not medias:
return None, 0
pas = max(1, len(pts) // 2000)
pts = pts[::pas]
# Grille d'environ 1 km : on ne compare un média qu'aux points voisins.
cellule = 0.01
grille = {}
for lat, lon in pts:
grille.setdefault((math.floor(lat / cellule), math.floor(lon / cellule)), []).append((lat, lon))
portee = int(rayon_km / 1.1 / (cellule * 100)) + 1
compte = Counter()
for jour, lat, lon in medias:
i, j = math.floor(lat / cellule), math.floor(lon / cellule)
cos = math.cos(math.radians(lat))
proche = any(math.hypot((la - lat) * 111.2, (lo - lon) * 111.2 * cos) <= rayon_km
for di in range(-portee, portee + 1) for dj in range(-portee, portee + 1)
for la, lo in grille.get((i + di, j + dj), ()))
if proche:
compte[jour] += 1
if not compte:
return None, 0
return compte.most_common(1)[0]
+6
View File
@@ -54,6 +54,10 @@
display: block; width: 100%; height: min(600px, 75vh); border: none; display: block; width: 100%; height: min(600px, 75vh); border: none;
border-radius: 15px; box-shadow: 0 10px 20px rgba(0,0,0,0.1); border-radius: 15px; box-shadow: 0 10px 20px rgba(0,0,0,0.1);
} }
/* Bouton discret vers l'interface d'administration (site.toml, [administration]). */
a.connexion { position: absolute; top: 16px; right: 20px; font-size: 0.85rem; color: #777;
text-decoration: none; padding: 5px 12px; border: 1px solid #ccc; border-radius: 99px; }
a.connexion:hover { color: #3498db; border-color: #3498db; }
.vide { text-align: center; color: #777; margin-top: 60px; line-height: 1.7; } .vide { text-align: center; color: #777; margin-top: 60px; line-height: 1.7; }
code { background: #e8e8ef; padding: 2px 6px; border-radius: 4px; } code { background: #e8e8ef; padding: 2px 6px; border-radius: 4px; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -62,10 +66,12 @@
.sous-titre, .vide, .periode { color: #9a9aa2; } .sous-titre, .vide, .periode { color: #9a9aa2; }
.rubrique-titre { border-color: #333; } .rubrique-titre { border-color: #333; }
code { background: #2e2e36; } code { background: #2e2e36; }
a.connexion { color: #9a9aa2; border-color: #444; }
} }
</style> </style>
</head> </head>
<body> <body>
{% if administration %}<a class="connexion" href="{{ administration }}" rel="nofollow">Connexion</a>{% endif %}
<header> <header>
<h1>{% block entete %}🌍 {{ titre_site }}{% endblock %}</h1> <h1>{% block entete %}🌍 {{ titre_site }}{% endblock %}</h1>
<div class="sous-titre">{% block sous_titre %}{% endblock %}</div> <div class="sous-titre">{% block sous_titre %}{% endblock %}</div>
+100
View File
@@ -0,0 +1,100 @@
"""`rtg verifier` : tout ce dont RTG a besoin est-il installé ?
Pour chaque élément manquant, la commande à taper sur ce système.
"""
import importlib
import platform
import subprocess
import sys
from rtg.outils import dire, programme
SYSTEME = {"Darwin": "mac", "Windows": "windows"}.get(platform.system(), "linux")
# Commande d'installation par système, pour chaque programme externe.
INSTALLER = {
"exiftool": {"mac": "brew install exiftool", "linux": "sudo apt install libimage-exiftool-perl",
"windows": "winget install OliverBetz.ExifTool"},
"ffmpeg": {"mac": "brew install ffmpeg", "linux": "sudo apt install ffmpeg",
"windows": "winget install Gyan.FFmpeg"},
}
# Modules Python : (nom à importer, nom du paquet, indispensable ?)
MODULES = [
("folium", "folium", True), ("gpxpy", "gpxpy", True), ("jinja2", "jinja2", True),
("markdown2", "markdown2", True), ("numpy", "numpy", True), ("pandas", "pandas", True),
("PIL", "pillow", True), ("pillow_heif", "pillow-heif", False),
]
def _version(cmd):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
return (r.stdout or r.stderr).strip().splitlines()[0]
except Exception:
return ""
def verifier():
"""Affiche le bilan ; renvoie True si l'essentiel est là."""
ok = True
dire(f"Système : {platform.system()} {platform.release()} ({platform.machine()})")
v = sys.version_info
if v >= (3, 10):
dire(f"✅ Python {v.major}.{v.minor}.{v.micro}")
else:
ok = False
dire(f"❌ Python {v.major}.{v.minor} : il faut Python 3.10 ou plus récent")
manquants = []
for module, paquet, indispensable in MODULES:
try:
importlib.import_module(module)
except ImportError:
if indispensable:
ok = False
manquants.append(paquet)
dire(f"❌ Module Python {paquet} absent")
else:
dire(f"⚠️ Module Python {paquet} absent : les photos HEIC de l'iPhone seront ignorées")
if manquants:
dire(f" → réinstaller RTG avec pipx (il installe les modules), ou : "
f"python3 -m pip install {' '.join(manquants)}")
if not manquants:
dire("✅ Modules Python")
if sys.version_info < (3, 11):
try:
importlib.import_module("tomli")
except ImportError:
ok = False
dire("❌ Module Python tomli absent (nécessaire avant Python 3.11)")
if programme("exiftool"):
dire(f"✅ exiftool {_version(['exiftool', '-ver'])}")
else:
ok = False
dire(f"❌ exiftool absent (positions GPS des photos et vidéos) → {INSTALLER['exiftool'][SYSTEME]}")
if programme("ffmpeg") and programme("ffprobe"):
dire(f"✅ {_version(['ffmpeg', '-version'])[:60]}")
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True).stdout
for nom, role in (("libx265", "1080p HEVC"), ("libx264", "720p H.264")):
if nom in enc:
dire(f" ✅ {nom} ({role})")
else:
ok = False
dire(f" ❌ {nom} absent de ce ffmpeg ({role}) → installer un ffmpeg complet : "
f"{INSTALLER['ffmpeg'][SYSTEME]}")
if SYSTEME == "mac":
if "hevc_videotoolbox" in enc:
dire(" ✅ VideoToolbox (encodeur « matériel », facultatif)")
else:
dire(" ⚠️ VideoToolbox absent : seul l'encodeur « processeur » sera disponible")
else:
ok = False
dire(f"❌ ffmpeg absent (vidéos et aperçus) → {INSTALLER['ffmpeg'][SYSTEME]}")
dire()
dire("✅ Tout est prêt." if ok else "❌ Il manque des éléments : voir les lignes ❌ ci-dessus.")
return ok
+86 -21
View File
@@ -10,12 +10,13 @@ interrompu ne laisse jamais un fichier abîmé sous le vrai nom.
""" """
import os import os
import subprocess import subprocess
import sys
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from rtg.config import parametres_video from rtg.config import parametres_video
from rtg.outils import coeurs_performance, dire, exiger, lisible, taille_lisible from rtg.outils import ARRET, coeurs_performance, dire, exiger, lisible, taille_lisible
def _encodeurs_disponibles(): def _encodeurs_disponibles():
@@ -42,7 +43,7 @@ def repartition(p):
return jobs, threads, max(2, threads // 2), coeurs return jobs, threads, max(2, threads // 2), coeurs
def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src, stats): def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src):
hd = palier == "hd" hd = palier == "hd"
if hd: if hd:
vf = "scale=-2:1080:flags=lanczos" if hauteur_src > 1080 else None vf = "scale=-2:1080:flags=lanczos" if hauteur_src > 1080 else None
@@ -54,7 +55,7 @@ def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src, stats):
# pool de threads x265. hvc1 : sans lui, Safari refuse la vidéo. # pool de threads x265. hvc1 : sans lui, Safari refuse la vidéo.
vopts = ["-c:v", "libx265", "-preset", p["preset"], "-profile:v", "main", vopts = ["-c:v", "libx265", "-preset", p["preset"], "-profile:v", "main",
"-crf", str(p["crf_hevc"]), "-crf", str(p["crf_hevc"]),
"-x265-params", f"keyint=120:min-keyint=24:aq-mode=3:pools={threads}", "-x265-params", f"keyint=120:min-keyint=24:aq-mode=3:pools={threads}:log-level=error",
"-maxrate", "8M", "-bufsize", "16M", "-tag:v", "hvc1"] "-maxrate", "8M", "-bufsize", "16M", "-tag:v", "hvc1"]
audio = "96k" audio = "96k"
else: else:
@@ -67,9 +68,9 @@ def _commande(src, dest, palier, p, threads, threads_sd, hauteur_src, stats):
"-crf", str(p["crf_h264"]), "-g", "120", "-keyint_min", "24", "-crf", str(p["crf_h264"]), "-g", "120", "-keyint_min", "24",
"-threads", str(threads_sd), "-maxrate", "5M", "-bufsize", "10M"] "-threads", str(threads_sd), "-maxrate", "5M", "-bufsize", "10M"]
audio = "64k" audio = "64k"
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error"] # -progress pipe:1 : avancement lisible par RTG (out_time_us=…) au lieu de
if stats: # la ligne -stats, illisible quand deux encodages tournent ensemble.
cmd.append("-stats") cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-nostats", "-progress", "pipe:1"]
cmd += ["-i", src, "-map", "0:v:0", "-map", "0:a:0?"] + vopts cmd += ["-i", src, "-map", "0:v:0", "-map", "0:a:0?"] + vopts
if vf: if vf:
cmd += ["-vf", vf] cmd += ["-vf", vf]
@@ -89,20 +90,45 @@ def _hauteur(src):
return 1080 return 1080
def _duree(src):
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", src], capture_output=True, text=True)
try:
return float(r.stdout.strip())
except ValueError:
return 0.0
def _suivre(proc, palier, duree, avancement):
"""Lit la sortie -progress de ffmpeg et met à jour avancement[palier] (0-100)."""
for ligne in proc.stdout:
if ligne.startswith(("out_time_us=", "out_time_ms=")) and duree > 0:
try:
avancement[palier] = min(100.0, int(ligne.split("=")[1]) / 1e6 / duree * 100)
except ValueError:
pass
elif ligne.startswith("progress=end"):
avancement[palier] = 100.0
def sorties(voyage, src): def sorties(voyage, src):
base = os.path.splitext(os.path.basename(src))[0] base = os.path.splitext(os.path.basename(src))[0]
dossier = os.path.join(voyage, "videos") dossier = os.path.join(voyage, "videos")
return os.path.join(dossier, base + ".mp4"), os.path.join(dossier, base + "_mobile.mp4") return os.path.join(dossier, base + ".mp4"), os.path.join(dossier, base + "_mobile.mp4")
def deja_faite(voyage, src): def deja_faite(voyage, src, rapide=False):
"""Les deux sorties existent (rapide=True) ou sont lisibles par ffprobe."""
hd, sd = sorties(voyage, src) hd, sd = sorties(voyage, src)
if rapide:
return all(os.path.exists(f) and os.path.getsize(f) > 0 for f in (hd, sd))
return lisible(hd) and lisible(sd) return lisible(hd) and lisible(sd)
def compresser(voyage, cfg, analyse, force=False, progression=None): def compresser(voyage, cfg, analyse, force=False, progression=None):
"""Compresse les rushs du voyage. `progression(nom, etat)` est appelée à """Compresse les rushs du voyage. `progression(nom, etat, pourcentage, rang)`
chaque changement (pour l'interface). Renvoie True si tout a réussi.""" est appelée à chaque changement (pour l'interface) ; rang = (i, total).
Renvoie True si tout a réussi."""
videos = analyse.videos videos = analyse.videos
os.makedirs(os.path.join(voyage, "videos"), exist_ok=True) os.makedirs(os.path.join(voyage, "videos"), exist_ok=True)
p = parametres_video(cfg) p = parametres_video(cfg)
@@ -123,27 +149,60 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
verrou = threading.Lock() verrou = threading.Lock()
bilan = {"faits": 0, "sautes": 0, "echecs": 0, "octets_src": 0, "octets_web": 0} bilan = {"faits": 0, "sautes": 0, "echecs": 0, "octets_src": 0, "octets_web": 0}
def signaler(nom, etat): def signaler(nom, etat, detail=None, rang=None):
if progression: if progression:
progression(nom, etat) progression(nom, etat, detail, rang)
def un_rush(src): total = len(videos)
en_direct = jobs == 1 and sys.stdout.isatty()
def un_rush(arg):
i, src = arg
nom = os.path.basename(src) nom = os.path.basename(src)
rang = f"[{i:3d}/{total}]"
hd, sd = sorties(voyage, src) hd, sd = sorties(voyage, src)
if ARRET.is_set():
return False
if not force and lisible(hd) and lisible(sd): if not force and lisible(hd) and lisible(sd):
with verrou: with verrou:
bilan["sautes"] += 1 bilan["sautes"] += 1
signaler(nom, "deja_faite") dire(f"⏭️ {rang} {nom} — déjà faite")
signaler(nom, "deja_faite", rang=(i, total))
return True return True
h = _hauteur(src) h, duree = _hauteur(src), _duree(src)
dire(f"🎬 {nom} ({h}p) — encodage des 2 paliers en parallèle…") dire(f"🎬 {rang} {nom} ({h}p, {int(duree) // 60} min {int(duree) % 60:02d} s) — encodage…")
signaler(nom, "en_cours") signaler(nom, "en_cours", 0, rang=(i, total))
tmp_hd = os.path.join(os.path.dirname(hd), ".tmp-" + os.path.basename(hd)) tmp_hd = os.path.join(os.path.dirname(hd), ".tmp-" + os.path.basename(hd))
tmp_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd)) tmp_sd = os.path.join(os.path.dirname(sd), ".tmp-" + os.path.basename(sd))
stats = jobs == 1 avancement = {"hd": 0.0, "sd": 0.0}
procs = [subprocess.Popen(_commande(src, tmp_hd, "hd", p, threads, threads_sd, h, stats)), procs, lecteurs = [], []
subprocess.Popen(_commande(src, tmp_sd, "sd", p, threads, threads_sd, h, stats))] for palier, tmp in (("hd", tmp_hd), ("sd", tmp_sd)):
codes = [pr.wait() for pr in procs] pr = subprocess.Popen(_commande(src, tmp, palier, p, threads, threads_sd, h),
stdout=subprocess.PIPE, text=True)
th = threading.Thread(target=_suivre, args=(pr, palier, duree, avancement), daemon=True)
th.start()
procs.append(pr)
lecteurs.append(th)
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 0 < moyenne < 100 and moyenne // 25 > max(dernier, 0) // 25:
dire(f" {nom} : {moyenne} %")
if moyenne != dernier:
signaler(nom, "en_cours", moyenne)
dernier = moyenne
if en_direct:
print()
for th in lecteurs:
th.join(timeout=5)
codes = [pr.returncode for pr in procs]
if codes == [0, 0] and lisible(tmp_hd) and lisible(tmp_sd): if codes == [0, 0] and lisible(tmp_hd) and lisible(tmp_sd):
os.replace(tmp_hd, hd) os.replace(tmp_hd, hd)
os.replace(tmp_sd, sd) os.replace(tmp_sd, sd)
@@ -158,6 +217,10 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
for t in (tmp_hd, tmp_sd): for t in (tmp_hd, tmp_sd):
if os.path.exists(t): if os.path.exists(t):
os.remove(t) os.remove(t)
if ARRET.is_set():
dire(f" ⏹ {nom} : arrêtée")
signaler(nom, "arretee")
return False
with verrou: with verrou:
bilan["echecs"] += 1 bilan["echecs"] += 1
dire(f" ❌ Échec sur {nom}") dire(f" ❌ Échec sur {nom}")
@@ -171,7 +234,7 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
t0 = time.time() t0 = time.time()
with ThreadPoolExecutor(jobs) as pool: with ThreadPoolExecutor(jobs) as pool:
resultats = list(pool.map(un_rush, videos)) resultats = list(pool.map(un_rush, enumerate(videos, 1)))
duree = int(time.time() - t0) duree = int(time.time() - t0)
dire() dire()
if bilan["faits"]: if bilan["faits"]:
@@ -182,4 +245,6 @@ def compresser(voyage, cfg, analyse, force=False, progression=None):
dire(f"ℹ️ Aucun nouvel encodage ({bilan['sautes']} déjà faite(s))") dire(f"ℹ️ Aucun nouvel encodage ({bilan['sautes']} déjà faite(s))")
if bilan["echecs"]: if bilan["echecs"]:
dire(f"⚠️ {bilan['echecs']} échec(s)") dire(f"⚠️ {bilan['echecs']} échec(s)")
if ARRET.is_set():
dire("⏹ Compression arrêtée (les vidéos finies sont gardées).")
return all(resultats) return all(resultats)
+221
View File
@@ -0,0 +1,221 @@
"""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 import acces, corrections
from rtg.interface import serveur
from rtg.interface.securite import Gardien
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]
gardien = Gardien(port)
srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port, gardien)
threading.Thread(target=srv.serve_forever, daemon=True).start()
sid = gardien.ouvrir_session()
def appel(chemin, donnees=None, htmx=True, session=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")
if session:
req.add_header("Cookie", f"{gardien.cookie}={sid}")
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()
appel.port, appel.gardien = port, gardien
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()
class _SansRedirection(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
def test_acces(client, tmp_path, monkeypatch):
appel, racine, voyage = client
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("APPDATA", str(tmp_path / "config"))
base = f"http://127.0.0.1:{appel.port}"
ouvreur = urllib.request.build_opener(_SansRedirection)
def brut(chemin):
try:
return ouvreur.open(base + chemin)
except urllib.error.HTTPError as e:
return e
# Sans session : renvoyé vers la connexion, y compris pour les fichiers du site
for chemin in ("/", "/site/site.toml", "/voyage?v=Ecosse-2027"):
r = brut(chemin)
assert r.status == 303 and r.headers["Location"] == "/connexion", chemin
assert appel("/", session=False)[0] == 200 # htmx : HX-Redirect, corps vide
# La clé d'accès ouvre une session (cookie HttpOnly), une fausse clé non
r = brut("/?jeton=" + appel.gardien.jeton)
assert r.status == 303 and "HttpOnly" in r.headers["Set-Cookie"] and "SameSite=Strict" in r.headers["Set-Cookie"]
assert brut("/?jeton=faux").headers["Location"] == "/connexion"
# Mot de passe : faux, juste, puis blocage après 5 échecs
acces.definir("un mot de passe solide")
code, html = appel("/connexion", {"mot_de_passe": "non"}, session=False)
assert "incorrect" in html
ok, _ = appel.gardien.essayer("10.0.0.1", "un mot de passe solide")
assert ok
for _ in range(5):
appel.gardien.essayer("10.0.0.2", "faux")
ok, message = appel.gardien.essayer("10.0.0.2", "un mot de passe solide")
assert not ok and "Trop d'essais" in message
# En-têtes de sécurité
r = urllib.request.urlopen(urllib.request.Request(base + "/connexion"))
assert r.headers["X-Frame-Options"] == "DENY" and "frame-ancestors 'none'" in r.headers["Content-Security-Policy"]
def test_corrections(client):
appel, racine, voyage = client
# Médias : une photo localisée, une sans position
with open(voyage / "export_photos.csv", "w") as f:
f.write('"P1.jpg",2027:06:11 10:00:00,56.75,2.30,\n"P2.jpg",2027:06:11 11:00:00,,,\n')
code, texte = appel("/voyage/position", {"v": "Ecosse-2027", "fichier": "P2.jpg", "lat": "56.8", "lon": "2.36"})
assert '"deplace": true' in texte
cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
assert corrections.positions(cfg) == {"P2.jpg": (56.8, 2.36)}
# Zone masquée du voyage, puis zone privée du site
appel("/voyage/masque", {"v": "Ecosse-2027", "action": "ajouter", "lat": "56.7", "lon": "2.24", "rayon": "3000"})
appel("/voyage/masque", {"v": "Ecosse-2027", "action": "ajouter", "portee": "site",
"lat": "48.8", "lon": "2.1", "rayon": "500"})
cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
assert corrections.masques(cfg) == [(56.7, 2.24, 3000.0), (48.8, 2.1, 500.0)]
# Génération : la photo placée apparaît, le début d'Etape 2 est retiré
config.modifier_toml(str(voyage / "voyage.toml"), {"dates_traces": {"Etape 1.gpx": "2027-06-10",
"Etape 2.gpx": "2027-06-11"}})
from rtg import pages, sources
cfg = config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
sources.ranger(str(voyage), cfg, sources.analyser(str(voyage), cfg))
v = pages.Voyage(str(voyage), cfg)
assert set(v.df_p["Fichier"]) == {"P1.jpg", "P2.jpg"}
v.lire_traces()
assert v.points_masques > 0
for pistes in v.traces.values():
for piste, pts in pistes:
assert all(corrections.distance_m(la, lo, 56.7, 2.24) > 3000 for la, lo in pts)
def test_derriere_le_serveur_web(voyage_sans_heures, tmp_path, monkeypatch):
"""--proxy --prefixe /admin : adresses préfixées, mot de passe, cookie Secure."""
racine, voyage = voyage_sans_heures
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("APPDATA", str(tmp_path / "config"))
acces.definir("un mot de passe solide")
app = serveur.Application(str(racine), "/admin")
srv = ThreadingHTTPServer(("127.0.0.1", 0), None)
port = srv.server_address[1]
gardien = Gardien(port, proxy=True, prefixe=app.prefixe)
srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port, gardien)
threading.Thread(target=srv.serve_forever, daemon=True).start()
ouvreur = urllib.request.build_opener(_SansRedirection)
entetes = {"Host": "roadtrip.example", "X-Forwarded-For": "203.0.113.7", "X-Forwarded-Proto": "https"}
def requete(chemin, donnees=None, extra=None):
req = urllib.request.Request(f"http://127.0.0.1:{port}{chemin}", headers=dict(entetes, **(extra or {})),
data=urllib.parse.urlencode(donnees).encode() if donnees else None)
try:
return ouvreur.open(req)
except urllib.error.HTTPError as e:
return e
try:
r = requete("/admin/")
assert r.status == 303 and r.headers["Location"] == "/admin/connexion"
assert requete("/").status == 404 # hors du préfixe
assert requete("/admin/connexion").status == 200
r = requete("/admin/connexion", {"mot_de_passe": "un mot de passe solide"}, {"HX-Request": "true"})
cookie = r.headers["Set-Cookie"]
assert r.headers["HX-Redirect"] == "/admin/" and "Path=/admin/" in cookie and "Secure" in cookie
sid = cookie.split(";")[0]
r = requete("/admin/", extra={"Cookie": sid})
html = r.read().decode()
assert r.status == 200 and 'href="/admin/nouveau"' in html and 'href="/"' in html # Voir le site
# Les échecs sont comptés par visiteur (X-Forwarded-For), pas pour le serveur web
for _ in range(5):
requete("/admin/connexion", {"mot_de_passe": "faux"}, {"HX-Request": "true"})
assert gardien.attente("203.0.113.7") > 0 and gardien.attente("127.0.0.1") == 0
finally:
srv.shutdown()
serveur.ENV.globals["base"] = ""