API : déplacement multiple (/api/move-many) et miniatures (/api/thumb)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZ78TqR7fgtRx3rvpNUbAL
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 12:45:21 +02:00
1 parent 91aba3d232
commit 66eb1c08de
4 files changed
+317 -1

No files matched your search

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