Tags et favoris, filtres dans l'explorateur

- DATA_DIR/.meta.json : tags et favoris par chemin, suivis aux renommages,
  déplacements, copies (tags seuls) et suppressions.
- API : GET /api/tags, POST /api/meta/favorite, POST /api/meta/tags ; Entry
  porte tags et favorite ; /api/search accepte tag= et fav=true (q facultatif).
- Tags comparés sans casse ni accents, écriture existante conservée ; ils
  participent à la recherche texte.
- Explorateur : ligne de filtres (Favoris, tags avec leur nombre), étoile et
  tags sur les éléments, menu Favori / Tags…, actions sur la sélection,
  dialogue Tags (ajout, retrait, suggestions).

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:06:45 +02:00
1 parent c78df092c1
commit c1ea1e84a7
13 files changed
+880 -33

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
package storage
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// Tags et favoris des fichiers et dossiers, dans .meta.json à la racine :
// {"items":{"a/b.excalidraw":{"tags":["NSX"],"favorite":true}}}. Ils suivent
// les renommages, déplacements et suppressions.
const metaFile = ".meta.json"
const (
// MaxTagLen borne la longueur d'un tag (en caractères).
MaxTagLen = 40
// MaxTags borne le nombre de tags d'un élément.
MaxTags = 20
)
type itemMeta struct {
Tags []string `json:"tags,omitempty"`
Favorite bool `json:"favorite,omitempty"`
}
func (m itemMeta) empty() bool { return len(m.Tags) == 0 && !m.Favorite }
// TagCount est un tag utilisé et son nombre d'éléments.
type TagCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
// loadMeta charge .meta.json au premier accès ; appelé sous metaMu.
func (s *Store) loadMeta() error {
if s.meta != nil {
return nil
}
var d struct {
Items map[string]itemMeta `json:"items"`
}
b, err := os.ReadFile(filepath.Join(s.root, metaFile))
switch {
case errors.Is(err, fs.ErrNotExist):
case err != nil:
return err
default:
if err := json.Unmarshal(b, &d); err != nil {
return fmt.Errorf("%s : %w", metaFile, err)
}
}
if d.Items == nil {
d.Items = map[string]itemMeta{}
}
s.meta = d.Items
return nil
}
// saveMeta écrit .meta.json ; appelé sous metaMu.
func (s *Store) saveMeta() error {
for k, m := range s.meta {
if m.empty() {
delete(s.meta, k)
}
}
b, err := json.Marshal(map[string]any{"items": s.meta})
if err != nil {
return err
}
return atomicWrite(filepath.Join(s.root, metaFile), b)
}
// metaSnapshot renvoie une copie des métadonnées (pour List et Search).
func (s *Store) metaSnapshot() map[string]itemMeta {
s.metaMu.Lock()
defer s.metaMu.Unlock()
if s.loadMeta() != nil {
return map[string]itemMeta{}
}
out := make(map[string]itemMeta, len(s.meta))
for k, v := range s.meta {
out[k] = v
}
return out
}
func decorate(e *Entry, meta map[string]itemMeta) {
if m, ok := meta[e.Path]; ok {
e.Tags, e.Favorite = m.Tags, m.Favorite
}
}
func normTag(t string) (string, error) {
t = strings.Join(strings.Fields(strings.TrimPrefix(strings.TrimSpace(t), "#")), " ")
switch {
case t == "", !utf8.ValidString(t), strings.ContainsAny(t, ",\x00"):
return "", fmt.Errorf("%w : tag %q", ErrInvalidPath, t)
case utf8.RuneCountInString(t) > MaxTagLen:
return "", fmt.Errorf("%w : tag de %d caractères max", ErrInvalidPath, MaxTagLen)
}
return t, nil
}
// editMeta vérifie que chaque chemin existe puis applique edit à ses
// métadonnées, et enregistre le tout.
func (s *Store) editMeta(paths []string, edit func(*itemMeta) error) error {
cs := make([]string, 0, len(paths))
for _, p := range paths {
c, abs, err := s.resolve(p)
if err != nil {
return err
}
if c == "" {
return ErrInvalidPath
}
if _, err := os.Lstat(abs); err != nil {
return mapErr(err)
}
cs = append(cs, c)
}
s.metaMu.Lock()
defer s.metaMu.Unlock()
if err := s.loadMeta(); err != nil {
return err
}
for _, c := range cs {
m := s.meta[c]
m.Tags = append([]string(nil), m.Tags...)
if err := edit(&m); err != nil {
return err
}
s.meta[c] = m
}
return s.saveMeta()
}
// SetFavorite ajoute ou retire des éléments des favoris.
func (s *Store) SetFavorite(paths []string, favorite bool) error {
return s.editMeta(paths, func(m *itemMeta) error {
m.Favorite = favorite
return nil
})
}
// EditTags ajoute et retire des tags sur des éléments. Les tags sont comparés
// sans casse ni accents ; un tag déjà utilisé garde son écriture d'origine.
func (s *Store) EditTags(paths, add, remove []string) error {
var adds []string
for _, t := range add {
n, err := normTag(t)
if err != nil {
return err
}
adds = append(adds, n)
}
drop := map[string]bool{}
for _, t := range remove {
drop[fold(strings.TrimSpace(t))] = true
}
canon := map[string]string{} // écriture existante de chaque tag
s.metaMu.Lock()
err := s.loadMeta()
for _, m := range s.meta {
for _, t := range m.Tags {
if _, ok := canon[fold(t)]; !ok {
canon[fold(t)] = t
}
}
}
s.metaMu.Unlock()
if err != nil {
return err
}
return s.editMeta(paths, func(m *itemMeta) error {
kept := m.Tags[:0]
has := map[string]bool{}
for _, t := range m.Tags {
if !drop[fold(t)] && !has[fold(t)] {
kept = append(kept, t)
has[fold(t)] = true
}
}
for _, t := range adds {
if has[fold(t)] {
continue
}
if c, ok := canon[fold(t)]; ok {
t = c
}
kept = append(kept, t)
has[fold(t)] = true
}
if len(kept) > MaxTags {
return fmt.Errorf("%w : %d tags max par élément", ErrInvalidPath, MaxTags)
}
sort.Slice(kept, func(i, j int) bool { return fold(kept[i]) < fold(kept[j]) })
m.Tags = kept
return nil
})
}
// Tags liste les tags utilisés, triés par nom, avec leur nombre d'éléments.
func (s *Store) Tags() ([]TagCount, error) {
s.metaMu.Lock()
defer s.metaMu.Unlock()
if err := s.loadMeta(); err != nil {
return nil, err
}
idx := map[string]int{}
out := []TagCount{}
for _, m := range s.meta {
for _, t := range m.Tags {
k := fold(t)
if i, ok := idx[k]; ok {
out[i].Count++
} else {
idx[k] = len(out)
out = append(out, TagCount{Name: t, Count: 1})
}
}
}
sort.Slice(out, func(i, j int) bool { return fold(out[i].Name) < fold(out[j].Name) })
return out, nil
}
// Les opérations suivantes accompagnent Move/Copy/Delete (au mieux).
func under(k, c string) bool { return k == c || strings.HasPrefix(k, c+"/") }
func (s *Store) moveMeta(from, to string) {
s.metaMu.Lock()
defer s.metaMu.Unlock()
if s.loadMeta() != nil {
return
}
moved := map[string]itemMeta{}
for k, m := range s.meta {
if under(k, from) {
delete(s.meta, k)
moved[to+k[len(from):]] = m
}
}
for k, m := range moved {
s.meta[k] = m
}
if len(moved) > 0 {
_ = s.saveMeta()
}
}
func (s *Store) copyMeta(from, to string) {
s.metaMu.Lock()
defer s.metaMu.Unlock()
if s.loadMeta() != nil {
return
}
if m, ok := s.meta[from]; ok && len(m.Tags) > 0 {
s.meta[to] = itemMeta{Tags: append([]string(nil), m.Tags...)} // pas le favori
_ = s.saveMeta()
}
}
func (s *Store) removeMeta(c string) {
s.metaMu.Lock()
defer s.metaMu.Unlock()
if s.loadMeta() != nil {
return
}
changed := false
for k := range s.meta {
if under(k, c) {
delete(s.meta, k)
changed = true
}
}
if changed {
_ = s.saveMeta()
}
}