diff --git a/.gitignore b/.gitignore index 1b8e5a5..7cf3f7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,24 @@ # Ce dépôt ne contient que le code : le planning de travail reste local. # data/exemple.json est versionné, lui, comme jeu de démonstration. data/projets.json +# Le binaire cherche ses données à côté de lui : à la racine, donc, quand on +# le lance depuis le dépôt. +/projets.json -# Sauvegardes horodatées écrites par serve.py à chaque enregistrement. +# Sauvegardes horodatées écrites par le serveur à chaque enregistrement. # Régénérables, et jusqu'à 50 fichiers en rotation : uniquement du bruit ici. data/backups/ +/backups/ # Fichier temporaire de l'écriture atomique du serveur. Normalement renommé # aussitôt, il ne subsiste qu'en cas d'arrêt brutal en cours d'écriture. data/*.tmp +/*.json.tmp + +# Binaires compilés, régénérables par ./build.sh +dist/ # Réglages locaux de Claude Code, propres à la machine. .claude/settings.local.json -__pycache__/ -*.py[cod] .DS_Store diff --git a/README.md b/README.md index 6e09d0f..fd26def 100644 --- a/README.md +++ b/README.md @@ -9,26 +9,63 @@ checklists. Une phase représente plusieurs semaines ou plusieurs mois de travai ## Démarrer +Récupérer le binaire de sa plateforme, le poser dans un dossier avec son planning, et le +lancer — par un double-clic ou en ligne de commande : + ```sh -python3 serve.py +./frise ``` -Puis ouvrir . Aucune installation, aucune dépendance, aucune étape de -compilation — ni côté Python (bibliothèque standard uniquement) ni côté navigateur (JavaScript -vanilla en modules ES natifs). +Le navigateur s'ouvre tout seul. **Rien à installer** : l'interface est embarquée dans +l'exécutable, qui ne dépend d'aucune bibliothèque système — pas même de la glibc. Il tourne tel +quel sur n'importe quelle distribution, et sur une machine sans Python ni Node. + +Par défaut, le planning est le fichier `projets.json` **posé à côté de l'exécutable**. C'est ce +qui permet de déposer les deux dans un dossier synchronisé (Dropbox, Nextcloud, Syncthing) et de +retrouver son outil et ses données sur n'importe quelle machine. + +``` +📁 Mon dossier synchronisé + ├── frise ← l'exécutable + └── projets.json ← le planning +``` Options : ```sh -python3 serve.py --port 9000 # changer le port -python3 serve.py --data data/exemple.json # ouvrir un autre fichier de données +./frise --port 9000 # imposer un port (sinon : le premier libre à partir de 8000) +./frise --data ~/plannings/2027.json # ouvrir un autre fichier +./frise --no-browser # ne pas ouvrir le navigateur ``` -Au premier lancement, le planning est vide. Pour découvrir l'outil sur des données -réalistes : +Au premier lancement, le planning est vide et aucun fichier n'est créé tant qu'on n'a rien +saisi. Pour découvrir l'outil sur des données réalistes, copier `data/exemple.json` en +`projets.json` à côté de l'exécutable. + +### Compiler soi-même ```sh -cp data/exemple.json data/projets.json +./build.sh # les cinq plateformes, dans dist/ +./build.sh local # seulement la machine courante +``` + +Il faut [Go](https://go.dev) 1.22 ou plus récent, et rien d'autre : aucune dépendance externe, +aucun `vendor/`. Les binaires des trois systèmes se produisent depuis une seule machine, sans +intégration continue ni SDK tiers. + +### Développer + +```sh +go run . --dev +``` + +Le mode `--dev` sert `index.html`, `css/` et `js/` **depuis le disque** plutôt que depuis le +binaire : un simple rafraîchissement suffit à voir ses modifications, sans recompiler. Il lit +alors `data/projets.json`, comme avant. + +```sh +go test ./... # le serveur +node --test tests/ # le modèle métier ``` ## Prise en main @@ -131,14 +168,20 @@ Tout tient dans un fichier JSON, `data/projets.json`, décrit dans parlants (`site-web`, `cadrage`) plutôt que des UUID : il reste éditable à la main et lisible dans un diff git. -À chaque sauvegarde, le serveur copie la version précédente dans `data/backups/`, horodatée. +À chaque sauvegarde, le serveur copie la version précédente dans un dossier `backups/` voisin du +planning, horodatée. -Le planning n'est **pas versionné** : `.gitignore` exclut `data/projets.json`, ce dépôt ne contenant +Le planning n'est **pas versionné** : `.gitignore` exclut `projets.json`, ce dépôt ne contenant que le code. Seul `data/exemple.json` y figure, comme jeu de démonstration. -**Pour synchroniser plusieurs machines**, placer le dossier du projet dans un espace synchronisé -(Syncthing, Drive). L'option `--data` ne permet pas de pointer ailleurs : le serveur refuse un -fichier situé hors de son propre dossier. +**Pour synchroniser plusieurs machines**, déposer l'exécutable et son `projets.json` dans un +espace synchronisé. L'option `--data` accepte n'importe quel chemin, le planning peut donc aussi +vivre ailleurs que le binaire. + +Deux frictions propres à ce mode de distribution, qui touchent tout exécutable et pas seulement +celui-ci : le bit d'exécution n'est pas toujours préservé par la synchronisation (`chmod +x frise` +une fois par machine), et un binaire non signé déclenche un avertissement au premier lancement — +SmartScreen sous Windows, Gatekeeper sous macOS. L'outil ne gère pas les conflits — si deux machines modifient le planning en même temps, la dernière écriture l'emporte. Les sauvegardes horodatées de `data/backups/` sont le filet de sécurité. @@ -146,18 +189,21 @@ L'outil ne gère pas les conflits — si deux machines modifient le planning en ## Tests ```sh -node --test +node --test # la logique métier +go test ./... # le serveur ``` -Couvre la logique métier : validation des dates, bornes d'un projet, calcul de dérive, génération des -identifiants, normalisation et filtrage des tags. Aucune dépendance, le lanceur de tests intégré à -Node suffit. +Côté navigateur : validation des dates, bornes d'un projet, calcul de dérive, génération des +identifiants, normalisation et filtrage des tags. Côté serveur : lecture et écriture du planning, +refus des corps invalides, rotation des sauvegardes, écriture atomique, résolution du chemin de +données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node et à Go suffisent. ## Organisation du code | Fichier | Rôle | |---|---| -| `serve.py` | Sert les fichiers statiques, expose `GET`/`PUT` sur `/api/data`, écrit les sauvegardes | +| `main.go` | Sert l'interface embarquée, expose `GET`/`PUT` sur `/api/data`, écrit les sauvegardes | +| `build.sh` | Compile les binaires des cinq plateformes dans `dist/` | | `js/model.js` | Données et règles métier : CRUD, validation, bornes, référence, dérive, tags | | `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état | | `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli | diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ebe7ac3 --- /dev/null +++ b/build.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Compile le binaire pour les plateformes usuelles, dans dist/. +# +# Aucune dépendance : CGO_ENABLED=0 produit un exécutable statique, qui ne +# réclame ni glibc ni runtime sur la machine où il atterrit. C'est ce qui +# permet de déposer le binaire dans un dossier synchronisé et de le lancer +# tel quel, quelle que soit la distribution. +# +# ./build.sh toutes les plateformes +# ./build.sh local seulement la machine courante, pour tester vite +set -eu + +cd "$(dirname "$0")" +mkdir -p dist + +# -s -w retirent la table des symboles et les infos de debug : environ 30 % +# de moins à synchroniser à chaque version. +DRAPEAUX='-s -w' + +construire() { + os=$1 + arch=$2 + suffixe=${3:-} + sortie="dist/frise-$os-$arch$suffixe" + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -ldflags="$DRAPEAUX" -o "$sortie" . + printf '%-28s %s\n' "$sortie" "$(du -h "$sortie" | cut -f1)" +} + +go test ./... + +if [ "${1:-}" = local ]; then + CGO_ENABLED=0 go build -ldflags="$DRAPEAUX" -o dist/frise . + printf '%-28s %s\n' dist/frise "$(du -h dist/frise | cut -f1)" + exit 0 +fi + +construire linux amd64 +construire linux arm64 +construire windows amd64 .exe +construire darwin amd64 # Mac Intel +construire darwin arm64 # Apple Silicon diff --git a/docs/decisions.md b/docs/decisions.md index 1221f60..9c8dbc4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -44,11 +44,20 @@ décision 4). La première approche envisagée était d'écrire directement dans le fichier depuis le navigateur, via `showOpenFilePicker` / `createWritable`, pour se passer de tout back-end. -Sauf que cette API exige un **contexte sécurisé** : elle ne fonctionne pas depuis `file://`. Il -fallait donc de toute façon lancer un serveur statique. Le « zéro back-end » était perdu d'avance, -tout en payant trois prix : limitation aux navigateurs Chromium, re-autorisation du sélecteur de -fichier à chaque session, et maintien d'un chemin de repli export/import pour Firefox et Safari, -doublé d'un miroir `localStorage`. +> **Rectification (1ᵉʳ août 2026).** Le paragraphe suivant affirmait que l'API « ne fonctionne pas +> depuis `file://` ». C'est faux, et la vérification sur Chromium 150 le montre : `file://` **est** +> un contexte sécurisé (`isSecureContext === true`), `showSaveFilePicker` y ouvre bien son +> dialogue, et `localStorage` comme IndexedDB y persistent d'une session à l'autre — un handle +> peut donc y être mémorisé. Ce qui bloque réellement en `file://`, c'est autre chose : les +> **modules ES** et `fetch` y sont interdits par CORS (origine `null`), donc c'est la décision 10 +> qui ferme cette voie, pas la sécurité de l'API. La conclusion ci-dessous — lancer un serveur — +> reste la bonne, mais pour ce motif-là. Voir décision 22. + +Cette API exige un **contexte sécurisé**, et l'on avait cru qu'elle ne fonctionnait pas depuis +`file://`. Il fallait donc de toute façon lancer un serveur statique. Le « zéro back-end » était +perdu d'avance, tout en payant trois prix : limitation aux navigateurs Chromium, re-autorisation du +sélecteur de fichier à chaque session, et maintien d'un chemin de repli export/import pour Firefox +et Safari, doublé d'un miroir `localStorage`. Puisqu'une commande doit être lancée dans tous les cas, autant qu'elle serve à quelque chose. Un serveur de la bibliothèque standard, `GET` et `PUT` sur `/api/data`, tient en une centaine de lignes @@ -132,6 +141,13 @@ compilation obsolète. Node n'est utilisé que pour lancer les tests, via son lanceur intégré (`node --test`), sans aucune dépendance. +**Portée après la décision 22.** Le serveur se compile désormais, mais le front, lui, ne se +compile toujours pas : aucun bundler, aucune transformation, aucun `node_modules`. Les fichiers de +`js/` sont embarqués tels quels dans le binaire et servis tels quels au navigateur — c'est bien la +ligne qu'on lit dans l'éditeur qui s'exécute, et `--dev` les sert directement depuis le disque. +Ce qui était visé ici est donc intact ; ce qui change, c'est seulement la façon dont le serveur est +distribué. + ## 11. Un en-tête à trois bandes, la semaine pour grain le plus fin L'en-tête affichait une seule rangée de graduations dont le contenu changeait selon le @@ -439,3 +455,48 @@ même si l'on ferme le panneau sans rien saisir. Il porte alors le nom « Nouveau projet », se voit sur la frise, et se supprime d'un clic depuis son propre panneau — ce qui reste moins coûteux que d'imposer un dialogue à tout le monde à chaque création. + +## 22. Un binaire Go plutôt qu'un script Python + +`serve.py` supposait Python installé et une commande tapée dans un terminal, depuis le dossier du +dépôt. C'est une friction quotidienne pour un outil qu'on veut ouvrir d'un geste, et un obstacle +net sous Windows, où Python est absent par défaut. + +L'objectif visé : **poser l'outil et son planning dans un dossier synchronisé, et les retrouver +sur n'importe quelle machine**. Trois voies menaient là. + +**Un `index.html` autonome en `file://`.** Séduisante — zéro exécutable, longévité maximale — mais +elle impose de renoncer aux modules ES, bloqués par CORS depuis `file://` (voir la rectification en +décision 3), donc de concaténer les sources en un fichier unique. Et surtout elle enferme dans +Chromium : Firefox et Safari n'implémentent pas la File System Access API. + +**Tauri.** Accès direct au disque, sans dialogue ni permission, et le code du navigateur reste +intact. Mais un webview système à installer sous Linux (WebKitGTK), un rendu à vérifier sur trois +moteurs différents, une chaîne Rust et une intégration continue multi-OS pour produire les +binaires. C'est contradictoire avec la décision 10 : une chaîne Tauri de 2026 ne se recompilera pas +aussi sûrement dans dix ans qu'un fichier HTML s'ouvrira. + +**Un binaire Go qui contient le serveur et l'interface**, retenu. Il conserve exactement +l'architecture éprouvée — mêmes routes, même fichier JSON, mêmes sauvegardes horodatées, front +inchangé — en supprimant la seule vraie friction, l'installation. `CGO_ENABLED=0` produit un +exécutable statique de 6 Mo qui ne dépend de rien, pas même de la glibc, et les cinq plateformes se +compilent depuis une seule machine sans intégration continue. + +Face à `file://`, il garde **tous** les navigateurs et les modules ES. Face à Tauri, il n'exige +aucun webview et divise l'outillage par dix. Ce qu'il concède : l'interface s'ouvre dans un onglet +plutôt que dans une fenêtre d'application, et il faut un binaire par plateforme. + +Deux choix de détail méritent d'être notés. + +Le port n'est plus fixe : lancé par un double-clic, l'utilisateur n'a aucun moyen de passer +`--port` si 8000 est occupé. Le serveur essaie donc les dix premiers ports libres à partir de 8000. +Un `--port` explicite, lui, n'est jamais contourné — c'est un choix de l'utilisateur. + +Le fichier de données est cherché **à côté de l'exécutable**, pas dans le répertoire courant, qui +est imprévisible lors d'un double-clic. Le mode `--dev` fait exception : il sert l'interface depuis +le disque et lit `data/projets.json`, pour qu'un rafraîchissement suffise à voir ses modifications +sans recompiler. + +`serve.py` a été supprimé une fois le binaire validé en usage réel. Il n'y avait rien à migrer : le +format du fichier et les routes sont identiques, et son historique reste dans git si le besoin de +le relire se présentait. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e9077d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module frise + +go 1.22 diff --git a/js/storage.js b/js/storage.js index 77062fd..7d31e6d 100644 --- a/js/storage.js +++ b/js/storage.js @@ -89,8 +89,8 @@ export function creerStockage(surEtat = () => {}) { reponse = await fetch(URL_DONNEES, { headers: { Accept: 'application/json' } }); } catch { throw new Error( - "Serveur injoignable. L'application doit être ouverte via « python3 serve.py », " + - 'pas en ouvrant index.html directement.' + "Serveur injoignable. L'application doit être ouverte en lançant l'exécutable " + + '« frise », pas en ouvrant index.html directement.' ); } diff --git a/main.go b/main.go new file mode 100644 index 0000000..ee47891 --- /dev/null +++ b/main.go @@ -0,0 +1,402 @@ +// Serveur local de la frise multi-projets, distribué comme exécutable unique. +// +// GET /api/data -> renvoie le JSON du planning +// PUT /api/data -> le remplace, après copie horodatée dans backups/ +// +// L'interface (index.html, css/, js/) est embarquée dans le binaire. Il n'y a +// donc rien à installer et rien à poser à côté, sinon le fichier de données — +// ce qui permet de déposer l'exécutable et son planning dans un dossier +// synchronisé et de retrouver les deux sur n'importe quelle machine. +// +// Ce n'est volontairement pas un back-end : aucune logique métier ici, le +// serveur lit et écrit un fichier. Les règles sont dans js/model.js. +// Voir docs/decisions.md, sections 3 et 22. +package main + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "sort" + "strings" + "syscall" + "time" +) + +//go:embed index.html css js +var interfaceEmbarquee embed.FS + +// Au-delà, on refuse la requête : un planning macro pèse quelques kilo-octets, +// un corps de cette taille signale forcément une erreur. +const tailleMax = 5 << 20 + +// Nombre de sauvegardes conservées par fichier de données. Les plus anciennes +// sont supprimées pour que backups/ ne grossisse pas indéfiniment. +const backupsConserves = 50 + +// Premier port tenté. En cas d'occupation on essaie les suivants : lancé par +// un double-clic, l'utilisateur n'a aucun moyen de passer --port. +const portInitial = 8000 +const portsTentes = 10 + +var donneesInitiales = []byte(`{"version": 3, "projects": []}`) + +type serveur struct { + fichierDonnees string + interfaceWeb fs.FS +} + +func main() { + port := flag.Int("port", 0, "port d'écoute (défaut : le premier libre à partir de 8000)") + data := flag.String("data", "", "fichier de données (défaut : projets.json à côté de l'exécutable)") + dev := flag.Bool("dev", false, "servir l'interface depuis le disque au lieu du binaire") + sansNavigateur := flag.Bool("no-browser", false, "ne pas ouvrir le navigateur au démarrage") + flag.Parse() + + fichier, err := resoudreFichierDonnees(*data, *dev) + if err != nil { + echouer(err) + } + + srv := &serveur{fichierDonnees: fichier} + if *dev { + // Le répertoire courant, pour recharger le JS et le CSS d'un simple + // rafraîchissement sans recompiler. + srv.interfaceWeb = os.DirFS(".") + } else { + srv.interfaceWeb = interfaceEmbarquee + } + + ecouteur, err := ecouter(*port) + if err != nil { + echouer(err) + } + + url := fmt.Sprintf("http://localhost:%d", ecouteur.Addr().(*net.TCPAddr).Port) + fmt.Printf("Frise multi-projets -> %s\n", url) + fmt.Printf("Données : %s\n", fichier) + fmt.Println("Ctrl+C pour arrêter.") + fmt.Println() + + if !*sansNavigateur { + ouvrirNavigateur(url) + } + + if err := servir(ecouteur, srv.routes()); err != nil { + echouer(err) + } + fmt.Println("Arrêt.") +} + +// --- démarrage ------------------------------------------------------------ + +// resoudreFichierDonnees applique la règle du chemin par défaut : à côté de +// l'exécutable en usage normal, dans data/ quand on développe depuis le dépôt. +func resoudreFichierDonnees(demande string, dev bool) (string, error) { + if demande != "" { + return filepath.Abs(demande) + } + if dev { + return filepath.Abs(filepath.Join("data", "projets.json")) + } + + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("impossible de localiser l'exécutable : %w", err) + } + // Un lien symbolique placé dans le PATH ne doit pas faire chercher les + // données à côté du lien, mais à côté du binaire réel. + if resolu, err := filepath.EvalSymlinks(executable); err == nil { + executable = resolu + } + return filepath.Join(filepath.Dir(executable), "projets.json"), nil +} + +// ecouter ouvre le port demandé, ou cherche le premier libre si aucun n'est +// imposé. Un port explicite qui échoue est une erreur : l'utilisateur l'a +// choisi, on ne le contourne pas silencieusement. +func ecouter(port int) (net.Listener, error) { + if port != 0 { + ecouteur, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, fmt.Errorf("impossible d'écouter sur le port %d : %w", port, err) + } + return ecouteur, nil + } + + for essai := 0; essai < portsTentes; essai++ { + ecouteur, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", portInitial+essai)) + if err == nil { + return ecouteur, nil + } + } + return nil, fmt.Errorf( + "aucun port libre entre %d et %d. Un autre exemplaire tourne peut-être déjà ; "+ + "sinon, préciser --port", portInitial, portInitial+portsTentes-1) +} + +// servir écoute jusqu'à Ctrl+C, puis laisse les écritures en cours se terminer. +func servir(ecouteur net.Listener, routes http.Handler) error { + ctx, arreter := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer arreter() + + httpSrv := &http.Server{Handler: routes} + fini := make(chan error, 1) + go func() { fini <- httpSrv.Serve(ecouteur) }() + + select { + case err := <-fini: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + fmt.Println() + extinction, annuler := context.WithTimeout(context.Background(), 5*time.Second) + defer annuler() + return httpSrv.Shutdown(extinction) + } +} + +func ouvrirNavigateur(url string) { + var commande *exec.Cmd + switch runtime.GOOS { + case "windows": + commande = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + commande = exec.Command("open", url) + default: + commande = exec.Command("xdg-open", url) + } + // Un échec n'est pas bloquant : l'URL est déjà affichée, l'utilisateur peut + // la coller lui-même. C'est le cas sur une machine sans xdg-utils. + _ = commande.Start() +} + +func echouer(err error) { + fmt.Fprintln(os.Stderr, "Erreur :", err) + os.Exit(1) +} + +// --- routes --------------------------------------------------------------- + +func (s *serveur) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/data", s.donnees) + mux.Handle("/", http.FileServerFS(s.interfaceWeb)) + return sansCache(mux) +} + +// sansCache s'applique à toutes les routes, et depuis un seul endroit. +// +// Filtrer sur l'extension ne suffirait pas : la page d'accueil est servie sur +// « / », donc elle passerait au travers et le navigateur resservirait un +// index.html périmé après chaque modification. +func sansCache(suivant http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + suivant.ServeHTTP(w, r) + }) +} + +func (s *serveur) donnees(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.lireDonnees(w) + case http.MethodPut: + s.ecrireDonnees(w, r) + default: + w.Header().Set("Allow", "GET, PUT") + erreur(w, http.StatusMethodNotAllowed, "Méthode non autorisée sur /api/data.") + } +} + +func (s *serveur) lireDonnees(w http.ResponseWriter) { + contenu, err := os.ReadFile(s.fichierDonnees) + if errors.Is(err, fs.ErrNotExist) { + // Premier lancement : on répond un planning vide sans rien écrire sur + // le disque. Le fichier naîtra à la première sauvegarde. + envoyer(w, http.StatusOK, donneesInitiales) + return + } + if err != nil { + erreur(w, http.StatusInternalServerError, fmt.Sprintf("Lecture impossible : %v", err)) + return + } + + if !json.Valid(contenu) { + // On refuse de servir un fichier corrompu : l'application ne doit + // jamais démarrer sur des données douteuses. + erreur(w, http.StatusInternalServerError, fmt.Sprintf( + "%s n'est pas un JSON valide. Le corriger à la main ou repartir d'une "+ + "sauvegarde de %s.", filepath.Base(s.fichierDonnees), s.dossierBackups())) + return + } + + envoyer(w, http.StatusOK, contenu) +} + +func (s *serveur) ecrireDonnees(w http.ResponseWriter, r *http.Request) { + if r.ContentLength > tailleMax { + erreur(w, http.StatusRequestEntityTooLarge, "Corps de requête trop volumineux.") + return + } + + corps, err := io.ReadAll(http.MaxBytesReader(w, r.Body, tailleMax)) + if err != nil { + erreur(w, http.StatusRequestEntityTooLarge, "Corps de requête trop volumineux.") + return + } + if len(corps) == 0 { + erreur(w, http.StatusBadRequest, "Corps de requête vide.") + return + } + + // On valide avant d'écrire : mieux vaut refuser une sauvegarde que + // remplacer un planning correct par du JSON cassé. + var racine map[string]json.RawMessage + if err := json.Unmarshal(corps, &racine); err != nil { + erreur(w, http.StatusBadRequest, fmt.Sprintf("JSON invalide : %v", err)) + return + } + if _, ok := racine["projects"]; !ok { + erreur(w, http.StatusBadRequest, "Le JSON doit être un objet contenant « projects ».") + return + } + + // json.Indent plutôt qu'un ré-encodage : il préserve l'ordre des clés + // et les accents littéraux, pour que le fichier reste lisible et que + // deux versions successives se comparent proprement. + var indente bytes.Buffer + if err := json.Indent(&indente, corps, "", " "); err != nil { + erreur(w, http.StatusBadRequest, fmt.Sprintf("JSON invalide : %v", err)) + return + } + indente.WriteByte('\n') + + if err := s.sauvegarderVersionPrecedente(); err != nil { + erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) + return + } + if err := s.ecrireAtomiquement(indente.Bytes()); err != nil { + erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) + return + } + + repondreJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "savedAt": time.Now().UTC().Format(time.RFC3339), + }) +} + +// --- écriture sur disque -------------------------------------------------- + +func (s *serveur) dossierBackups() string { + return filepath.Join(filepath.Dir(s.fichierDonnees), "backups") +} + +// sauvegarderVersionPrecedente copie le fichier actuel dans backups/ avant de +// l'écraser. +func (s *serveur) sauvegarderVersionPrecedente() error { + contenu, err := os.ReadFile(s.fichierDonnees) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + + dossier := s.dossierBackups() + if err := os.MkdirAll(dossier, 0o755); err != nil { + return err + } + + base := strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees)) + marqueur := time.Now().Format("20060102-150405") + cible := filepath.Join(dossier, fmt.Sprintf("%s-%s.json", base, marqueur)) + + // Deux sauvegardes dans la même seconde : on suffixe pour ne pas écraser + // la précédente. + for compteur := 1; existe(cible); compteur++ { + cible = filepath.Join(dossier, fmt.Sprintf("%s-%s-%d.json", base, marqueur, compteur)) + } + + if err := os.WriteFile(cible, contenu, 0o644); err != nil { + return err + } + return elaguerBackups(dossier, base) +} + +// ecrireAtomiquement écrit dans un fichier temporaire puis remplace, pour +// qu'une coupure en cours d'écriture ne laisse jamais un planning tronqué. +func (s *serveur) ecrireAtomiquement(contenu []byte) error { + if err := os.MkdirAll(filepath.Dir(s.fichierDonnees), 0o755); err != nil { + return err + } + // Le temporaire doit partager le dossier de la cible : un rename n'est + // atomique qu'à l'intérieur d'un même système de fichiers. + temporaire := s.fichierDonnees + ".tmp" + if err := os.WriteFile(temporaire, contenu, 0o644); err != nil { + return err + } + if err := os.Rename(temporaire, s.fichierDonnees); err != nil { + os.Remove(temporaire) + return err + } + return nil +} + +func elaguerBackups(dossier, base string) error { + entrees, err := filepath.Glob(filepath.Join(dossier, base+"-*.json")) + if err != nil { + return err + } + // L'horodatage est en tête et de longueur fixe : l'ordre lexicographique + // est l'ordre chronologique. + sort.Strings(entrees) + for i := 0; i < len(entrees)-backupsConserves; i++ { + if err := os.Remove(entrees[i]); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + return nil +} + +func existe(chemin string) bool { + _, err := os.Stat(chemin) + return err == nil +} + +// --- réponses ------------------------------------------------------------- + +func envoyer(w http.ResponseWriter, statut int, corps []byte) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(statut) + _, _ = w.Write(corps) +} + +func repondreJSON(w http.ResponseWriter, statut int, charge any) { + corps, err := json.Marshal(charge) + if err != nil { + http.Error(w, "encodage impossible", http.StatusInternalServerError) + return + } + envoyer(w, statut, corps) +} + +func erreur(w http.ResponseWriter, statut int, message string) { + repondreJSON(w, statut, map[string]string{"error": message}) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0f474d9 --- /dev/null +++ b/main_test.go @@ -0,0 +1,352 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +// serveurDeTest monte un serveur sur un dossier temporaire. Le fichier de +// données n'existe pas encore : c'est l'état d'un premier lancement. +func serveurDeTest(t *testing.T) (*serveur, http.Handler) { + t.Helper() + dossier := t.TempDir() + s := &serveur{ + fichierDonnees: filepath.Join(dossier, "projets.json"), + interfaceWeb: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("

frise

")}}, + } + return s, s.routes() +} + +func appeler(t *testing.T, routes http.Handler, methode, cible, corps string) *httptest.ResponseRecorder { + t.Helper() + var lecteur *strings.Reader + if corps == "" { + lecteur = strings.NewReader("") + } else { + lecteur = strings.NewReader(corps) + } + req := httptest.NewRequest(methode, cible, lecteur) + rec := httptest.NewRecorder() + routes.ServeHTTP(rec, req) + return rec +} + +func ecrireFichier(t *testing.T, chemin, contenu string) { + t.Helper() + if err := os.WriteFile(chemin, []byte(contenu), 0o644); err != nil { + t.Fatalf("préparation du fichier : %v", err) + } +} + +func lireFichier(t *testing.T, chemin string) string { + t.Helper() + contenu, err := os.ReadFile(chemin) + if err != nil { + t.Fatalf("lecture du fichier : %v", err) + } + return string(contenu) +} + +// --- lecture -------------------------------------------------------------- + +func TestGetSansFichierRenvoiePlanningVide(t *testing.T) { + s, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodGet, "/api/data", "") + + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200", rec.Code) + } + + var planning struct { + Version int `json:"version"` + Projects []any `json:"projects"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil { + t.Fatalf("réponse illisible : %v", err) + } + // Le format courant est la version 3 (js/model.js, VERSION_FORMAT). + if planning.Version != 3 { + t.Errorf("version = %d, attendu 3", planning.Version) + } + if len(planning.Projects) != 0 { + t.Errorf("projects = %v, attendu vide", planning.Projects) + } + + // Rien ne doit avoir été écrit : le fichier naît à la première sauvegarde. + if existe(s.fichierDonnees) { + t.Error("le fichier de données a été créé alors qu'aucune écriture n'a eu lieu") + } +} + +func TestGetRenvoieLeFichierTelQuel(t *testing.T) { + s, routes := serveurDeTest(t) + contenu := "{\n \"version\": 3,\n \"projects\": [],\n \"note\": \"éàü\"\n}\n" + ecrireFichier(t, s.fichierDonnees, contenu) + + rec := appeler(t, routes, http.MethodGet, "/api/data", "") + + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200", rec.Code) + } + if rec.Body.String() != contenu { + t.Errorf("corps = %q, attendu %q", rec.Body.String(), contenu) + } +} + +func TestGetRefuseUnFichierCorrompu(t *testing.T) { + s, routes := serveurDeTest(t) + ecrireFichier(t, s.fichierDonnees, "{ceci n'est pas du JSON") + + rec := appeler(t, routes, http.MethodGet, "/api/data", "") + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("statut = %d, attendu 500", rec.Code) + } + // Le message doit orienter vers la réparation, pas seulement constater. + if !strings.Contains(rec.Body.String(), "backups") { + t.Errorf("le message n'oriente pas vers les sauvegardes : %s", rec.Body.String()) + } +} + +// --- écriture ------------------------------------------------------------- + +func TestPutEcritLeFichierIndente(t *testing.T) { + s, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodPut, "/api/data", + `{"version":3,"projects":[{"id":"a","name":"Réunion"}]}`) + + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200 (%s)", rec.Code, rec.Body.String()) + } + + ecrit := lireFichier(t, s.fichierDonnees) + if !strings.HasSuffix(ecrit, "}\n") { + t.Errorf("le fichier ne se termine pas par une nouvelle ligne : %q", ecrit) + } + if !strings.Contains(ecrit, "\n \"version\": 3") { + t.Errorf("le fichier n'est pas indenté de deux espaces : %s", ecrit) + } + // L'ordre des clés du client est conservé : « version » avant « projects ». + if strings.Index(ecrit, "version") > strings.Index(ecrit, "projects") { + t.Errorf("l'ordre des clés n'a pas été préservé : %s", ecrit) + } + // Les accents restent littéraux, pour que le fichier se lise à l'œil. + if !strings.Contains(ecrit, "Réunion") { + t.Errorf("les accents ont été échappés : %s", ecrit) + } +} + +func TestPutRefuseUnJSONInvalideSansToucherAuFichier(t *testing.T) { + s, routes := serveurDeTest(t) + origine := "{\n \"version\": 3,\n \"projects\": []\n}\n" + ecrireFichier(t, s.fichierDonnees, origine) + + rec := appeler(t, routes, http.MethodPut, "/api/data", "{cassé") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("statut = %d, attendu 400", rec.Code) + } + if lireFichier(t, s.fichierDonnees) != origine { + t.Error("le fichier a été modifié alors que la requête était invalide") + } + if existe(s.dossierBackups()) { + t.Error("une sauvegarde a été créée pour une requête refusée") + } +} + +func TestPutRefuseUnObjetSansProjects(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodPut, "/api/data", `{"version":3}`) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("statut = %d, attendu 400", rec.Code) + } +} + +func TestPutRefuseUnTableauALaRacine(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodPut, "/api/data", `[1,2,3]`) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("statut = %d, attendu 400", rec.Code) + } +} + +func TestPutRefuseUnCorpsVide(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodPut, "/api/data", "") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("statut = %d, attendu 400", rec.Code) + } +} + +func TestPutRefuseUnCorpsTropVolumineux(t *testing.T) { + _, routes := serveurDeTest(t) + + req := httptest.NewRequest(http.MethodPut, "/api/data", strings.NewReader(`{"projects":[]}`)) + req.ContentLength = tailleMax + 1 + rec := httptest.NewRecorder() + routes.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("statut = %d, attendu 413", rec.Code) + } +} + +// --- sauvegardes ---------------------------------------------------------- + +func TestPutSauvegardeLaVersionPrecedente(t *testing.T) { + s, routes := serveurDeTest(t) + origine := "{\n \"version\": 3,\n \"projects\": [],\n \"marqueur\": \"ancien\"\n}\n" + ecrireFichier(t, s.fichierDonnees, origine) + + rec := appeler(t, routes, http.MethodPut, "/api/data", `{"version":3,"projects":[]}`) + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200", rec.Code) + } + + sauvegardes, err := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) + if err != nil || len(sauvegardes) != 1 { + t.Fatalf("sauvegardes = %v (err %v), attendu une seule", sauvegardes, err) + } + if lireFichier(t, sauvegardes[0]) != origine { + t.Error("la sauvegarde ne contient pas la version précédente") + } +} + +func TestElaguerBackupsNeGardeQueLesPlusRecents(t *testing.T) { + dossier := t.TempDir() + // Horodatages croissants : l'ordre lexicographique doit être l'ordre + // chronologique, c'est sur quoi repose l'élagage. + for i := 0; i < backupsConserves+5; i++ { + nom := fmt.Sprintf("projets-20260801-%06d.json", i) + ecrireFichier(t, filepath.Join(dossier, nom), "{}") + } + + if err := elaguerBackups(dossier, "projets"); err != nil { + t.Fatalf("élagage : %v", err) + } + + restants, _ := filepath.Glob(filepath.Join(dossier, "projets-*.json")) + if len(restants) != backupsConserves { + t.Fatalf("restants = %d, attendu %d", len(restants), backupsConserves) + } + // Les cinq plus anciens doivent avoir disparu, pas d'autres. + if strings.Contains(strings.Join(restants, " "), "-000004.json") { + t.Error("un fichier ancien a survécu à l'élagage") + } + if !strings.Contains(strings.Join(restants, " "), "-000005.json") { + t.Error("un fichier qui devait être conservé a été supprimé") + } +} + +func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) { + s, routes := serveurDeTest(t) + ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`) + + for i := 0; i < 3; i++ { + rec := appeler(t, routes, http.MethodPut, "/api/data", + fmt.Sprintf(`{"version":3,"projects":[],"n":%d}`, i)) + if rec.Code != http.StatusOK { + t.Fatalf("écriture %d : statut %d", i, rec.Code) + } + } + + sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) + if len(sauvegardes) != 3 { + t.Errorf("sauvegardes = %d, attendu 3 — des copies se sont écrasées", len(sauvegardes)) + } +} + +func TestEcritureAtomiqueNeLaissePasDeTemporaire(t *testing.T) { + s, routes := serveurDeTest(t) + + appeler(t, routes, http.MethodPut, "/api/data", `{"version":3,"projects":[]}`) + + if existe(s.fichierDonnees + ".tmp") { + t.Error("le fichier temporaire subsiste après l'écriture") + } +} + +// --- routes --------------------------------------------------------------- + +func TestMethodeNonAutoriseeSurApiData(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodDelete, "/api/data", "") + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("statut = %d, attendu 405", rec.Code) + } +} + +func TestInterfaceServieDepuisLeBinaire(t *testing.T) { + _, routes := serveurDeTest(t) + + rec := appeler(t, routes, http.MethodGet, "/", "") + + if rec.Code != http.StatusOK { + t.Fatalf("statut = %d, attendu 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "frise") { + t.Errorf("index.html non servi : %s", rec.Body.String()) + } +} + +func TestAucuneReponseNEstMiseEnCache(t *testing.T) { + s, routes := serveurDeTest(t) + ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`) + + for _, cible := range []string{"/", "/api/data"} { + rec := appeler(t, routes, http.MethodGet, cible, "") + if rec.Header().Get("Cache-Control") != "no-store" { + t.Errorf("%s : Cache-Control = %q, attendu no-store", + cible, rec.Header().Get("Cache-Control")) + } + } +} + +// --- chemin des données --------------------------------------------------- + +func TestFichierDonneesParDefautEstACoteDeLExecutable(t *testing.T) { + chemin, err := resoudreFichierDonnees("", false) + if err != nil { + t.Fatalf("résolution : %v", err) + } + + executable, _ := os.Executable() + attendu := filepath.Join(filepath.Dir(executable), "projets.json") + // Le binaire de test peut être un lien ; on compare après résolution. + if resolu, err := filepath.EvalSymlinks(executable); err == nil { + attendu = filepath.Join(filepath.Dir(resolu), "projets.json") + } + if chemin != attendu { + t.Errorf("chemin = %q, attendu %q", chemin, attendu) + } +} + +func TestOptionDataLEmporteSurLeDefaut(t *testing.T) { + chemin, err := resoudreFichierDonnees(filepath.Join(t.TempDir(), "ailleurs.json"), false) + if err != nil { + t.Fatalf("résolution : %v", err) + } + if filepath.Base(chemin) != "ailleurs.json" { + t.Errorf("chemin = %q, attendu un fichier ailleurs.json", chemin) + } + if !filepath.IsAbs(chemin) { + t.Errorf("chemin = %q, attendu un chemin absolu", chemin) + } +} diff --git a/serve.py b/serve.py deleted file mode 100644 index d9d57c7..0000000 --- a/serve.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -"""Micro-serveur pour la frise multi-projets. - -Sert les fichiers statiques du dossier courant et expose le fichier de données : - - GET /api/data -> renvoie le JSON du planning - PUT /api/data -> le remplace, apres copie horodatee dans data/backups/ - -Bibliotheque standard uniquement, aucune dependance a installer. - -Ce n'est volontairement pas un back-end : il n'y a ici aucune logique metier, le -serveur ne fait que lire et ecrire un fichier. Voir docs/decisions.md, section 3. -""" - -import argparse -import json -import shutil -import sys -from datetime import datetime, timezone -from functools import partial -from http import HTTPStatus -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path - -RACINE = Path(__file__).resolve().parent - -# Au-dela, on refuse la requete : un planning macro pese quelques kilo-octets, -# un corps de cette taille signale forcement une erreur. -TAILLE_MAX = 5 * 1024 * 1024 - -# Nombre de sauvegardes conservees par fichier de donnees. Les plus anciennes -# sont supprimees pour que data/backups/ ne grossisse pas indefiniment. -BACKUPS_CONSERVES = 50 - -DONNEES_INITIALES = {"version": 2, "projects": []} - - -class Handler(SimpleHTTPRequestHandler): - """Fichiers statiques, plus deux routes pour le fichier de donnees.""" - - def __init__(self, *args, fichier_donnees: Path, **kwargs): - self.fichier_donnees = fichier_donnees - super().__init__(*args, directory=str(RACINE), **kwargs) - - # --- routes ----------------------------------------------------------- - - def do_GET(self): - if self.path.split("?")[0] == "/api/data": - self._lire_donnees() - return - super().do_GET() - - def do_PUT(self): - if self.path.split("?")[0] == "/api/data": - self._ecrire_donnees() - return - self._erreur(HTTPStatus.NOT_FOUND, "Route inconnue.") - - # --- implementation des routes ---------------------------------------- - - def _lire_donnees(self): - if not self.fichier_donnees.exists(): - # Premier lancement : on repond un planning vide sans rien ecrire - # sur le disque. Le fichier naitra a la premiere sauvegarde. - self._json(HTTPStatus.OK, DONNEES_INITIALES) - return - - try: - contenu = self.fichier_donnees.read_text(encoding="utf-8") - json.loads(contenu) # on refuse de servir un fichier corrompu - except json.JSONDecodeError as err: - self._erreur( - HTTPStatus.INTERNAL_SERVER_ERROR, - f"{self.fichier_donnees.name} n'est pas un JSON valide : {err}. " - "Le corriger a la main ou repartir d'une sauvegarde de data/backups/.", - ) - return - except OSError as err: - self._erreur(HTTPStatus.INTERNAL_SERVER_ERROR, f"Lecture impossible : {err}") - return - - self._envoyer(HTTPStatus.OK, contenu.encode("utf-8"), "application/json") - - def _ecrire_donnees(self): - try: - taille = int(self.headers.get("Content-Length", 0)) - except ValueError: - self._erreur(HTTPStatus.BAD_REQUEST, "En-tete Content-Length invalide.") - return - - if taille <= 0: - self._erreur(HTTPStatus.BAD_REQUEST, "Corps de requete vide.") - return - if taille > TAILLE_MAX: - self._erreur(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Corps de requete trop volumineux.") - return - - corps = self.rfile.read(taille) - - # On valide avant d'ecrire : mieux vaut refuser une sauvegarde que - # remplacer un planning correct par du JSON casse. - try: - donnees = json.loads(corps.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError) as err: - self._erreur(HTTPStatus.BAD_REQUEST, f"JSON invalide : {err}") - return - - if not isinstance(donnees, dict) or "projects" not in donnees: - self._erreur(HTTPStatus.BAD_REQUEST, "Le JSON doit etre un objet contenant 'projects'.") - return - - try: - self._sauvegarder_version_precedente() - self._ecrire_atomiquement(donnees) - except OSError as err: - self._erreur(HTTPStatus.INTERNAL_SERVER_ERROR, f"Ecriture impossible : {err}") - return - - self._json(HTTPStatus.OK, {"ok": True, "savedAt": _horodatage_iso()}) - - # --- ecriture sur disque ---------------------------------------------- - - def _sauvegarder_version_precedente(self): - """Copie le fichier actuel dans data/backups/ avant de l'ecraser.""" - if not self.fichier_donnees.exists(): - return - - dossier = self.fichier_donnees.parent / "backups" - dossier.mkdir(parents=True, exist_ok=True) - - marqueur = datetime.now().strftime("%Y%m%d-%H%M%S") - cible = dossier / f"{self.fichier_donnees.stem}-{marqueur}.json" - - # Deux sauvegardes dans la meme seconde : on suffixe pour ne pas - # ecraser la precedente. - compteur = 1 - while cible.exists(): - cible = dossier / f"{self.fichier_donnees.stem}-{marqueur}-{compteur}.json" - compteur += 1 - - shutil.copy2(self.fichier_donnees, cible) - _elaguer_backups(dossier, self.fichier_donnees.stem) - - def _ecrire_atomiquement(self, donnees): - """Ecrit dans un fichier temporaire puis remplace, pour qu'une coupure - en cours d'ecriture ne laisse jamais un planning tronque.""" - self.fichier_donnees.parent.mkdir(parents=True, exist_ok=True) - temporaire = self.fichier_donnees.with_suffix(".json.tmp") - texte = json.dumps(donnees, indent=2, ensure_ascii=False) + "\n" - temporaire.write_text(texte, encoding="utf-8") - temporaire.replace(self.fichier_donnees) - - # --- helpers de reponse ----------------------------------------------- - - def _json(self, statut, charge): - self._envoyer(statut, json.dumps(charge, ensure_ascii=False).encode("utf-8"), - "application/json") - - def _erreur(self, statut, message): - self._json(statut, {"error": message}) - - def _envoyer(self, statut, corps, type_contenu): - self.send_response(statut) - self.send_header("Content-Type", f"{type_contenu}; charset=utf-8") - self.send_header("Content-Length", str(len(corps))) - self.end_headers() # ajoute Cache-Control: no-store - self.wfile.write(corps) - - def end_headers(self): - # Aucun cache, sur aucune route, et emis ici seulement pour n'avoir - # qu'un seul endroit responsable de cet en-tete. - # - # Filtrer sur l'extension ne suffirait pas : la page d'accueil est - # servie sur « / », donc elle passerait au travers et Chromium - # resservirait un index.html perime apres chaque modification. - self.send_header("Cache-Control", "no-store") - super().end_headers() - - def log_message(self, format, *args): - # Une ligne par requete suffit ; on retire l'adresse, toujours locale. - sys.stderr.write(f" {format % args}\n") - - -def _elaguer_backups(dossier: Path, prefixe: str): - fichiers = sorted(dossier.glob(f"{prefixe}-*.json")) - for vieux in fichiers[:-BACKUPS_CONSERVES]: - vieux.unlink(missing_ok=True) - - -def _horodatage_iso(): - return datetime.now(timezone.utc).isoformat(timespec="seconds") - - -def main(): - analyseur = argparse.ArgumentParser(description="Serveur local de la frise multi-projets.") - analyseur.add_argument("--port", type=int, default=8000, help="port d'ecoute (defaut : 8000)") - analyseur.add_argument("--data", default="data/projets.json", - help="fichier de donnees (defaut : data/projets.json)") - arguments = analyseur.parse_args() - - fichier = Path(arguments.data) - if not fichier.is_absolute(): - fichier = RACINE / fichier - fichier = fichier.resolve() - - if RACINE not in fichier.parents: - analyseur.error("--data doit designer un fichier situe dans le dossier du projet.") - - handler = partial(Handler, fichier_donnees=fichier) - - try: - serveur = ThreadingHTTPServer(("127.0.0.1", arguments.port), handler) - except OSError as err: - print(f"Impossible d'ecouter sur le port {arguments.port} : {err}", file=sys.stderr) - print("Un autre serveur tourne peut-etre deja. Essayer --port 8001.", file=sys.stderr) - raise SystemExit(1) - - print(f"Frise multi-projets -> http://localhost:{arguments.port}") - print(f"Donnees : {fichier.relative_to(RACINE)}") - print("Ctrl+C pour arreter.\n") - - try: - serveur.serve_forever() - except KeyboardInterrupt: - print("\nArret.") - serveur.shutdown() - - -if __name__ == "__main__": - main()