From 7da52b6755a18964fdb8e28bbfbd532f75e0d239 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 16:00:43 +0200 Subject: [PATCH] =?UTF-8?q?Serveur=20:=20canal=20d'Excalidraw=20suivi=20(l?= =?UTF-8?q?atest/next)=20dans=20les=20param=C3=A8tres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lecture des dist-tags npm (latest, next) en une requête ; /api/update expose tags{latest,next} (latest conservé pour compatibilité). - Paramètre channel (latest par défaut) dans DATA_DIR/.settings.json, modifiable par PUT /api/settings {"channel":...} ; titre et canal sont mis à jour indépendamment, sous verrou. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019EuvvuTv7AUc8ksDHv8VKx --- internal/api/api.go | 14 ++++-- internal/storage/settings.go | 77 +++++++++++++++++++++++-------- internal/storage/settings_test.go | 23 ++++++++- internal/storage/storage.go | 3 ++ internal/update/update.go | 70 ++++++++++++++++++++-------- internal/update/update_test.go | 12 +++-- 6 files changed, 153 insertions(+), 46 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 643fc33..23f4db6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -11,7 +11,7 @@ // DELETE /api/entry?path=x[&recursive=true] mise à la corbeille // GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma // GET /api/library / PUT /api/library bibliothèque Excalidraw -// GET /api/settings / PUT {"title":"..."} personnalisation (titre, logo) +// GET /api/settings / PUT {"title","channel"} paramètres (titre, logo, canal) // GET /api/logo / PUT (image/png) / DELETE logo 64x64 // GET /api/update dernière version d'Excalidraw sur npm package api @@ -303,8 +303,10 @@ func (h *Handler) readSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, st) } +// settingsReq : seuls les champs présents sont modifiés. type settingsReq struct { - Title string `json:"title"` + Title *string `json:"title"` + Channel *string `json:"channel"` } func (h *Handler) writeSettings(w http.ResponseWriter, r *http.Request) { @@ -312,7 +314,13 @@ func (h *Handler) writeSettings(w http.ResponseWriter, r *http.Request) { if !h.decode(w, r, &req) { return } - st, err := h.Store.WriteTitle(req.Title) + st, err := h.Store.ReadSettings() + if err == nil && req.Title != nil { + st, err = h.Store.WriteTitle(*req.Title) + } + if err == nil && req.Channel != nil { + st, err = h.Store.WriteChannel(*req.Channel) + } if err != nil { fail(w, err) return diff --git a/internal/storage/settings.go b/internal/storage/settings.go index 3ac0fab..805b928 100644 --- a/internal/storage/settings.go +++ b/internal/storage/settings.go @@ -14,10 +14,12 @@ import ( "strconv" "strings" "unicode/utf8" + + "github.com/vblogio/excalidraw-go/internal/update" ) -// Personnalisation de l'interface (titre, logo), partagée par tous les -// navigateurs : .settings.json et .logo.png à la racine. +// Paramètres de l'interface (titre, logo, canal d'Excalidraw), partagés par +// tous les navigateurs : .settings.json et .logo.png à la racine. const ( settingsFile = ".settings.json" @@ -36,37 +38,83 @@ const ( // ErrInvalidImage signale un logo illisible ou hors limites. var ErrInvalidImage = errors.New("image invalide") +// DefaultChannel est le canal npm d'Excalidraw suivi par défaut (versions stables). +const DefaultChannel = "latest" + // Settings est l'état exposé au client. Logo vaut "" sans logo, sinon une // version (date) à ajouter à l'URL pour invalider le cache navigateur. +// Channel est le canal npm d'Excalidraw (latest ou next) dont l'interface +// signale les nouvelles versions. type Settings struct { - Title string `json:"title"` - Logo string `json:"logo"` + Title string `json:"title"` + Logo string `json:"logo"` + Channel string `json:"channel"` } type settingsDisk struct { - Title string `json:"title"` + Title string `json:"title"` + Channel string `json:"channel,omitempty"` } -// ReadSettings renvoie la personnalisation courante (valeurs vides par défaut). -func (s *Store) ReadSettings() (Settings, error) { +func (s *Store) readSettingsDisk() (settingsDisk, error) { var d settingsDisk b, err := os.ReadFile(filepath.Join(s.root, settingsFile)) switch { case errors.Is(err, fs.ErrNotExist): case err != nil: - return Settings{}, err + return d, err default: if err := json.Unmarshal(b, &d); err != nil { - return Settings{}, fmt.Errorf("%s : %w", settingsFile, err) + return d, fmt.Errorf("%s : %w", settingsFile, err) } } - st := Settings{Title: d.Title} + return d, nil +} + +// updateSettings applique edit au fichier de paramètres (lecture, modification, +// écriture atomique) sous le verrou du Store. +func (s *Store) updateSettings(edit func(*settingsDisk)) (Settings, error) { + s.settingsMu.Lock() + defer s.settingsMu.Unlock() + d, err := s.readSettingsDisk() + if err != nil { + return Settings{}, err + } + edit(&d) + b, err := json.Marshal(d) + if err != nil { + return Settings{}, err + } + if err := atomicWrite(filepath.Join(s.root, settingsFile), b); err != nil { + return Settings{}, err + } + return s.ReadSettings() +} + +// ReadSettings renvoie les paramètres courants (valeurs par défaut si absents). +func (s *Store) ReadSettings() (Settings, error) { + d, err := s.readSettingsDisk() + if err != nil { + return Settings{}, err + } + st := Settings{Title: d.Title, Channel: d.Channel} + if !update.ValidChannel(st.Channel) { + st.Channel = DefaultChannel + } if fi, err := os.Stat(filepath.Join(s.root, logoFile)); err == nil { st.Logo = strconv.FormatInt(fi.ModTime().UnixNano(), 36) } return st, nil } +// WriteChannel enregistre le canal npm d'Excalidraw suivi (latest ou next). +func (s *Store) WriteChannel(channel string) (Settings, error) { + if !update.ValidChannel(channel) { + return Settings{}, fmt.Errorf("%w : canal %q (latest ou next)", ErrInvalidPath, channel) + } + return s.updateSettings(func(d *settingsDisk) { d.Channel = channel }) +} + // WriteTitle enregistre le titre affiché ("" = titre par défaut). func (s *Store) WriteTitle(title string) (Settings, error) { title = strings.TrimSpace(title) @@ -76,14 +124,7 @@ func (s *Store) WriteTitle(title string) (Settings, error) { if utf8.RuneCountInString(title) > MaxTitleLen { return Settings{}, fmt.Errorf("%w : titre de %d caractères max", ErrInvalidPath, MaxTitleLen) } - b, err := json.Marshal(settingsDisk{Title: title}) - if err != nil { - return Settings{}, err - } - if err := atomicWrite(filepath.Join(s.root, settingsFile), b); err != nil { - return Settings{}, err - } - return s.ReadSettings() + return s.updateSettings(func(d *settingsDisk) { d.Title = title }) } // ReadLogo renvoie le logo PNG 64x64, ou ErrNotFound. diff --git a/internal/storage/settings_test.go b/internal/storage/settings_test.go index 06ed017..159736d 100644 --- a/internal/storage/settings_test.go +++ b/internal/storage/settings_test.go @@ -21,7 +21,7 @@ func encodePNG(t *testing.T, img image.Image) []byte { func TestTitle(t *testing.T) { s := newStore(t) - if st, err := s.ReadSettings(); err != nil || st != (Settings{}) { + if st, err := s.ReadSettings(); err != nil || st != (Settings{Channel: DefaultChannel}) { t.Fatalf("défauts : %+v, %v", st, err) } st, err := s.WriteTitle(" Schémas vLab ") @@ -42,6 +42,27 @@ func TestTitle(t *testing.T) { } } +func TestChannel(t *testing.T) { + s := newStore(t) + if _, err := s.WriteTitle("Titre"); err != nil { + t.Fatal(err) + } + st, err := s.WriteChannel("next") + if err != nil || st.Channel != "next" || st.Title != "Titre" { + t.Fatalf("canal : %+v, %v", st, err) + } + // Changer le titre conserve le canal, et inversement. + if st, _ := s.WriteTitle("Autre"); st.Channel != "next" { + t.Fatalf("canal perdu : %+v", st) + } + if _, err := s.WriteChannel("rc"); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("canal invalide accepté : %v", err) + } + if st, _ := s.ReadSettings(); st.Channel != "next" || st.Title != "Autre" { + t.Fatalf("relu : %+v", st) + } +} + func TestLogo(t *testing.T) { s := newStore(t) if _, err := s.ReadLogo(); !errors.Is(err, ErrNotFound) { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index c25ed99..b28ee59 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -17,6 +17,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" ) @@ -50,6 +51,8 @@ type Entry struct { // Store est un dépôt de schémas enraciné dans un répertoire. type Store struct { root string + + settingsMu sync.Mutex // sérialise les lectures-modifications de .settings.json } // New crée le répertoire racine si besoin et renvoie un Store. diff --git a/internal/update/update.go b/internal/update/update.go index edd60d4..bcd61f8 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -1,12 +1,14 @@ -// Package update surveille la dernière version publiée de @excalidraw/excalidraw -// sur le registre npm. Le frontend la compare à la version embarquée au build et -// signale qu'une reconstruction de l'image est possible. +// Package update surveille les versions publiées de @excalidraw/excalidraw sur +// le registre npm (canaux latest et next). Le frontend compare celle du canal +// choisi dans les paramètres à la version embarquée au build et signale qu'une +// reconstruction de l'image est possible. package update import ( "context" "encoding/json" "fmt" + "io" "log/slog" "net/http" "sync" @@ -16,11 +18,26 @@ import ( // Package suivi sur le registre npm. const Package = "@excalidraw/excalidraw" +// Channels sont les dist-tags proposés : latest (versions stables) et next +// (préversion publiée à chaque modification de la branche principale). +var Channels = []string{"latest", "next"} + +// ValidChannel indique si ch est un canal proposé. +func ValidChannel(ch string) bool { + for _, c := range Channels { + if c == ch { + return true + } + } + return false +} + // Status est la réponse de GET /api/update. type Status struct { - Enabled bool `json:"enabled"` - Latest string `json:"latest,omitempty"` // vide tant que le registre n'a pas répondu - CheckedAt *time.Time `json:"checkedAt,omitempty"` // dernière interrogation réussie + Enabled bool `json:"enabled"` + Tags map[string]string `json:"tags,omitempty"` // canal -> version ; vide tant que le registre n'a pas répondu + Latest string `json:"latest,omitempty"` // = Tags["latest"] (compatibilité) + CheckedAt *time.Time `json:"checkedAt,omitempty"` // dernière interrogation réussie } // Checker interroge le registre au démarrage puis à intervalle régulier. @@ -48,7 +65,7 @@ func (c *Checker) Run(ctx context.Context) { } func (c *Checker) check(ctx context.Context) { - v, err := c.fetch(ctx) + tags, err := c.fetch(ctx) if err != nil { if ctx.Err() == nil { slog.Warn("vérification de mise à jour d'Excalidraw", "err", err) @@ -57,36 +74,42 @@ func (c *Checker) check(ctx context.Context) { } now := time.Now().UTC() c.mu.Lock() - c.status = Status{Enabled: true, Latest: v, CheckedAt: &now} + c.status = Status{Enabled: true, Tags: tags, Latest: tags["latest"], CheckedAt: &now} c.mu.Unlock() } -func (c *Checker) fetch(ctx context.Context) (string, error) { +// fetch lit les dist-tags du paquet (une seule requête, réponse de quelques +// centaines d'octets) et ne garde que les canaux proposés. +func (c *Checker) fetch(ctx context.Context) (map[string]string, error) { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Registry+"/"+Package+"/latest", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Registry+"/-/package/"+Package+"/dist-tags", nil) if err != nil { - return "", err + return nil, err } req.Header.Set("Accept", "application/json") res, err := c.Client.Do(req) if err != nil { - return "", err + return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return "", fmt.Errorf("registre npm : %s", res.Status) + return nil, fmt.Errorf("registre npm : %s", res.Status) } - var body struct { - Version string `json:"version"` + var all map[string]string + if err := json.NewDecoder(io.LimitReader(res.Body, 1<<16)).Decode(&all); err != nil { + return nil, fmt.Errorf("registre npm : %w", err) } - if err := json.NewDecoder(res.Body).Decode(&body); err != nil { - return "", fmt.Errorf("registre npm : %w", err) + tags := map[string]string{} + for _, ch := range Channels { + if v := all[ch]; v != "" { + tags[ch] = v + } } - if body.Version == "" { - return "", fmt.Errorf("registre npm : version absente") + if tags["latest"] == "" { + return nil, fmt.Errorf("registre npm : dist-tag latest absent") } - return body.Version, nil + return tags, nil } // Status renvoie le dernier état connu. Un Checker nil signifie « désactivé ». @@ -98,5 +121,12 @@ func (c *Checker) Status() Status { defer c.mu.RUnlock() s := c.status s.Enabled = true + if s.Tags != nil { // copie : la map est partagée avec les lectures concurrentes + tags := make(map[string]string, len(s.Tags)) + for k, v := range s.Tags { + tags[k] = v + } + s.Tags = tags + } return s } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 37ee5cf..47a7a0a 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -10,11 +10,11 @@ import ( func TestCheck(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/@excalidraw/excalidraw/latest" { + if r.URL.Path != "/-/package/@excalidraw/excalidraw/dist-tags" { http.NotFound(w, r) return } - _, _ = w.Write([]byte(`{"name":"@excalidraw/excalidraw","version":"0.19.0"}`)) + _, _ = w.Write([]byte(`{"latest":"0.19.0","next":"0.19.0-abc1234","rc":"0.19.0-rc.1"}`)) })) defer srv.Close() @@ -23,9 +23,13 @@ func TestCheck(t *testing.T) { t.Fatalf("avant vérification : %+v", s) } c.check(context.Background()) - if s := c.Status(); s.Latest != "0.19.0" || s.CheckedAt == nil { + s := c.Status() + if s.Latest != "0.19.0" || s.Tags["next"] != "0.19.0-abc1234" || s.CheckedAt == nil { t.Fatalf("après vérification : %+v", s) } + if _, ok := s.Tags["rc"]; ok { + t.Fatalf("canal non proposé exposé : %+v", s.Tags) + } } func TestCheckKeepsLastValueOnError(t *testing.T) { @@ -35,7 +39,7 @@ func TestCheckKeepsLastValueOnError(t *testing.T) { http.Error(w, "panne", http.StatusBadGateway) return } - _, _ = w.Write([]byte(`{"version":"0.18.2"}`)) + _, _ = w.Write([]byte(`{"latest":"0.18.2"}`)) })) defer srv.Close()