diff --git a/README.md b/README.md index 475ccba..c7324c2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # excalidraw-go -Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), recherche, import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur, **historique des versions** avec comparaison visuelle et restauration. Interface web unique, responsive (l'explorateur devient un tiroir sur mobile). +Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), **recherche plein texte** (noms et contenu des schémas), import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur, **historique des versions** avec comparaison visuelle et restauration. Interface web unique, responsive (l'explorateur devient un tiroir sur mobile). - **Backend** : Go 1.24, bibliothèque standard uniquement (aucune dépendance externe), API REST/JSON, stockage en fichiers `.excalidraw` sur un volume. - **Frontend** : React 19 + composant officiel [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw) 0.18.1 (licence MIT), build Vite **embarqué dans le binaire Go** (`embed`). @@ -93,6 +93,12 @@ Rendues par le navigateur avec `exportToBlob` d'Excalidraw (PNG 320 px), puis st Une miniature plus ancienne que son schéma est ignorée (`404`) puis régénérée. Elles suivent les renommages, déplacements, copies et suppressions ; `.thumbs/` peut être supprimé sans risque. En thème sombre, elles sont inversées en CSS comme le canevas Excalidraw. +### Recherche + +Le champ de recherche de l'explorateur cherche dans les noms de fichiers et de dossiers **et dans le contenu des schémas** : textes, libellés des formes et des flèches, noms des cadres, liens. Taper `172.27.207` retrouve tous les schémas qui mentionnent ce réseau, avec l'extrait correspondant sous le nom. Tous les mots saisis doivent être présents (dans le nom ou le contenu) ; casse et accents sont ignorés (`replication` trouve « Réplication »). + +L'index est tenu en mémoire par le serveur : le texte de chaque schéma est extrait à la première recherche, puis relu seulement si le fichier a changé (date ou taille), y compris pour les fichiers modifiés hors de l'application. Rien n'est écrit sur le volume. + ### Historique des versions Avant qu'un schéma soit écrasé, son contenu précédent est copié dans `DATA_DIR/.history//.excalidraw` (fichier Excalidraw complet, ouvrable tel quel). L'enregistrement automatique écrit toutes les 1,5 s pendant l'édition : une version n'est donc conservée que si la précédente date d'au moins `HISTORY_INTERVAL` (10 min par défaut), ce qui donne une version par séance de travail ou par tranche de 10 minutes. Les `HISTORY_KEEP` plus récentes sont gardées, et `HISTORY_MAX_AGE` purge les plus anciennes. @@ -118,7 +124,7 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co | Méthode | Route | Corps / paramètres | Réponse | |---|---|---|---| | GET | `/api/list?path=dir` | | `[Entry]` (dossiers puis schémas) | -| GET | `/api/search?q=texte` | | `[Entry]` (200 max) | +| GET | `/api/search?q=texte` | | `[Entry + "snippet"]` (200 max) : noms puis contenu, tous les mots requis | | GET | `/api/file?path=x.excalidraw` | | JSON du schéma + `ETag` | | PUT | `/api/file?path=x.excalidraw` | JSON ; `If-Match: ` ou `If-None-Match: *` (création) | `Entry` + `ETag` ; `409` existe, `412` conflit | | POST | `/api/folder` | `{"path":"a/b"}` | `Entry` | diff --git a/frontend/src/Explorer.tsx b/frontend/src/Explorer.tsx index f5c5245..dc2bc7a 100644 --- a/frontend/src/Explorer.tsx +++ b/frontend/src/Explorer.tsx @@ -10,6 +10,7 @@ import { stripExt, withExt, type Entry, + type Hit, } from "./api"; import { CloseIcon, @@ -84,7 +85,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi const [dir, setDir] = useState(() => (current ? dirname(current) : "")); const [entries, setEntries] = useState([]); const [query, setQuery] = useState(""); - const [results, setResults] = useState(null); + const [results, setResults] = useState(null); const [view, setView] = useState(initialView); const [selected, setSelected] = useState>(new Set()); const [anchor, setAnchor] = useState(null); // origine d'une sélection Maj+clic @@ -436,7 +437,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi ); - const item = (e: Entry) => { + const item = (e: Hit) => { const isSel = selected.has(e.path); const cls = [ "item", @@ -472,6 +473,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi {e.isDir ? e.name : stripExt(e.name)} {results && {dirname(e.path) || "/"}} + {e.snippet && {e.snippet}} {!e.isDir && view === "list" && {fmtDate(e.modTime)}} @@ -551,7 +553,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi setQuery(ev.target.value)} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 883a804..15cfdf0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -8,6 +8,11 @@ export interface Entry { modTime: string; } +/** Résultat de recherche ; snippet = extrait du contenu qui correspond. */ +export interface Hit extends Entry { + snippet?: string; +} + export interface MoveResult { from: string; to?: string; @@ -72,7 +77,7 @@ export const api = { list: async (dir: string): Promise => (await request(`/api/list?path=${q(dir)}`)).json(), - search: async (text: string): Promise => + search: async (text: string): Promise => (await request(`/api/search?q=${q(text)}`)).json(), read: async (path: string): Promise<{ text: string; etag: string }> => { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3e193e5..1514077 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1127,3 +1127,16 @@ li.active > .row { display: none; } } + +/* Extrait du contenu qui correspond à la recherche. */ +.row .label .snippet { + display: block; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11.5px; + color: var(--ex-on-primary-container); + opacity: 0.8; +} +.entries.grid .snippet { + text-align: center; +} diff --git a/internal/api/api.go b/internal/api/api.go index 4b0f11d..5d2871c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,7 +1,7 @@ // Package api expose le Store en REST/JSON. // // GET /api/list?path=dir contenu d'un dossier -// GET /api/search?q=texte recherche par nom +// GET /api/search?q=texte recherche (noms et texte des schémas) // GET /api/file?path=a/b.excalidraw lecture (ETag en en-tête) // PUT /api/file?path=a/b.excalidraw écriture (If-Match / If-None-Match: *) // POST /api/folder {"path":"a/b"} création de dossier diff --git a/internal/storage/search.go b/internal/storage/search.go new file mode 100644 index 0000000..a7d2a32 --- /dev/null +++ b/internal/storage/search.go @@ -0,0 +1,223 @@ +package storage + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" +) + +// Recherche plein texte : noms des fichiers et dossiers, et contenu des +// schémas (textes et libellés, noms de cadres, liens). Le texte de chaque +// schéma est extrait une fois puis gardé en mémoire tant que le fichier ne +// change pas (date et taille) : seule la première recherche lit tous les +// schémas, les suivantes ne relisent que ceux modifiés. + +// Hit est un résultat de recherche. Snippet est l'extrait du contenu qui +// correspond (vide si seul le nom correspond). +type Hit struct { + Entry + Snippet string `json:"snippet,omitempty"` +} + +type indexDoc struct { + mod time.Time + size int64 + text string // texte extrait, une ligne par élément + fold string // texte normalisé (minuscules, sans accents), même nombre de runes +} + +type textIndex struct { + mu sync.Mutex + docs map[string]indexDoc +} + +// fold met en minuscules et retire les accents des lettres latines courantes, +// rune pour rune : la position d'une correspondance vaut pour le texte d'origine. +func fold(s string) string { + return strings.Map(func(r rune) rune { + r = unicode.ToLower(r) + if f, ok := foldMap[r]; ok { + return f + } + return r + }, s) +} + +var foldMap = func() map[rune]rune { + m := map[rune]rune{} + for base, accented := range map[rune]string{ + 'a': "àáâãäåāăą", 'c': "çćĉċč", 'd': "ď", 'e': "èéêëēĕėęě", 'g': "ĝğġģ", + 'i': "ìíîïĩīĭįı", 'n': "ñńņňʼn", 'o': "òóôõöøōŏő", 'r': "ŕŗř", 's': "śŝşš", + 't': "ţťŧ", 'u': "ùúûüũūŭůűų", 'y': "ýÿŷ", 'z': "źżž", + } { + for _, r := range accented { + m[r] = base + } + } + return m +}() + +// sceneText extrait le texte d'un schéma Excalidraw (éléments non supprimés). +func sceneText(data []byte) string { + var doc struct { + Elements []struct { + Text string `json:"text"` + OriginalText string `json:"originalText"` + Name string `json:"name"` + Link string `json:"link"` + IsDeleted bool `json:"isDeleted"` + } `json:"elements"` + } + if json.Unmarshal(data, &doc) != nil { + return "" + } + var b strings.Builder + add := func(s string) { + s = strings.Join(strings.Fields(s), " ") // retours à la ligne du texte replié + if s != "" { + b.WriteString(s) + b.WriteByte('\n') + } + } + for _, e := range doc.Elements { + if e.IsDeleted { + continue + } + if e.OriginalText != "" { + add(e.OriginalText) + } else { + add(e.Text) + } + add(e.Name) + add(e.Link) + } + return b.String() +} + +// content renvoie le texte indexé d'un schéma, relu seulement s'il a changé. +func (s *Store) content(rel, abs string, fi fs.FileInfo) indexDoc { + d, ok := s.index.docs[rel] + if ok && d.mod.Equal(fi.ModTime()) && d.size == fi.Size() { + return d + } + d = indexDoc{mod: fi.ModTime(), size: fi.Size()} + if b, err := os.ReadFile(abs); err == nil { + d.text = sceneText(b) + d.fold = fold(d.text) + } + s.index.docs[rel] = d + return d +} + +// snippet renvoie la ligne du texte qui contient la position i (en octets +// de fold), raccourcie autour de la correspondance. +func snippet(d indexDoc, i, n int) string { + runes := []rune(d.text) + at := utf8.RuneCountInString(d.fold[:i]) + end := at + utf8.RuneCountInString(d.fold[i:i+n]) + lo, hi := at, end + for lo > 0 && runes[lo-1] != '\n' { + lo-- + } + for hi < len(runes) && runes[hi] != '\n' { + hi++ + } + const ctx = 40 + pre, post := "", "" + if at-lo > ctx { + lo, pre = at-ctx, "…" + } + if hi-end > ctx { + hi, post = end+ctx, "…" + } + return pre + string(runes[lo:hi]) + post +} + +// Search renvoie les entrées dont le nom ou, pour un schéma, le contenu +// contient tous les mots de q (casse et accents ignorés). Les correspondances +// sur le nom viennent en premier. +func (s *Store) Search(q string, limit int) ([]Hit, error) { + terms := strings.Fields(fold(q)) + if len(terms) == 0 { + return []Hit{}, nil + } + s.index.mu.Lock() + defer s.index.mu.Unlock() + if s.index.docs == nil { + s.index.docs = map[string]indexDoc{} + } + seen := map[string]bool{} + var byName, byContent []Hit + err := filepath.WalkDir(s.root, func(p string, d fs.DirEntry, err error) error { + if err != nil || p == s.root { + return nil + } + if strings.HasPrefix(d.Name(), ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if !visible(d.Name(), d.IsDir()) || d.Type()&fs.ModeSymlink != 0 { + return nil + } + fi, err := d.Info() + if err != nil { + return nil + } + rel, err := filepath.Rel(s.root, p) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + name := fold(d.Name()) + var doc indexDoc + if !d.IsDir() { + seen[rel] = true + doc = s.content(rel, p, fi) + } + hit := Hit{Entry: toEntry(rel, fi)} + inName := true + for _, t := range terms { + if strings.Contains(name, t) { + continue + } + inName = false + i := strings.Index(doc.fold, t) + if i < 0 { + return nil + } + if hit.Snippet == "" { + hit.Snippet = snippet(doc, i, len(t)) + } + } + if inName { + byName = append(byName, hit) + } else { + byContent = append(byContent, hit) + } + return nil + }) + if err != nil { + return nil, err + } + for rel := range s.index.docs { + if !seen[rel] { + delete(s.index.docs, rel) // schéma supprimé ou déplacé + } + } + out := append(byName, byContent...) + if len(out) > limit { + out = out[:limit] + } + if out == nil { + out = []Hit{} + } + return out, nil +} diff --git a/internal/storage/search_test.go b/internal/storage/search_test.go new file mode 100644 index 0000000..a1a5949 --- /dev/null +++ b/internal/storage/search_test.go @@ -0,0 +1,72 @@ +package storage + +import ( + "strings" + "testing" + "time" +) + +func scene(texts ...string) []byte { + var els []string + for i, t := range texts { + els = append(els, `{"id":"e`+string(rune('a'+i))+`","type":"text","text":"`+t+`","originalText":"`+t+`"}`) + } + return []byte(`{"type":"excalidraw","elements":[` + strings.Join(els, ",") + `]}`) +} + +func TestSearchContent(t *testing.T) { + s := newStore(t) + s.Write("réseau/sdn.excalidraw", scene(`Edge NSX 172.27.207.1`, `Transport zone overlay`), "", true) + s.Write("stockage/vsan.excalidraw", scene(`Cluster vSAN\nRéplication`), "", true) + s.Write("réplication.excalidraw", scene(`rien`), "", true) + s.Write("supprimé.excalidraw", []byte(`{"elements":[{"type":"text","text":"172.27.207.99","isDeleted":true}]}`), "", true) + + hits, err := s.Search("172.27.207", 10) + if err != nil || len(hits) != 1 || hits[0].Path != "réseau/sdn.excalidraw" { + t.Fatalf("IP : %+v, %v", hits, err) + } + if hits[0].Snippet != "Edge NSX 172.27.207.1" { + t.Fatalf("extrait = %q", hits[0].Snippet) + } + + // Accents et casse ignorés ; le nom passe avant le contenu. + hits, _ = s.Search("REPLICATION", 10) + if len(hits) != 2 || hits[0].Path != "réplication.excalidraw" || hits[1].Path != "stockage/vsan.excalidraw" { + t.Fatalf("accents : %+v", hits) + } + if hits[1].Snippet != "Cluster vSAN Réplication" { + t.Fatalf("extrait replié = %q", hits[1].Snippet) + } + + // Tous les mots doivent correspondre, dans le nom ou le contenu. + if hits, _ := s.Search("sdn overlay", 10); len(hits) != 1 { + t.Fatalf("nom + contenu : %+v", hits) + } + if hits, _ := s.Search("sdn vsan", 10); len(hits) != 0 { + t.Fatalf("ET logique : %+v", hits) + } + // Les dossiers correspondent par leur nom. + if hits, _ := s.Search("stock", 10); len(hits) != 1 || !hits[0].IsDir { + t.Fatalf("dossier : %+v", hits) + } + + // Une modification est prise en compte (date ou taille changée). + time.Sleep(5 * time.Millisecond) + s.Write("réseau/sdn.excalidraw", scene(`Edge NSX 10.0.0.1`), "", false) + if hits, _ := s.Search("172.27.207", 10); len(hits) != 0 { + t.Fatalf("index périmé : %+v", hits) + } + if hits, _ := s.Search("10.0.0", 10); len(hits) != 1 { + t.Fatalf("index non mis à jour : %+v", hits) + } +} + +func TestSnippetLongLine(t *testing.T) { + long := strings.Repeat("a", 100) + " cible " + strings.Repeat("b", 100) + d := indexDoc{text: long + "\n"} + d.fold = fold(d.text) + got := snippet(d, strings.Index(d.fold, "cible"), len("cible")) + if !strings.HasPrefix(got, "…") || !strings.HasSuffix(got, "…") || !strings.Contains(got, " cible ") { + t.Fatalf("extrait = %q", got) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 0ce96ab..12f8b38 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -57,6 +57,7 @@ type Store struct { History HistoryPolicy settingsMu sync.Mutex // sérialise les lectures-modifications de .settings.json + index textIndex // texte des schémas pour la recherche (search.go) } // New crée le répertoire racine si besoin et renvoie un Store. @@ -183,44 +184,6 @@ func (s *Store) List(rel string) ([]Entry, error) { return out, nil } -// Search parcourt l'arborescence et renvoie les entrées dont le nom contient q. -func (s *Store) Search(q string, limit int) ([]Entry, error) { - q = strings.ToLower(strings.TrimSpace(q)) - if q == "" { - return []Entry{}, nil - } - out := []Entry{} - errStop := errors.New("stop") - err := filepath.WalkDir(s.root, func(p string, d fs.DirEntry, err error) error { - if err != nil || p == s.root { - return nil - } - if strings.HasPrefix(d.Name(), ".") { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - if !visible(d.Name(), d.IsDir()) || !strings.Contains(strings.ToLower(d.Name()), q) { - return nil - } - fi, err := d.Info() - if err != nil { - return nil - } - rel, _ := filepath.Rel(s.root, p) - out = append(out, toEntry(filepath.ToSlash(rel), fi)) - if len(out) >= limit { - return errStop - } - return nil - }) - if err != nil && !errors.Is(err, errStop) { - return nil, err - } - return out, nil -} - // ETag calcule l'empreinte d'un contenu (utilisée pour l'écriture optimiste). func ETag(b []byte) string { h := sha256.Sum256(b)