Une phase avait quatre lignes de texte brut ; un projet n'avait rien. Les deux
manques se tiennent : la frise ne dit pas qui pilote ni sur quel budget, et ce
contexte finissait dans les notes de la première phase venue, où il ne survit
pas à sa suppression.
Le bloc de notes est le même dans les deux panneaux et prend toute la hauteur
restante : les autres champs ont une taille dictée par leur contenu, une note
fait ce qu'on a à dire. Il s'ouvre sur l'aperçu quand la note existe, sur la
saisie quand elle est vide.
Les cases à cocher se cliquent dans l'aperçu — seul geste de l'aperçu qui touche
aux données. La bascule réécrit la seule ligne visée plutôt que de régénérer la
note, qui verrait sinon sa mise en forme normalisée. Inertes dans la vue par
mois, en lecture seule (décision 24).
La barre d'outils écrit par `document.execCommand('insertText')` et non en
affectant `value`, qui viderait la pile d'annulation du navigateur : `Ctrl+Z`
doit continuer de marcher. Le DOM est bâti nœud par nœud, sans un `innerHTML`.
Les infobulles aplatissent le markdown — `title` ne connaît que le texte —, et
celles d'un jalon et d'un libellé de projet les affichent désormais.
Le format passe en version 4 : le validateur reconstruit chaque projet champ par
champ, un binaire antérieur effacerait les notes de projet à la première
sauvegarde.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
15 KiB
Go
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": 4, "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})
|
|
}
|