Files
gestion_projets/main.go
Bertrand Benjamin 4a513f9a59 Repousser un projet en bloc, sauf ce dont on ne décide pas
Glisser la barre résumé décale toutes les phases du même nombre de jours,
écarts conservés : un projet engagé glisse souvent, et le faire phase par
phase les déformait. Restent en place ce qui est terminé — on ne réécrit
pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour
ce qui est délégué ou tenu du dehors.

D'où le format en version 6 : sans l'incrément, un binaire antérieur
effacerait ces dates imposées à la première sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:07:31 +02:00

475 lines
15 KiB
Go

// 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"
"sync"
"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
// Intervalle minimal entre deux sauvegardes tant que le serveur tourne.
//
// L'interface enregistre après une seconde d'inactivité : copier le planning à
// chaque écriture produisait des dizaines de fichiers quasi identiques par
// séance, qui chassaient les états anciens et ne remontaient pas plus loin que
// la dernière demi-heure. Une sauvegarde est prise à l'ouverture — l'état
// d'avant la séance, celui qu'on veut retrouver — puis au plus une par jour si
// le serveur reste allumé longtemps. Voir docs/decisions.md, section 23.
const intervalleBackup = 24 * time.Hour
// 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
// Doit suivre VERSION_FORMAT dans js/model.js : le fichier créé au premier
// lancement est immédiatement relu par le front, qui refuse une version plus
// récente que la sienne — mais accepterait sans rien dire une version plus
// ancienne, et la réécrirait au format courant.
var donneesInitiales = []byte(`{"version": 6, "projects": []}`)
type serveur struct {
fichierDonnees string
interfaceWeb fs.FS
// Sérialise les écritures : la copie de sauvegarde et le remplacement du
// fichier forment un tout, que deux onglets ouverts ne doivent pas
// entrelacer.
mu sync.Mutex
dernierBackup time.Time
}
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)
// L'état d'avant la séance : c'est celui qu'on voudra retrouver si la
// séance tourne mal. Un échec ici n'empêche pas de travailler, mais il doit
// se voir — l'utilisateur croirait sinon avoir un filet qu'il n'a pas.
if err := srv.sauvegarderVersionPrecedente(true); err != nil {
fmt.Fprintf(os.Stderr, "Attention : sauvegarde d'ouverture impossible (%v)\n", err)
}
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')
s.mu.Lock()
err = s.sauvegarderVersionPrecedente(false)
if err == nil {
err = s.ecrireAtomiquement(indente.Bytes())
}
s.mu.Unlock()
if 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/, si la
// copie apporte quelque chose.
//
// Deux garde-fous évitent d'inonder le dossier : l'intervalle minimal, que
// « forcer » outrepasse à l'ouverture, et la comparaison au dernier état
// sauvegardé — rouvrir l'outil sans avoir rien modifié n'a pas à laisser de
// trace.
func (s *serveur) sauvegarderVersionPrecedente(forcer bool) error {
if !forcer && time.Since(s.dernierBackup) < intervalleBackup {
return nil
}
contenu, err := os.ReadFile(s.fichierDonnees)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if precedent, trouve := s.dernierEtatSauvegarde(); trouve && bytes.Equal(precedent, contenu) {
// Rien n'a bougé depuis la dernière copie. On note tout de même le
// passage, pour ne pas relire le dossier à chaque écriture.
s.dernierBackup = time.Now()
return nil
}
dossier := s.dossierBackups()
if err := os.MkdirAll(dossier, 0o755); err != nil {
return err
}
base := s.baseNom()
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
}
s.dernierBackup = time.Now()
return elaguerBackups(dossier, base)
}
// baseNom est le nom du planning sans son extension, préfixe commun à ses
// sauvegardes.
func (s *serveur) baseNom() string {
return strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees))
}
// dernierEtatSauvegarde renvoie le contenu de la sauvegarde la plus récente.
// L'horodatage est en tête et de longueur fixe : l'ordre lexicographique est
// l'ordre chronologique.
func (s *serveur) dernierEtatSauvegarde() ([]byte, bool) {
entrees, err := filepath.Glob(filepath.Join(s.dossierBackups(), s.baseNom()+"-*.json"))
if err != nil || len(entrees) == 0 {
return nil, false
}
sort.Strings(entrees)
contenu, err := os.ReadFile(entrees[len(entrees)-1])
if err != nil {
return nil, false
}
return contenu, true
}
// 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})
}