From c1ea1e84a766bc6f22ee2cdce94d3f69cf51d628 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 18:06:45 +0200 Subject: [PATCH] Tags et favoris, filtres dans l'explorateur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm --- README.md | 17 +- frontend/src/Explorer.tsx | 125 +++++++++++++- frontend/src/TagsDialog.tsx | 144 ++++++++++++++++ frontend/src/api.ts | 29 +++- frontend/src/icons.tsx | 20 +++ frontend/src/styles.css | 87 ++++++++++ internal/api/api.go | 65 ++++++- internal/storage/meta.go | 286 +++++++++++++++++++++++++++++++ internal/storage/meta_test.go | 73 ++++++++ internal/storage/search.go | 35 +++- internal/storage/search_test.go | 14 +- internal/storage/storage.go | 16 +- internal/storage/storage_test.go | 2 +- 13 files changed, 880 insertions(+), 33 deletions(-) create mode 100644 frontend/src/TagsDialog.tsx create mode 100644 internal/storage/meta.go create mode 100644 internal/storage/meta_test.go diff --git a/README.md b/README.md index c7324c2..30aab87 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 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). +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), **tags et favoris**, 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`). @@ -20,6 +20,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs ├─ .library.excalidrawlib ← bibliothèque partagée ├─ .settings.json, .logo.png ← titre et logo de la barre ├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables) + ├─ .meta.json ← tags et favoris ├─ .history/projets/réseau/sdn.excalidraw/.excalidraw ← versions └─ .trash//... ← éléments supprimés ``` @@ -99,6 +100,12 @@ Le champ de recherche de l'explorateur cherche dans les noms de fichiers et de d 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. +### Tags et favoris + +Menu « … » d'un élément (ou barre de sélection pour plusieurs) : **Favori** et **Tags…**. Les tags sont libres (40 caractères, 20 par élément, saisis séparés par des virgules), comparés sans casse ni accents ; un tag déjà utilisé garde son écriture d'origine (`nsx` devient `NSX` s'il existe). La ligne de filtres sous la recherche liste les tags utilisés avec leur nombre d'éléments : un clic sur un tag ou sur **Favoris** affiche les éléments correspondants de toute l'arborescence, combinables avec le texte recherché. Les tags participent aussi à la recherche texte. + +Stockage : `DATA_DIR/.meta.json` (`{"items":{"chemin":{"tags":[…],"favorite":true}}}`), mis à jour aux renommages, déplacements, copies (tags seuls) et suppressions. + ### 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. @@ -124,7 +131,10 @@ 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 + "snippet"]` (200 max) : noms puis contenu, tous les mots requis | +| GET | `/api/search?q=texte[&tag=t][&fav=true]` | | `[Entry + "snippet"]` (200 max) : noms et tags puis contenu, tous les mots requis ; `q` facultatif avec un filtre | +| GET | `/api/tags` | | `[{"name","count"}]` | +| POST | `/api/meta/favorite` | `{"paths":[…],"favorite":true}` | `204` | +| POST | `/api/meta/tags` | `{"paths":[…],"add":["NSX"],"remove":["old"]}` | `204` | | 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` | @@ -147,7 +157,7 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co | GET | `/api/update` | | `{"enabled","tags":{"latest","next"},"latest","checkedAt"}` | | GET | `/healthz` | | `ok` (hors authentification) | -`Entry` = `{"name","path","isDir","size","modTime"}`. +`Entry` = `{"name","path","isDir","size","modTime","tags","favorite"}` (les deux derniers omis s'ils sont vides). Exemple d'automatisation (génération de schémas par script) : @@ -182,6 +192,7 @@ frontend/src/MoveDialog.tsx sélecteur de dossier « Déplacer vers… » frontend/src/Thumb.tsx vignette chargée à l'affichage, générée si absente frontend/src/thumbs.ts rendu PNG des miniatures et file de génération frontend/src/HistoryDialog.tsx historique : aperçu, différences, restauration +frontend/src/TagsDialog.tsx tags d'un ou plusieurs éléments ``` ## Sécurité diff --git a/frontend/src/Explorer.tsx b/frontend/src/Explorer.tsx index dc2bc7a..1b844a7 100644 --- a/frontend/src/Explorer.tsx +++ b/frontend/src/Explorer.tsx @@ -11,6 +11,7 @@ import { withExt, type Entry, type Hit, + type TagCount, } from "./api"; import { CloseIcon, @@ -26,12 +27,16 @@ import { RefreshIcon, SearchIcon, SelectAllIcon, + StarFilledIcon, + StarIcon, + TagIcon, TrashIcon, UpIcon, UploadIcon, } from "./icons"; import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer"; import { MoveDialog } from "./MoveDialog"; +import { TagsDialog } from "./TagsDialog"; import { Thumb } from "./Thumb"; export const EMPTY_SCENE = JSON.stringify({ @@ -96,10 +101,16 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi const [notice, setNotice] = useState<{ text: string; detail?: string } | null>(null); const [busy, setBusy] = useState(null); // progression d'un import const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur + const [tags, setTags] = useState([]); + const [tagFilter, setTagFilter] = useState(null); + const [favOnly, setFavOnly] = useState(false); + const [tagging, setTagging] = useState(null); // éléments du dialogue « Tags » const fileInput = useRef(null); const dirInput = useRef(null); const list = results ?? entries; + const filter = { tag: tagFilter, fav: favOnly }; + const filtering = query.trim() !== "" || tagFilter !== null || favOnly; const guard = useCallback(async (fn: () => Promise) => { setError(null); @@ -113,8 +124,11 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi const refresh = useCallback( () => guard(async () => { - const next = await api.list(dir); + const [next, tagList] = await Promise.all([api.list(dir), api.tags()]); setEntries(next); + setTags(tagList); + // Tag filtré qui n'existe plus : filtre retiré. + setTagFilter((t) => (t && tagList.some((x) => x.name === t) ? t : null)); setMenuFor(null); // Oublie les éléments sélectionnés qui ont disparu. setSelected((s) => { @@ -141,15 +155,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi if (current) setDir(dirname(current)); }, [current]); - // Recherche avec anti-rebond. + // Recherche (texte, tag, favoris) avec anti-rebond. useEffect(() => { - if (!query.trim()) { + if (!filtering) { setResults(null); return; } - const t = setTimeout(() => guard(async () => setResults(await api.search(query))), 250); + const t = setTimeout(() => guard(async () => setResults(await api.search(query, filter))), 250); return () => clearTimeout(t); - }, [query, guard, refreshKey]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, tagFilter, favOnly, guard, refreshKey]); + + /** Recharge le dossier et, si une recherche est affichée, ses résultats. */ + const reload = async () => { + await refresh(); + if (filtering) setResults(await api.search(query, filter)); + }; useEffect(() => { try { @@ -254,13 +275,24 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi } clearSelection(); setMoving(null); - await refresh(); - if (results) setResults(await api.search(query)); + await reload(); if (failed.length) { setError(`${plural(failed.length, "élément")} non déplacé${failed.length > 1 ? "s" : ""} — ${failed.join(" ; ")}`); } }); + /** Favoris : la sélection devient favorite, sauf si elle l'est déjà entièrement. */ + const toggleFavorite = (items: Entry[]) => + guard(async () => { + const fav = !items.every((e) => e.favorite); + await api.setFavorite( + items.map((e) => e.path), + fav, + ); + setMenuFor(null); + await reload(); + }); + const duplicate = (e: Entry) => guard(async () => { const base = join(dirname(e.path), stripExt(e.name)); @@ -291,8 +323,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi onDeleted(p); } clearSelection(); - await refresh(); - if (results) setResults(await api.search(query)); + await reload(); }); /** @@ -425,6 +456,14 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi {!e.isDir && } {!e.isDir && } + + {!e.isDir && ( Télécharger @@ -471,11 +510,30 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi + {!!e.tags?.length && ( + + {e.tags.map((t) => ( + + ))} + + )} {!e.isDir && view === "list" && {fmtDate(e.modTime)}} + {tags.map((t) => ( + + ))} + + {!results && (