Explorateur : sélection et déplacement multiples, vignettes des schémas #2

Merged
claude Bot merged 3 commits from feat/explorateur-multi-vignettes into main 2026-10-07 14:34:18 +02:00
4 changed files with 317 additions and 1 deletions
Showing only changes of commit 66eb1c08de - Show all commits

No files matched your search

+56
View File
@@ -6,8 +6,10 @@
// PUT /api/file?path=a/b.excalidraw écriture (If-Match / If-None-Match: *)
// POST /api/folder {"path":"a/b"} création de dossier
// POST /api/move {"from":"x","to":"y"} renommage / déplacement
// POST /api/move-many {"paths":[...],"dir":"d"} déplacement multiple
// POST /api/copy {"from":"x","to":"y"} duplication d'un schéma
// 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
package api
@@ -35,8 +37,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("PUT /api/file", h.writeFile)
mux.HandleFunc("POST /api/folder", h.mkdir)
mux.HandleFunc("POST /api/move", h.move)
mux.HandleFunc("POST /api/move-many", h.moveMany)
mux.HandleFunc("POST /api/copy", h.copy)
mux.HandleFunc("DELETE /api/entry", h.delete)
mux.HandleFunc("GET /api/thumb", h.readThumb)
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
mux.HandleFunc("GET /api/library", h.readLibrary)
mux.HandleFunc("PUT /api/library", h.writeLibrary)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
@@ -175,6 +180,57 @@ func (h *Handler) move(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, entry)
}
type moveManyReq struct {
Paths []string `json:"paths"`
Dir string `json:"dir"`
}
// moveMany renvoie toujours 200 avec le détail par élément : le client
// affiche les échecs partiels (ex. nom déjà pris dans la destination).
func (h *Handler) moveMany(w http.ResponseWriter, r *http.Request) {
var req moveManyReq
if !h.decode(w, r, &req) {
return
}
if len(req.Paths) == 0 || len(req.Paths) > 1000 {
writeErr(w, http.StatusBadRequest, "entre 1 et 1000 chemins attendus")
return
}
res, err := h.Store.MoveMany(req.Paths, req.Dir)
if err != nil {
fail(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) readThumb(w http.ResponseWriter, r *http.Request) {
data, mod, err := h.Store.ReadThumb(r.URL.Query().Get("path"))
if err != nil {
// Pas de mise en cache d'une absence : la miniature va être générée.
w.Header().Set("Cache-Control", "no-store")
fail(w, err)
return
}
// L'URL porte la date du schéma (?v=...) : une nouvelle version change l'URL.
w.Header().Set("Cache-Control", "private, max-age=86400")
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Last-Modified", mod.UTC().Format(http.TimeFormat))
_, _ = w.Write(data)
}
func (h *Handler) writeThumb(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, storage.MaxThumbSize))
if err == nil {
err = h.Store.WriteThumb(r.URL.Query().Get("path"), data)
}
if err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) copy(w http.ResponseWriter, r *http.Request) {
var req moveReq
if !h.decode(w, r, &req) {
+59 -1
View File
@@ -25,6 +25,7 @@ const Ext = ".excalidraw"
const (
trashDir = ".trash"
thumbsDir = ".thumbs"
libraryFile = ".library.excalidrawlib"
)
@@ -358,6 +359,7 @@ func (s *Store) Move(from, to string) (Entry, error) {
if err := os.Rename(af, at); err != nil {
return Entry{}, mapErr(err)
}
s.moveThumb(cf, ct, fi.IsDir())
fi, err = os.Stat(at)
if err != nil {
return Entry{}, err
@@ -365,6 +367,55 @@ func (s *Store) Move(from, to string) (Entry, error) {
return toEntry(ct, fi), nil
}
// MoveResult est le résultat d'un déplacement unitaire dans MoveMany.
type MoveResult struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Error string `json:"error,omitempty"`
err error
}
// Err renvoie l'erreur du déplacement (nil si réussi).
func (r MoveResult) Err() error { return r.err }
// MoveMany déplace plusieurs fichiers ou dossiers dans le dossier dir en
// conservant leur nom. Chaque élément est traité indépendamment : un échec
// (destination existante, dossier déplacé dans lui-même...) n'interrompt pas
// les autres. Les éléments déjà présents dans dir sont ignorés.
func (s *Store) MoveMany(paths []string, dir string) ([]MoveResult, error) {
cd, ad, err := s.resolve(dir)
if err != nil {
return nil, err
}
fi, err := os.Stat(ad)
if err != nil {
return nil, mapErr(err)
}
if !fi.IsDir() {
return nil, fmt.Errorf("%w : la destination n'est pas un dossier", ErrInvalidPath)
}
out := make([]MoveResult, 0, len(paths))
for _, p := range paths {
r := MoveResult{From: p}
if c, err := Clean(p); err != nil || c == "" {
r.err = ErrInvalidPath
} else if strings.TrimPrefix(path.Dir("/"+c), "/") == cd {
r.From, r.To = c, c // déjà dans le dossier cible
} else {
r.From = c
var e Entry
if e, r.err = s.Move(c, path.Join(cd, path.Base(c))); r.err == nil {
r.To = e.Path
}
}
if r.err != nil {
r.Error = r.err.Error()
}
out = append(out, r)
}
return out, nil
}
// Copy duplique un schéma (les dossiers ne sont pas copiés).
func (s *Store) Copy(from, to string) (Entry, error) {
data, _, err := s.Read(from)
@@ -372,6 +423,9 @@ func (s *Store) Copy(from, to string) (Entry, error) {
return Entry{}, err
}
e, _, err := s.Write(to, data, "", true)
if err == nil {
s.copyThumb(from, e.Path)
}
return e, err
}
@@ -402,7 +456,11 @@ func (s *Store) Delete(rel string, recursive bool) error {
if err := os.MkdirAll(trash, 0o755); err != nil {
return err
}
return os.Rename(abs, filepath.Join(trash, filepath.Base(abs)))
if err := os.Rename(abs, filepath.Join(trash, filepath.Base(abs))); err != nil {
return err
}
s.removeThumb(c, fi.IsDir())
return nil
}
// ReadLibrary renvoie la bibliothèque Excalidraw partagée (ou nil si absente).
+94
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
const doc = `{"type":"excalidraw","version":2,"elements":[]}`
@@ -99,3 +100,96 @@ func TestSymlinkEscape(t *testing.T) {
t.Fatalf("échappement par symlink non bloqué : %v", err)
}
}
func TestMoveMany(t *testing.T) {
s := newStore(t)
for _, p := range []string{"a.excalidraw", "b.excalidraw", "src/c.excalidraw", "dst/b.excalidraw"} {
if _, _, err := s.Write(p, []byte(doc), "", true); err != nil {
t.Fatal(err)
}
}
if _, err := s.Mkdir("src/sub"); err != nil {
t.Fatal(err)
}
if _, err := s.MoveMany([]string{"a.excalidraw"}, "a.excalidraw"); !errors.Is(err, ErrInvalidPath) {
t.Fatalf("destination fichier acceptée : %v", err)
}
res, err := s.MoveMany([]string{"a.excalidraw", "b.excalidraw", "src/c.excalidraw", "src/sub", "dst/b.excalidraw", "dst", "nope.excalidraw"}, "dst")
if err != nil {
t.Fatal(err)
}
want := []struct {
to string
err error
}{
{"dst/a.excalidraw", nil},
{"", ErrExists},
{"dst/c.excalidraw", nil},
{"dst/sub", nil},
{"dst/b.excalidraw", nil}, // déjà dans dst : ignoré
{"", ErrInvalidPath}, // dossier dans lui-même
{"", ErrNotFound},
}
for i, w := range want {
if res[i].To != w.to || !errors.Is(res[i].Err(), w.err) {
t.Errorf("%s : to=%q err=%v ; attendu %q, %v", res[i].From, res[i].To, res[i].Err(), w.to, w.err)
}
}
if _, _, err := s.Read("b.excalidraw"); err != nil {
t.Fatalf("b déplacé malgré le conflit : %v", err)
}
// Vers la racine.
res, err = s.MoveMany([]string{"dst/sub", "b.excalidraw"}, "")
if err != nil || res[0].To != "sub" || res[0].Err() != nil || res[1].To != "b.excalidraw" {
t.Fatalf("vers la racine : %+v, %v", res, err)
}
}
var png = []byte("\x89PNG\r\n\x1a\nfake")
func TestThumbs(t *testing.T) {
s := newStore(t)
if err := s.WriteThumb("d/a.excalidraw", png); !errors.Is(err, ErrNotFound) {
t.Fatalf("miniature d'un schéma absent : %v", err)
}
if _, _, err := s.Write("d/a.excalidraw", []byte(doc), "", true); err != nil {
t.Fatal(err)
}
if err := s.WriteThumb("d/a.excalidraw", []byte("<svg/>")); !errors.Is(err, ErrInvalidPath) {
t.Fatalf("non-PNG accepté : %v", err)
}
if _, _, err := s.ReadThumb("d/a.excalidraw"); !errors.Is(err, ErrNotFound) {
t.Fatalf("miniature inexistante : %v", err)
}
if err := s.WriteThumb("d/a.excalidraw", png); err != nil {
t.Fatal(err)
}
if b, _, err := s.ReadThumb("d/a.excalidraw"); err != nil || string(b) != string(png) {
t.Fatalf("lecture : %q, %v", b, err)
}
// Suit le déplacement du dossier, la copie, puis la suppression.
if _, err := s.Move("d", "e"); err != nil {
t.Fatal(err)
}
if _, _, err := s.ReadThumb("e/a.excalidraw"); err != nil {
t.Fatalf("après déplacement : %v", err)
}
if _, err := s.Copy("e/a.excalidraw", "e/b.excalidraw"); err != nil {
t.Fatal(err)
}
if _, _, err := s.ReadThumb("e/b.excalidraw"); err != nil {
t.Fatalf("après copie : %v", err)
}
if err := s.Delete("e/a.excalidraw", false); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(s.root, thumbsDir, "e", "a.excalidraw.png")); !os.IsNotExist(err) {
t.Fatalf("miniature non supprimée : %v", err)
}
// Périmée après modification du schéma.
old := time.Now().Add(-time.Hour)
_ = os.Chtimes(filepath.Join(s.root, thumbsDir, "e", "b.excalidraw.png"), old, old)
if _, _, err := s.ReadThumb("e/b.excalidraw"); !errors.Is(err, ErrNotFound) {
t.Fatalf("miniature périmée servie : %v", err)
}
}
+108
View File
@@ -0,0 +1,108 @@
package storage
import (
"bytes"
"fmt"
"os"
"path/filepath"
"time"
)
// Miniatures PNG des schémas, générées par le navigateur et rangées dans
// .thumbs/ en miroir de l'arborescence (a/b.excalidraw -> .thumbs/a/b.excalidraw.png).
// Une miniature plus ancienne que son schéma est considérée comme périmée.
// MaxThumbSize borne la taille d'une miniature acceptée.
const MaxThumbSize = 1 << 20
var pngMagic = []byte("\x89PNG\r\n\x1a\n")
func (s *Store) thumbAbs(c string, isDir bool) string {
p := filepath.Join(s.root, thumbsDir, filepath.FromSlash(c))
if isDir {
return p
}
return p + ".png"
}
// ReadThumb renvoie la miniature d'un schéma et sa date, ou ErrNotFound si
// elle est absente ou périmée.
func (s *Store) ReadThumb(rel string) ([]byte, time.Time, error) {
c, abs, err := s.resolve(rel)
if err != nil {
return nil, time.Time{}, err
}
if err := requireSchemaPath(c); err != nil {
return nil, time.Time{}, err
}
src, err := os.Stat(abs)
if err != nil {
return nil, time.Time{}, mapErr(err)
}
ta := s.thumbAbs(c, false)
ti, err := os.Stat(ta)
if err != nil {
return nil, time.Time{}, mapErr(err)
}
if ti.ModTime().Before(src.ModTime()) {
return nil, time.Time{}, fmt.Errorf("%w : miniature périmée", ErrNotFound)
}
b, err := os.ReadFile(ta)
return b, ti.ModTime(), mapErr(err)
}
// WriteThumb enregistre la miniature PNG d'un schéma existant.
func (s *Store) WriteThumb(rel string, png []byte) error {
c, abs, err := s.resolve(rel)
if err != nil {
return err
}
if err := requireSchemaPath(c); err != nil {
return err
}
if !bytes.HasPrefix(png, pngMagic) || len(png) > MaxThumbSize {
return fmt.Errorf("%w : PNG de %d Ko max attendu", ErrInvalidPath, MaxThumbSize>>10)
}
if _, err := os.Stat(abs); err != nil {
return mapErr(err)
}
ta := s.thumbAbs(c, false)
if err := os.MkdirAll(filepath.Dir(ta), 0o755); err != nil {
return err
}
return atomicWrite(ta, png)
}
// Les opérations suivantes accompagnent Move/Copy/Delete. Elles sont
// « au mieux » : une miniature manquante sera simplement régénérée.
func (s *Store) moveThumb(from, to string, isDir bool) {
src, dst := s.thumbAbs(from, isDir), s.thumbAbs(to, isDir)
if _, err := os.Stat(src); err != nil {
return
}
_ = os.RemoveAll(dst)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
_ = os.Rename(src, dst)
}
}
func (s *Store) copyThumb(from, to string) {
cf, err1 := Clean(from)
ct, err2 := Clean(to)
if err1 != nil || err2 != nil {
return
}
b, err := os.ReadFile(s.thumbAbs(cf, false))
if err != nil {
return
}
dst := s.thumbAbs(ct, false)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
_ = atomicWrite(dst, b) // écrite après la copie du schéma : donc à jour
}
}
func (s *Store) removeThumb(c string, isDir bool) {
_ = os.RemoveAll(s.thumbAbs(c, isDir))
}