Distribuer l'outil comme un binaire Go autonome

serve.py supposait Python installé et une commande tapée dans un terminal
depuis le dossier du dépôt. L'objectif ici : poser l'exécutable et son
planning dans un dossier synchronisé, et les retrouver sur n'importe quelle
machine sans rien installer.

Le binaire embarque l'interface, expose les mêmes routes sur le même format,
et cherche son projets.json à côté de lui. Compilé avec CGO_ENABLED=0, il ne
dépend d'aucune bibliothèque système, pas même de la glibc ; build.sh produit
les cinq plateformes depuis une seule machine.

Le front est inchangé : les modules ES sont embarqués et servis tels quels,
et --dev les sert depuis le disque pour développer sans recompiler.

Deux ajustements dictés par le double-clic, où aucun drapeau ne peut être
passé : le port se cherche à partir de 8000 s'il est occupé, et le fichier de
données est résolu depuis l'exécutable et non depuis le répertoire courant.

La décision 3 est rectifiée au passage : elle affirmait à tort que la File
System Access API ne fonctionne pas depuis file://. Vérification faite, c'est
CORS sur les modules ES qui ferme cette voie, pas la sécurité de l'API.

serve.py est supprimé, le binaire l'ayant remplacé en usage réel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 16:48:59 +02:00
parent 6b15972e3b
commit 558d42f4cb
9 changed files with 940 additions and 259 deletions

12
.gitignore vendored
View File

@@ -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

View File

@@ -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 <http://localhost:8000>. 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 |

41
build.sh Executable file
View File

@@ -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

View File

@@ -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.

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module frise
go 1.22

View File

@@ -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.'
);
}

402
main.go Normal file
View File

@@ -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})
}

352
main_test.go Normal file
View File

@@ -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("<h1>frise</h1>")}},
}
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)
}
}

230
serve.py
View File

@@ -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()