Recherche plein texte dans le contenu des schémas

- /api/search cherche dans les noms et dans le texte des schémas (textes et
  libellés, noms de cadres, liens) ; tous les mots requis, casse et accents
  ignorés, correspondances sur le nom en premier.
- Index en mémoire côté serveur : texte extrait à la première recherche, relu
  seulement si le fichier change (date ou taille), rien d'écrit sur le volume.
- L'explorateur affiche l'extrait correspondant sous le nom.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 18:04:21 +02:00
1 parent 9f9ec3b3a4
commit c78df092c1
8 files changed
+329 -45

No files matched your search

+8 -2
View File
@@ -1,6 +1,6 @@
# excalidraw-go # 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. - **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`). - **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. 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 ### Historique des versions
Avant qu'un schéma soit écrasé, son contenu précédent est copié dans `DATA_DIR/.history/<chemin du schéma>/<date>.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. Avant qu'un schéma soit écrasé, son contenu précédent est copié dans `DATA_DIR/.history/<chemin du schéma>/<date>.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 | | Méthode | Route | Corps / paramètres | Réponse |
|---|---|---|---| |---|---|---|---|
| GET | `/api/list?path=dir` | | `[Entry]` (dossiers puis schémas) | | 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` | | GET | `/api/file?path=x.excalidraw` | | JSON du schéma + `ETag` |
| PUT | `/api/file?path=x.excalidraw` | JSON ; `If-Match: <etag>` ou `If-None-Match: *` (création) | `Entry` + `ETag` ; `409` existe, `412` conflit | | PUT | `/api/file?path=x.excalidraw` | JSON ; `If-Match: <etag>` ou `If-None-Match: *` (création) | `Entry` + `ETag` ; `409` existe, `412` conflit |
| POST | `/api/folder` | `{"path":"a/b"}` | `Entry` | | POST | `/api/folder` | `{"path":"a/b"}` | `Entry` |
+5 -3
View File
@@ -10,6 +10,7 @@ import {
stripExt, stripExt,
withExt, withExt,
type Entry, type Entry,
type Hit,
} from "./api"; } from "./api";
import { import {
CloseIcon, CloseIcon,
@@ -84,7 +85,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
const [dir, setDir] = useState(() => (current ? dirname(current) : "")); const [dir, setDir] = useState(() => (current ? dirname(current) : ""));
const [entries, setEntries] = useState<Entry[]>([]); const [entries, setEntries] = useState<Entry[]>([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<Entry[] | null>(null); const [results, setResults] = useState<Hit[] | null>(null);
const [view, setView] = useState<View>(initialView); const [view, setView] = useState<View>(initialView);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [anchor, setAnchor] = useState<string | null>(null); // origine d'une sélection Maj+clic const [anchor, setAnchor] = useState<string | null>(null); // origine d'une sélection Maj+clic
@@ -436,7 +437,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
</div> </div>
); );
const item = (e: Entry) => { const item = (e: Hit) => {
const isSel = selected.has(e.path); const isSel = selected.has(e.path);
const cls = [ const cls = [
"item", "item",
@@ -472,6 +473,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
<span className="label"> <span className="label">
{e.isDir ? e.name : stripExt(e.name)} {e.isDir ? e.name : stripExt(e.name)}
{results && <small className="muted where"> {dirname(e.path) || "/"}</small>} {results && <small className="muted where"> {dirname(e.path) || "/"}</small>}
{e.snippet && <small className="snippet">{e.snippet}</small>}
</span> </span>
</button> </button>
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>} {!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
@@ -551,7 +553,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
<SearchIcon /> <SearchIcon />
<input <input
type="search" type="search"
placeholder="Rechercher…" placeholder="Rechercher (noms, textes, IP…)"
value={query} value={query}
onChange={(ev) => setQuery(ev.target.value)} onChange={(ev) => setQuery(ev.target.value)}
/> />
+6 -1
View File
@@ -8,6 +8,11 @@ export interface Entry {
modTime: string; modTime: string;
} }
/** Résultat de recherche ; snippet = extrait du contenu qui correspond. */
export interface Hit extends Entry {
snippet?: string;
}
export interface MoveResult { export interface MoveResult {
from: string; from: string;
to?: string; to?: string;
@@ -72,7 +77,7 @@ export const api = {
list: async (dir: string): Promise<Entry[]> => list: async (dir: string): Promise<Entry[]> =>
(await request(`/api/list?path=${q(dir)}`)).json(), (await request(`/api/list?path=${q(dir)}`)).json(),
search: async (text: string): Promise<Entry[]> => search: async (text: string): Promise<Hit[]> =>
(await request(`/api/search?q=${q(text)}`)).json(), (await request(`/api/search?q=${q(text)}`)).json(),
read: async (path: string): Promise<{ text: string; etag: string }> => { read: async (path: string): Promise<{ text: string; etag: string }> => {
+13
View File
@@ -1127,3 +1127,16 @@ li.active > .row {
display: none; 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;
}
+1 -1
View File
@@ -1,7 +1,7 @@
// Package api expose le Store en REST/JSON. // Package api expose le Store en REST/JSON.
// //
// GET /api/list?path=dir contenu d'un dossier // 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) // 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: *) // 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/folder {"path":"a/b"} création de dossier
+223
View File
@@ -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
}
+72
View File
@@ -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)
}
}
+1 -38
View File
@@ -57,6 +57,7 @@ type Store struct {
History HistoryPolicy History HistoryPolicy
settingsMu sync.Mutex // sérialise les lectures-modifications de .settings.json 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. // 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 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). // ETag calcule l'empreinte d'un contenu (utilisée pour l'écriture optimiste).
func ETag(b []byte) string { func ETag(b []byte) string {
h := sha256.Sum256(b) h := sha256.Sum256(b)