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:
1 parent
c78df092c1
commit
c1ea1e84a7
13 files changed
+879
-32
No files matched your search
@@ -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 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.
|
- **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`).
|
||||||
@@ -20,6 +20,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs
|
|||||||
├─ .library.excalidrawlib ← bibliothèque partagée
|
├─ .library.excalidrawlib ← bibliothèque partagée
|
||||||
├─ .settings.json, .logo.png ← titre et logo de la barre
|
├─ .settings.json, .logo.png ← titre et logo de la barre
|
||||||
├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables)
|
├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables)
|
||||||
|
├─ .meta.json ← tags et favoris
|
||||||
├─ .history/projets/réseau/sdn.excalidraw/<date>.excalidraw ← versions
|
├─ .history/projets/réseau/sdn.excalidraw/<date>.excalidraw ← versions
|
||||||
└─ .trash/<horodatage>/... ← éléments supprimés
|
└─ .trash/<horodatage>/... ← é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.
|
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
|
### 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.
|
||||||
@@ -124,7 +131,10 @@ 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 + "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` |
|
| 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` |
|
||||||
@@ -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 | `/api/update` | | `{"enabled","tags":{"latest","next"},"latest","checkedAt"}` |
|
||||||
| GET | `/healthz` | | `ok` (hors authentification) |
|
| 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) :
|
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/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/thumbs.ts rendu PNG des miniatures et file de génération
|
||||||
frontend/src/HistoryDialog.tsx historique : aperçu, différences, restauration
|
frontend/src/HistoryDialog.tsx historique : aperçu, différences, restauration
|
||||||
|
frontend/src/TagsDialog.tsx tags d'un ou plusieurs éléments
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|||||||
+116
-9
@@ -11,6 +11,7 @@ import {
|
|||||||
withExt,
|
withExt,
|
||||||
type Entry,
|
type Entry,
|
||||||
type Hit,
|
type Hit,
|
||||||
|
type TagCount,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import {
|
import {
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
@@ -26,12 +27,16 @@ import {
|
|||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SelectAllIcon,
|
SelectAllIcon,
|
||||||
|
StarFilledIcon,
|
||||||
|
StarIcon,
|
||||||
|
TagIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UpIcon,
|
UpIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
|
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
|
||||||
import { MoveDialog } from "./MoveDialog";
|
import { MoveDialog } from "./MoveDialog";
|
||||||
|
import { TagsDialog } from "./TagsDialog";
|
||||||
import { Thumb } from "./Thumb";
|
import { Thumb } from "./Thumb";
|
||||||
|
|
||||||
export const EMPTY_SCENE = JSON.stringify({
|
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 [notice, setNotice] = useState<{ text: string; detail?: string } | null>(null);
|
||||||
const [busy, setBusy] = useState<string | null>(null); // progression d'un import
|
const [busy, setBusy] = useState<string | null>(null); // progression d'un import
|
||||||
const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur
|
const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur
|
||||||
|
const [tags, setTags] = useState<TagCount[]>([]);
|
||||||
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||||
|
const [favOnly, setFavOnly] = useState(false);
|
||||||
|
const [tagging, setTagging] = useState<Entry[] | null>(null); // éléments du dialogue « Tags »
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
const dirInput = useRef<HTMLInputElement>(null);
|
const dirInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const list = results ?? entries;
|
const list = results ?? entries;
|
||||||
|
const filter = { tag: tagFilter, fav: favOnly };
|
||||||
|
const filtering = query.trim() !== "" || tagFilter !== null || favOnly;
|
||||||
|
|
||||||
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -113,8 +124,11 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
() =>
|
() =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
const next = await api.list(dir);
|
const [next, tagList] = await Promise.all([api.list(dir), api.tags()]);
|
||||||
setEntries(next);
|
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);
|
setMenuFor(null);
|
||||||
// Oublie les éléments sélectionnés qui ont disparu.
|
// Oublie les éléments sélectionnés qui ont disparu.
|
||||||
setSelected((s) => {
|
setSelected((s) => {
|
||||||
@@ -141,15 +155,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
if (current) setDir(dirname(current));
|
if (current) setDir(dirname(current));
|
||||||
}, [current]);
|
}, [current]);
|
||||||
|
|
||||||
// Recherche avec anti-rebond.
|
// Recherche (texte, tag, favoris) avec anti-rebond.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!query.trim()) {
|
if (!filtering) {
|
||||||
setResults(null);
|
setResults(null);
|
||||||
return;
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -254,13 +275,24 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
}
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
setMoving(null);
|
setMoving(null);
|
||||||
await refresh();
|
await reload();
|
||||||
if (results) setResults(await api.search(query));
|
|
||||||
if (failed.length) {
|
if (failed.length) {
|
||||||
setError(`${plural(failed.length, "élément")} non déplacé${failed.length > 1 ? "s" : ""} — ${failed.join(" ; ")}`);
|
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) =>
|
const duplicate = (e: Entry) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
const base = join(dirname(e.path), stripExt(e.name));
|
const base = join(dirname(e.path), stripExt(e.name));
|
||||||
@@ -291,8 +323,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
onDeleted(p);
|
onDeleted(p);
|
||||||
}
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
await refresh();
|
await reload();
|
||||||
if (results) setResults(await api.search(query));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -425,6 +456,14 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
|
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
|
||||||
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
||||||
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
|
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
|
||||||
|
<button onClick={() => toggleFavorite([e])}>
|
||||||
|
{e.favorite ? <StarFilledIcon /> : <StarIcon />}
|
||||||
|
{e.favorite ? "Retirer des favoris" : "Favori"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setTagging([e])}>
|
||||||
|
<TagIcon />
|
||||||
|
Tags…
|
||||||
|
</button>
|
||||||
{!e.isDir && (
|
{!e.isDir && (
|
||||||
<a href={api.downloadUrl(e.path)} download={e.name}>
|
<a href={api.downloadUrl(e.path)} download={e.name}>
|
||||||
Télécharger
|
Télécharger
|
||||||
@@ -471,11 +510,30 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<button className="name" onClick={(ev) => onItemClick(ev, e)} title={e.path}>
|
<button className="name" onClick={(ev) => onItemClick(ev, e)} title={e.path}>
|
||||||
{icon(e)}
|
{icon(e)}
|
||||||
<span className="label">
|
<span className="label">
|
||||||
|
{e.favorite && (
|
||||||
|
<span className="fav" title="Favori">
|
||||||
|
<StarFilledIcon size={12} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{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>}
|
{e.snippet && <small className="snippet">{e.snippet}</small>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{!!e.tags?.length && (
|
||||||
|
<span className="tags">
|
||||||
|
{e.tags.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
className={`chip ${t === tagFilter ? "on" : ""}`}
|
||||||
|
title={`Filtrer sur « ${t} »`}
|
||||||
|
onClick={() => setTagFilter(t === tagFilter ? null : t)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
||||||
<button
|
<button
|
||||||
className="icon-btn more"
|
className="icon-btn more"
|
||||||
@@ -583,6 +641,30 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="filters" role="group" aria-label="Filtres">
|
||||||
|
<button
|
||||||
|
className={`chip ${favOnly ? "on" : ""}`}
|
||||||
|
aria-pressed={favOnly}
|
||||||
|
onClick={() => setFavOnly((f) => !f)}
|
||||||
|
title="Favoris de toute l'arborescence"
|
||||||
|
>
|
||||||
|
{favOnly ? <StarFilledIcon size={12} /> : <StarIcon size={12} />}
|
||||||
|
Favoris
|
||||||
|
</button>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.name}
|
||||||
|
className={`chip ${t.name === tagFilter ? "on" : ""}`}
|
||||||
|
aria-pressed={t.name === tagFilter}
|
||||||
|
onClick={() => setTagFilter(t.name === tagFilter ? null : t.name)}
|
||||||
|
title={`${plural(t.count, "élément")} avec ce tag`}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
<small>{t.count}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!results && (
|
{!results && (
|
||||||
<nav className="crumbs">
|
<nav className="crumbs">
|
||||||
<button className={dropTarget === "" ? "drop" : ""} onClick={() => setDir("")} {...dropProps("")}>
|
<button className={dropTarget === "" ? "drop" : ""} onClick={() => setDir("")} {...dropProps("")}>
|
||||||
@@ -616,6 +698,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
>
|
>
|
||||||
{selected.size === list.length ? <DeselectIcon /> : <SelectAllIcon />}
|
{selected.size === list.length ? <DeselectIcon /> : <SelectAllIcon />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={() => toggleFavorite(list.filter((e) => selected.has(e.path)))}
|
||||||
|
title="Favoris (ajouter ou retirer)"
|
||||||
|
aria-label="Favoris"
|
||||||
|
>
|
||||||
|
<StarIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={() => setTagging(list.filter((e) => selected.has(e.path)))}
|
||||||
|
title="Tags…"
|
||||||
|
aria-label="Tags"
|
||||||
|
>
|
||||||
|
<TagIcon />
|
||||||
|
</button>
|
||||||
<button className="ex-btn" onClick={() => setMoving([...selected])}>
|
<button className="ex-btn" onClick={() => setMoving([...selected])}>
|
||||||
<MoveIcon />
|
<MoveIcon />
|
||||||
<span>Déplacer…</span>
|
<span>Déplacer…</span>
|
||||||
@@ -663,6 +761,15 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tagging && (
|
||||||
|
<TagsDialog
|
||||||
|
items={tagging.map((t) => list.find((e) => e.path === t.path) ?? t)}
|
||||||
|
known={tags}
|
||||||
|
onChanged={reload}
|
||||||
|
onClose={() => setTagging(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{moving && (
|
{moving && (
|
||||||
<MoveDialog
|
<MoveDialog
|
||||||
paths={moving}
|
paths={moving}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, basename, stripExt, type Entry, type TagCount } from "./api";
|
||||||
|
import { CloseIcon, TagIcon } from "./icons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Éléments concernés, avec leurs tags actuels. */
|
||||||
|
items: Entry[];
|
||||||
|
/** Tags déjà utilisés (suggestions). */
|
||||||
|
known: TagCount[];
|
||||||
|
/** Appelé après chaque modification pour rafraîchir l'explorateur. */
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (t: string) => t.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
||||||
|
|
||||||
|
/** Ajout et retrait de tags sur un ou plusieurs éléments. */
|
||||||
|
export function TagsDialog({ items, known, onChanged, onClose }: Props) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const paths = items.map((e) => e.path);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Tags présents et nombre d'éléments qui les portent.
|
||||||
|
const present = new Map<string, { name: string; count: number }>();
|
||||||
|
for (const e of items)
|
||||||
|
for (const t of e.tags ?? []) {
|
||||||
|
const p = present.get(key(t));
|
||||||
|
if (p) p.count++;
|
||||||
|
else present.set(key(t), { name: t, count: 1 });
|
||||||
|
}
|
||||||
|
const suggestions = known.filter((t) => !present.has(key(t.name)) || present.get(key(t.name))!.count < items.length);
|
||||||
|
|
||||||
|
const run = async (add: string[], remove: string[]) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.editTags(paths, add, remove);
|
||||||
|
await onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = (raw: string) => {
|
||||||
|
const tags = raw
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (tags.length === 0) return;
|
||||||
|
setInput("");
|
||||||
|
run(tags, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const title =
|
||||||
|
items.length === 1
|
||||||
|
? `Tags · ${items[0].isDir ? items[0].name : stripExt(basename(items[0].path))}`
|
||||||
|
: `Tags · ${items.length} éléments`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal tags-dialog" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
|
||||||
|
<div className="chips">
|
||||||
|
{[...present.values()].map((t) => (
|
||||||
|
<span key={t.name} className="chip">
|
||||||
|
{t.name}
|
||||||
|
{items.length > 1 && t.count < items.length && (
|
||||||
|
<small className="muted">
|
||||||
|
{" "}
|
||||||
|
{t.count}/{items.length}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => run([], [t.name])}
|
||||||
|
title={`Retirer « ${t.name} »`}
|
||||||
|
aria-label={`Retirer ${t.name}`}
|
||||||
|
>
|
||||||
|
<CloseIcon size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{present.size === 0 && <span className="muted">Aucun tag.</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="tag-add"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
add(input);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="search">
|
||||||
|
<TagIcon />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={input}
|
||||||
|
list="known-tags"
|
||||||
|
maxLength={200}
|
||||||
|
placeholder="Ajouter des tags (séparés par des virgules)"
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<datalist id="known-tags">
|
||||||
|
{known.map((t) => (
|
||||||
|
<option key={t.name} value={t.name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<button type="submit" className="primary" disabled={busy || !input.trim()}>
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="chips suggestions">
|
||||||
|
{suggestions.slice(0, 30).map((t) => (
|
||||||
|
<button key={t.name} className="chip" disabled={busy} onClick={() => run([t.name], [])} title={`Ajouter « ${t.name} »`}>
|
||||||
|
+ {t.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={onClose}>Fermer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
-2
@@ -6,6 +6,19 @@ export interface Entry {
|
|||||||
isDir: boolean;
|
isDir: boolean;
|
||||||
size: number;
|
size: number;
|
||||||
modTime: string;
|
modTime: string;
|
||||||
|
tags?: string[];
|
||||||
|
favorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagCount {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtres de recherche : tag exact et/ou favoris seulement. */
|
||||||
|
export interface SearchFilter {
|
||||||
|
tag?: string | null;
|
||||||
|
fav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résultat de recherche ; snippet = extrait du contenu qui correspond. */
|
/** Résultat de recherche ; snippet = extrait du contenu qui correspond. */
|
||||||
@@ -77,8 +90,20 @@ 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<Hit[]> =>
|
search: async (text: string, f: SearchFilter = {}): Promise<Hit[]> =>
|
||||||
(await request(`/api/search?q=${q(text)}`)).json(),
|
(
|
||||||
|
await request(`/api/search?q=${q(text)}${f.tag ? `&tag=${q(f.tag)}` : ""}${f.fav ? "&fav=true" : ""}`)
|
||||||
|
).json(),
|
||||||
|
|
||||||
|
tags: async (): Promise<TagCount[]> => (await request("/api/tags")).json(),
|
||||||
|
|
||||||
|
setFavorite: async (paths: string[], favorite: boolean): Promise<void> => {
|
||||||
|
await request("/api/meta/favorite", json({ paths, favorite }));
|
||||||
|
},
|
||||||
|
|
||||||
|
editTags: async (paths: string[], add: string[], remove: string[]): Promise<void> => {
|
||||||
|
await request("/api/meta/tags", json({ paths, add, remove }));
|
||||||
|
},
|
||||||
|
|
||||||
read: async (path: string): Promise<{ text: string; etag: string }> => {
|
read: async (path: string): Promise<{ text: string; etag: string }> => {
|
||||||
const res = await request(`/api/file?path=${q(path)}`);
|
const res = await request(`/api/file?path=${q(path)}`);
|
||||||
|
|||||||
@@ -163,3 +163,23 @@ export const DownloadIcon = icon(
|
|||||||
<path d="M7 11l5 5 5-5M12 4v12" />
|
<path d="M7 11l5 5 5-5M12 4v12" />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const StarIcon = icon(
|
||||||
|
<path d="M12 17.75l-6.17 3.24 1.18-6.87-5-4.86 6.9-1 3.09-6.26 3.09 6.26 6.9 1-5 4.86 1.18 6.87z" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Étoile pleine (favori). */
|
||||||
|
export function StarFilledIcon({ size = 16 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M12 17.75l-6.17 3.24 1.18-6.87-5-4.86 6.9-1 3.09-6.26 3.09 6.26 6.9 1-5 4.86 1.18 6.87z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagIcon = icon(
|
||||||
|
<>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1" />
|
||||||
|
<path d="M4 7v3.86a2 2 0 0 0 .59 1.41l7.71 7.71a2.4 2.4 0 0 0 3.4 0l4.27-4.27a2.4 2.4 0 0 0 0-3.4L12.26 4.59A2 2 0 0 0 10.85 4H7a3 3 0 0 0-3 3z" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
@@ -1140,3 +1140,90 @@ li.active > .row {
|
|||||||
.entries.grid .snippet {
|
.entries.grid .snippet {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Tags et favoris -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: var(--ex-surface-low);
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button.chip.on,
|
||||||
|
.chip.on {
|
||||||
|
background: var(--ex-primary);
|
||||||
|
color: var(--ex-surface-lowest);
|
||||||
|
}
|
||||||
|
.chip small {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex: none;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.row .fav {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: -1px;
|
||||||
|
color: #f59f00;
|
||||||
|
}
|
||||||
|
.row .tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
/* En liste : tags sur une ligne sous le nom. */
|
||||||
|
.entries.list .row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.entries.list .row .tags {
|
||||||
|
order: 10;
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-top: -4px;
|
||||||
|
padding: 0 6px 5px 82px;
|
||||||
|
}
|
||||||
|
.row .tags .chip {
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
.entries.grid .row .tags {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px 6px;
|
||||||
|
}
|
||||||
|
.tags-dialog .chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tags-dialog .chip {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 4px 0 10px;
|
||||||
|
border-radius: 13px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.tags-dialog .chip .icon-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.tags-dialog .suggestions .chip {
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tag-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
+63
-2
@@ -1,7 +1,10 @@
|
|||||||
// 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 (noms et texte des schémas)
|
// GET /api/search?q=texte[&tag=t][&fav=true] recherche (noms, tags, texte des schémas)
|
||||||
|
// GET /api/tags tags utilisés et nombre d'éléments
|
||||||
|
// POST /api/meta/favorite {"paths":[...],"favorite":true} favoris
|
||||||
|
// POST /api/meta/tags {"paths":[...],"add":[...],"remove":[...]} tags
|
||||||
// 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
|
||||||
@@ -42,6 +45,9 @@ type Handler struct {
|
|||||||
func (h *Handler) Register(mux *http.ServeMux) {
|
func (h *Handler) Register(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/list", h.list)
|
mux.HandleFunc("GET /api/list", h.list)
|
||||||
mux.HandleFunc("GET /api/search", h.search)
|
mux.HandleFunc("GET /api/search", h.search)
|
||||||
|
mux.HandleFunc("GET /api/tags", h.tags)
|
||||||
|
mux.HandleFunc("POST /api/meta/favorite", h.setFavorite)
|
||||||
|
mux.HandleFunc("POST /api/meta/tags", h.editTags)
|
||||||
mux.HandleFunc("GET /api/file", h.readFile)
|
mux.HandleFunc("GET /api/file", h.readFile)
|
||||||
mux.HandleFunc("PUT /api/file", h.writeFile)
|
mux.HandleFunc("PUT /api/file", h.writeFile)
|
||||||
mux.HandleFunc("POST /api/folder", h.mkdir)
|
mux.HandleFunc("POST /api/folder", h.mkdir)
|
||||||
@@ -127,7 +133,9 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||||
entries, err := h.Store.Search(r.URL.Query().Get("q"), 200)
|
q := r.URL.Query()
|
||||||
|
f := storage.Filter{Tag: q.Get("tag"), Favorite: q.Get("fav") == "true"}
|
||||||
|
entries, err := h.Store.Search(q.Get("q"), f, 200)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(w, err)
|
fail(w, err)
|
||||||
return
|
return
|
||||||
@@ -135,6 +143,59 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, entries)
|
writeJSON(w, http.StatusOK, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) tags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tags, err := h.Store.Tags()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
writeJSON(w, http.StatusOK, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
type metaReq struct {
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
Favorite bool `json:"favorite"`
|
||||||
|
Add []string `json:"add"`
|
||||||
|
Remove []string `json:"remove"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) decodeMeta(w http.ResponseWriter, r *http.Request) (metaReq, bool) {
|
||||||
|
var req metaReq
|
||||||
|
if !h.decode(w, r, &req) {
|
||||||
|
return req, false
|
||||||
|
}
|
||||||
|
if len(req.Paths) == 0 || len(req.Paths) > 1000 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "entre 1 et 1000 chemins attendus")
|
||||||
|
return req, false
|
||||||
|
}
|
||||||
|
return req, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, ok := h.decodeMeta(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Store.SetFavorite(req.Paths, req.Favorite); err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) editTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, ok := h.decodeMeta(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Store.EditTags(req.Paths, req.Add, req.Remove); err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) readFile(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) readFile(w http.ResponseWriter, r *http.Request) {
|
||||||
data, etag, err := h.Store.Read(r.URL.Query().Get("path"))
|
data, etag, err := h.Store.Read(r.URL.Query().Get("path"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagsFavorites(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
s.Write("d/a.excalidraw", []byte(doc), "", true)
|
||||||
|
s.Write("d/b.excalidraw", []byte(doc), "", true)
|
||||||
|
s.Write("c.excalidraw", []byte(doc), "", true)
|
||||||
|
|
||||||
|
if err := s.EditTags([]string{"d/a.excalidraw", "d/b.excalidraw"}, []string{"#NSX", " Réseau cœur "}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Même tag sans casse ni accents : l'écriture existante est reprise.
|
||||||
|
if err := s.EditTags([]string{"c.excalidraw"}, []string{"nsx", "reseau cœur"}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tags, _ := s.Tags()
|
||||||
|
if len(tags) != 2 || tags[0] != (TagCount{"NSX", 3}) || tags[1] != (TagCount{"Réseau cœur", 3}) {
|
||||||
|
t.Fatalf("Tags = %+v", tags)
|
||||||
|
}
|
||||||
|
if err := s.EditTags([]string{"d/b.excalidraw"}, nil, []string{"NSX"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.SetFavorite([]string{"d/b.excalidraw", "d"}, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, bad := range [][]string{{""}, {"absent.excalidraw"}, {"../x"}} {
|
||||||
|
if err := s.SetFavorite(bad, true); err == nil {
|
||||||
|
t.Errorf("SetFavorite(%q) aurait dû échouer", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.EditTags([]string{"c.excalidraw"}, []string{"a,b"}, nil); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Fatalf("tag invalide : %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l, _ := s.List("d")
|
||||||
|
if len(l[0].Tags) != 2 || l[0].Favorite || len(l[1].Tags) != 1 || !l[1].Favorite {
|
||||||
|
t.Fatalf("List = %+v", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtres de recherche.
|
||||||
|
if h, _ := s.Search("", Filter{Tag: "nsx"}, 10); len(h) != 2 {
|
||||||
|
t.Fatalf("tag : %+v", h)
|
||||||
|
}
|
||||||
|
if h, _ := s.Search("", Filter{Favorite: true}, 10); len(h) != 2 {
|
||||||
|
t.Fatalf("favoris : %+v", h)
|
||||||
|
}
|
||||||
|
if h, _ := s.Search("reseau", Filter{Favorite: true}, 10); len(h) != 1 || h[0].Path != "d/b.excalidraw" {
|
||||||
|
t.Fatalf("mot (tag) + favoris : %+v", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les métadonnées suivent déplacement, copie (tags seuls) et suppression,
|
||||||
|
// et survivent au rechargement.
|
||||||
|
s.Move("d", "e")
|
||||||
|
s.Copy("e/b.excalidraw", "copie.excalidraw")
|
||||||
|
s.Delete("c.excalidraw", false)
|
||||||
|
s2, _ := New(s.root)
|
||||||
|
l, _ = s2.List("")
|
||||||
|
got := map[string]Entry{}
|
||||||
|
for _, e := range l {
|
||||||
|
got[e.Path] = e
|
||||||
|
}
|
||||||
|
if !got["e"].Favorite || got["copie.excalidraw"].Favorite || len(got["copie.excalidraw"].Tags) != 1 {
|
||||||
|
t.Fatalf("après déplacement/copie : %+v", l)
|
||||||
|
}
|
||||||
|
if tags, _ := s2.Tags(); len(tags) != 2 || tags[0] != (TagCount{"NSX", 1}) {
|
||||||
|
t.Fatalf("après suppression : %+v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,14 +139,23 @@ func snippet(d indexDoc, i, n int) string {
|
|||||||
return pre + string(runes[lo:hi]) + post
|
return pre + string(runes[lo:hi]) + post
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search renvoie les entrées dont le nom ou, pour un schéma, le contenu
|
// Filter restreint une recherche aux éléments portant un tag ou favoris.
|
||||||
// contient tous les mots de q (casse et accents ignorés). Les correspondances
|
type Filter struct {
|
||||||
// sur le nom viennent en premier.
|
Tag string
|
||||||
func (s *Store) Search(q string, limit int) ([]Hit, error) {
|
Favorite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search renvoie les entrées dont le nom, les tags ou, pour un schéma, le
|
||||||
|
// contenu contiennent tous les mots de q (casse et accents ignorés), et qui
|
||||||
|
// passent le filtre. Les correspondances sur le nom viennent en premier.
|
||||||
|
// q peut être vide si un filtre est donné.
|
||||||
|
func (s *Store) Search(q string, f Filter, limit int) ([]Hit, error) {
|
||||||
terms := strings.Fields(fold(q))
|
terms := strings.Fields(fold(q))
|
||||||
if len(terms) == 0 {
|
tag := fold(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(f.Tag), "#")))
|
||||||
|
if len(terms) == 0 && tag == "" && !f.Favorite {
|
||||||
return []Hit{}, nil
|
return []Hit{}, nil
|
||||||
}
|
}
|
||||||
|
meta := s.metaSnapshot()
|
||||||
s.index.mu.Lock()
|
s.index.mu.Lock()
|
||||||
defer s.index.mu.Unlock()
|
defer s.index.mu.Unlock()
|
||||||
if s.index.docs == nil {
|
if s.index.docs == nil {
|
||||||
@@ -176,13 +185,23 @@ func (s *Store) Search(q string, limit int) ([]Hit, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
name := fold(d.Name())
|
hit := Hit{Entry: toEntry(rel, fi)}
|
||||||
|
decorate(&hit.Entry, meta)
|
||||||
|
if f.Favorite && !hit.Favorite {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tags := fold(strings.Join(hit.Tags, "\n"))
|
||||||
|
if tag != "" && !strings.Contains("\n"+tags+"\n", "\n"+tag+"\n") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := fold(d.Name()) + "\n" + tags
|
||||||
var doc indexDoc
|
var doc indexDoc
|
||||||
if !d.IsDir() {
|
if !d.IsDir() {
|
||||||
seen[rel] = true
|
seen[rel] = true
|
||||||
|
if len(terms) > 0 {
|
||||||
doc = s.content(rel, p, fi)
|
doc = s.content(rel, p, fi)
|
||||||
}
|
}
|
||||||
hit := Hit{Entry: toEntry(rel, fi)}
|
}
|
||||||
inName := true
|
inName := true
|
||||||
for _, t := range terms {
|
for _, t := range terms {
|
||||||
if strings.Contains(name, t) {
|
if strings.Contains(name, t) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestSearchContent(t *testing.T) {
|
|||||||
s.Write("réplication.excalidraw", scene(`rien`), "", 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)
|
s.Write("supprimé.excalidraw", []byte(`{"elements":[{"type":"text","text":"172.27.207.99","isDeleted":true}]}`), "", true)
|
||||||
|
|
||||||
hits, err := s.Search("172.27.207", 10)
|
hits, err := s.Search("172.27.207", Filter{}, 10)
|
||||||
if err != nil || len(hits) != 1 || hits[0].Path != "réseau/sdn.excalidraw" {
|
if err != nil || len(hits) != 1 || hits[0].Path != "réseau/sdn.excalidraw" {
|
||||||
t.Fatalf("IP : %+v, %v", hits, err)
|
t.Fatalf("IP : %+v, %v", hits, err)
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func TestSearchContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accents et casse ignorés ; le nom passe avant le contenu.
|
// Accents et casse ignorés ; le nom passe avant le contenu.
|
||||||
hits, _ = s.Search("REPLICATION", 10)
|
hits, _ = s.Search("REPLICATION", Filter{}, 10)
|
||||||
if len(hits) != 2 || hits[0].Path != "réplication.excalidraw" || hits[1].Path != "stockage/vsan.excalidraw" {
|
if len(hits) != 2 || hits[0].Path != "réplication.excalidraw" || hits[1].Path != "stockage/vsan.excalidraw" {
|
||||||
t.Fatalf("accents : %+v", hits)
|
t.Fatalf("accents : %+v", hits)
|
||||||
}
|
}
|
||||||
@@ -39,24 +39,24 @@ func TestSearchContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tous les mots doivent correspondre, dans le nom ou le contenu.
|
// Tous les mots doivent correspondre, dans le nom ou le contenu.
|
||||||
if hits, _ := s.Search("sdn overlay", 10); len(hits) != 1 {
|
if hits, _ := s.Search("sdn overlay", Filter{}, 10); len(hits) != 1 {
|
||||||
t.Fatalf("nom + contenu : %+v", hits)
|
t.Fatalf("nom + contenu : %+v", hits)
|
||||||
}
|
}
|
||||||
if hits, _ := s.Search("sdn vsan", 10); len(hits) != 0 {
|
if hits, _ := s.Search("sdn vsan", Filter{}, 10); len(hits) != 0 {
|
||||||
t.Fatalf("ET logique : %+v", hits)
|
t.Fatalf("ET logique : %+v", hits)
|
||||||
}
|
}
|
||||||
// Les dossiers correspondent par leur nom.
|
// Les dossiers correspondent par leur nom.
|
||||||
if hits, _ := s.Search("stock", 10); len(hits) != 1 || !hits[0].IsDir {
|
if hits, _ := s.Search("stock", Filter{}, 10); len(hits) != 1 || !hits[0].IsDir {
|
||||||
t.Fatalf("dossier : %+v", hits)
|
t.Fatalf("dossier : %+v", hits)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Une modification est prise en compte (date ou taille changée).
|
// Une modification est prise en compte (date ou taille changée).
|
||||||
time.Sleep(5 * time.Millisecond)
|
time.Sleep(5 * time.Millisecond)
|
||||||
s.Write("réseau/sdn.excalidraw", scene(`Edge NSX 10.0.0.1`), "", false)
|
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 {
|
if hits, _ := s.Search("172.27.207", Filter{}, 10); len(hits) != 0 {
|
||||||
t.Fatalf("index périmé : %+v", hits)
|
t.Fatalf("index périmé : %+v", hits)
|
||||||
}
|
}
|
||||||
if hits, _ := s.Search("10.0.0", 10); len(hits) != 1 {
|
if hits, _ := s.Search("10.0.0", Filter{}, 10); len(hits) != 1 {
|
||||||
t.Fatalf("index non mis à jour : %+v", hits)
|
t.Fatalf("index non mis à jour : %+v", hits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ type Entry struct {
|
|||||||
IsDir bool `json:"isDir"`
|
IsDir bool `json:"isDir"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
ModTime time.Time `json:"modTime"`
|
ModTime time.Time `json:"modTime"`
|
||||||
|
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Favorite bool `json:"favorite,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store est un dépôt de schémas enraciné dans un répertoire.
|
// Store est un dépôt de schémas enraciné dans un répertoire.
|
||||||
@@ -58,6 +61,9 @@ type Store struct {
|
|||||||
|
|
||||||
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)
|
index textIndex // texte des schémas pour la recherche (search.go)
|
||||||
|
|
||||||
|
metaMu sync.Mutex // tags et favoris (meta.go)
|
||||||
|
meta map[string]itemMeta // nil : pas encore chargé
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -165,6 +171,7 @@ func (s *Store) List(rel string) ([]Entry, error) {
|
|||||||
return nil, mapErr(err)
|
return nil, mapErr(err)
|
||||||
}
|
}
|
||||||
out := make([]Entry, 0, len(des))
|
out := make([]Entry, 0, len(des))
|
||||||
|
meta := s.metaSnapshot()
|
||||||
for _, de := range des {
|
for _, de := range des {
|
||||||
if !visible(de.Name(), de.IsDir()) || de.Type()&fs.ModeSymlink != 0 {
|
if !visible(de.Name(), de.IsDir()) || de.Type()&fs.ModeSymlink != 0 {
|
||||||
continue
|
continue
|
||||||
@@ -173,7 +180,9 @@ func (s *Store) List(rel string) ([]Entry, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, toEntry(path.Join(c, de.Name()), fi))
|
e := toEntry(path.Join(c, de.Name()), fi)
|
||||||
|
decorate(&e, meta)
|
||||||
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
if out[i].IsDir != out[j].IsDir {
|
if out[i].IsDir != out[j].IsDir {
|
||||||
@@ -335,6 +344,7 @@ func (s *Store) Move(from, to string) (Entry, error) {
|
|||||||
}
|
}
|
||||||
s.moveThumb(cf, ct, fi.IsDir())
|
s.moveThumb(cf, ct, fi.IsDir())
|
||||||
s.moveHistory(cf, ct)
|
s.moveHistory(cf, ct)
|
||||||
|
s.moveMeta(cf, ct)
|
||||||
fi, err = os.Stat(at)
|
fi, err = os.Stat(at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Entry{}, err
|
return Entry{}, err
|
||||||
@@ -400,6 +410,9 @@ func (s *Store) Copy(from, to string) (Entry, error) {
|
|||||||
e, _, err := s.Write(to, data, "", true)
|
e, _, err := s.Write(to, data, "", true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.copyThumb(from, e.Path)
|
s.copyThumb(from, e.Path)
|
||||||
|
if cf, err := Clean(from); err == nil {
|
||||||
|
s.copyMeta(cf, e.Path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
@@ -436,6 +449,7 @@ func (s *Store) Delete(rel string, recursive bool) error {
|
|||||||
}
|
}
|
||||||
s.removeThumb(c, fi.IsDir())
|
s.removeThumb(c, fi.IsDir())
|
||||||
s.trashHistory(c, trash)
|
s.trashHistory(c, trash)
|
||||||
|
s.removeMeta(c)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestListMoveDelete(t *testing.T) {
|
|||||||
if _, err := s.Move("dir", "dir/sub"); !errors.Is(err, ErrInvalidPath) {
|
if _, err := s.Move("dir", "dir/sub"); !errors.Is(err, ErrInvalidPath) {
|
||||||
t.Fatalf("déplacement dans soi-même : %v", err)
|
t.Fatalf("déplacement dans soi-même : %v", err)
|
||||||
}
|
}
|
||||||
if res, _ := s.Search("Y.EXCAL", 10); len(res) != 1 || res[0].Path != "dir/y.excalidraw" {
|
if res, _ := s.Search("Y.EXCAL", Filter{}, 10); len(res) != 1 || res[0].Path != "dir/y.excalidraw" {
|
||||||
t.Fatalf("Search = %+v", res)
|
t.Fatalf("Search = %+v", res)
|
||||||
}
|
}
|
||||||
if err := s.Delete("dir", false); !errors.Is(err, ErrNotEmpty) {
|
if err := s.Delete("dir", false); !errors.Is(err, ErrNotEmpty) {
|
||||||
|
|||||||
Reference in new issue
Block a user