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:
1 parent
9f9ec3b3a4
commit
c78df092c1
8 files changed
+329
-45
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user