Historique des versions : snapshots horodatés, différences, restauration
- Avant d'écraser un schéma, le contenu précédent est copié dans DATA_DIR/.history/<chemin>/<date>.excalidraw, au plus une version par HISTORY_INTERVAL (10 min) ; rétention HISTORY_KEEP (50) et HISTORY_MAX_AGE. Pas de git : l'image reste distroless. - API : GET /api/history, GET /api/history/version, POST /api/history/snapshot, POST /api/history/restore (le contenu remplacé devient une version). - L'historique suit renommages et déplacements, part à la corbeille avec le schéma supprimé. - Dialogue Historique (barre du haut, menu de l'explorateur) : aperçu, différences colorées avec l'état actuel, restaurer, restaurer en copie, créer une version. 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
88f7750577
commit
9f9ec3b3a4
14 files changed
+1005
-7
No files matched your search
@@ -4,4 +4,7 @@ AUTH_PASSWORD=
|
|||||||
MAX_BODY_MB=50
|
MAX_BODY_MB=50
|
||||||
UPDATE_CHECK=true
|
UPDATE_CHECK=true
|
||||||
UPDATE_CHECK_INTERVAL=12h
|
UPDATE_CHECK_INTERVAL=12h
|
||||||
|
HISTORY_KEEP=50
|
||||||
|
HISTORY_INTERVAL=10m
|
||||||
|
HISTORY_MAX_AGE=0
|
||||||
EXCALIDRAW_VERSION=
|
EXCALIDRAW_VERSION=
|
||||||
@@ -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. 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, 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)
|
||||||
|
├─ .history/projets/réseau/sdn.excalidraw/<date>.excalidraw ← versions
|
||||||
└─ .trash/<horodatage>/... ← éléments supprimés
|
└─ .trash/<horodatage>/... ← éléments supprimés
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,6 +43,9 @@ docker compose up -d --build
|
|||||||
| `UPDATE_CHECK` | `true` | Interroge `registry.npmjs.org` pour signaler une nouvelle version d'Excalidraw (`false` : désactivé) |
|
| `UPDATE_CHECK` | `true` | Interroge `registry.npmjs.org` pour signaler une nouvelle version d'Excalidraw (`false` : désactivé) |
|
||||||
| `UPDATE_CHECK_INTERVAL` | `12h` | Intervalle entre deux vérifications (durée Go, minimum `1m`) |
|
| `UPDATE_CHECK_INTERVAL` | `12h` | Intervalle entre deux vérifications (durée Go, minimum `1m`) |
|
||||||
| `NPM_REGISTRY` | `https://registry.npmjs.org` | Registre interrogé (miroir interne possible) |
|
| `NPM_REGISTRY` | `https://registry.npmjs.org` | Registre interrogé (miroir interne possible) |
|
||||||
|
| `HISTORY_KEEP` | `50` | Versions conservées par schéma (`0` : historique désactivé) |
|
||||||
|
| `HISTORY_INTERVAL` | `10m` | Écart minimal entre deux versions automatiques (`0` : une version à chaque enregistrement) |
|
||||||
|
| `HISTORY_MAX_AGE` | `0` | Âge au-delà duquel une version est purgée (ex. `2160h` = 90 jours ; `0` : jamais) |
|
||||||
| `EXCALIDRAW_VERSION` (build) | vide | Version d'Excalidraw imposée à la construction de l'image ; vide = celle de `package-lock.json` |
|
| `EXCALIDRAW_VERSION` (build) | vide | Version d'Excalidraw imposée à la construction de l'image ; vide = celle de `package-lock.json` |
|
||||||
|
|
||||||
Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:65532 ./data`.
|
Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:65532 ./data`.
|
||||||
@@ -89,6 +93,20 @@ 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
Le dialogue *Historique* (icône horloge de la barre du haut, ou menu « … » d'un schéma dans l'explorateur) :
|
||||||
|
|
||||||
|
- liste les versions et affiche un aperçu de chacune ;
|
||||||
|
- **Différences** : l'état actuel superposé à la version choisie, éléments ajoutés depuis en vert, supprimés depuis en rouge pointillé, modifiés en orange, le reste estompé (comparaison par identifiant d'élément Excalidraw) ;
|
||||||
|
- **Restaurer** remplace le schéma par la version ; l'état remplacé devient lui-même une version, la restauration s'annule donc ;
|
||||||
|
- **Restaurer en copie** crée un nouveau schéma « nom (version du …) » à côté de l'original ;
|
||||||
|
- **Créer une version** fixe immédiatement l'état actuel (jalon avant une modification importante).
|
||||||
|
|
||||||
|
L'historique suit les renommages et déplacements ; à la suppression d'un schéma, il part avec lui dans la corbeille (`.trash/<horodatage>/.history/`). Pas de binaire `git` requis : l'image reste distroless.
|
||||||
|
|
||||||
### Concurrence
|
### Concurrence
|
||||||
|
|
||||||
Chaque lecture renvoie un `ETag` (SHA-256 tronqué du contenu) ; chaque sauvegarde l'envoie en `If-Match`. Si le fichier a été modifié ailleurs (autre onglet, autre poste), le serveur répond `412` et l'interface propose d'écraser ou de recharger. Il n'y a pas d'édition collaborative temps réel (voir *Évolutions*).
|
Chaque lecture renvoie un `ETag` (SHA-256 tronqué du contenu) ; chaque sauvegarde l'envoie en `If-Match`. Si le fichier a été modifié ailleurs (autre onglet, autre poste), le serveur répond `412` et l'interface propose d'écraser ou de recharger. Il n'y a pas d'édition collaborative temps réel (voir *Évolutions*).
|
||||||
@@ -110,6 +128,10 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co
|
|||||||
| PUT | `/api/thumb?path=x.excalidraw` | PNG (1 Mo max) | `204` |
|
| PUT | `/api/thumb?path=x.excalidraw` | PNG (1 Mo max) | `204` |
|
||||||
| POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` |
|
| POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` |
|
||||||
| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) |
|
| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) |
|
||||||
|
| GET | `/api/history?path=x.excalidraw` | | `[{"id","time","size"}]`, la plus récente en premier |
|
||||||
|
| GET | `/api/history/version?path=x.excalidraw&id=<id>` | | JSON de la version |
|
||||||
|
| POST | `/api/history/snapshot` | `{"path":"x.excalidraw"}` | `{"id","time","size"}` (version du contenu courant) |
|
||||||
|
| POST | `/api/history/restore` | `{"path":"x.excalidraw","id":"<id>"}` | `Entry` + `ETag` |
|
||||||
| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | |
|
| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | |
|
||||||
| GET | `/api/settings` | | `{"title":"...","logo":"<version>","channel":"latest"}` (`logo` vide sans logo) |
|
| GET | `/api/settings` | | `{"title":"...","logo":"<version>","channel":"latest"}` (`logo` vide sans logo) |
|
||||||
| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) et/ou `{"channel":"latest"\|"next"}` | réglages |
|
| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) et/ou `{"channel":"latest"\|"next"}` | réglages |
|
||||||
@@ -145,7 +167,7 @@ Arborescence :
|
|||||||
|
|
||||||
```
|
```
|
||||||
cmd/server/main.go serveur HTTP, SPA embarquée, auth Basic, healthcheck
|
cmd/server/main.go serveur HTTP, SPA embarquée, auth Basic, healthcheck
|
||||||
internal/storage/ accès disque : sécurité des chemins, écriture atomique, ETag, corbeille
|
internal/storage/ accès disque : sécurité des chemins, écriture atomique, ETag, corbeille, historique
|
||||||
internal/api/ routes REST
|
internal/api/ routes REST
|
||||||
web/embed.go embarque web/dist (produit par le build Vite)
|
web/embed.go embarque web/dist (produit par le build Vite)
|
||||||
frontend/src/App.tsx éditeur Excalidraw, sauvegarde auto, conflits, bibliothèque
|
frontend/src/App.tsx éditeur Excalidraw, sauvegarde auto, conflits, bibliothèque
|
||||||
@@ -153,6 +175,7 @@ frontend/src/Explorer.tsx gestionnaire de fichiers (vignettes/liste, sélectio
|
|||||||
frontend/src/MoveDialog.tsx sélecteur de dossier « Déplacer vers… »
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
@@ -165,6 +188,5 @@ frontend/src/thumbs.ts rendu PNG des miniatures et file de génération
|
|||||||
## Limites et évolutions possibles
|
## Limites et évolutions possibles
|
||||||
|
|
||||||
- **Collaboration temps réel** : nécessiterait le serveur [excalidraw-room](https://github.com/excalidraw/excalidraw-room) (WebSocket) et la logique de réconciliation (`reconcileElements`) côté client.
|
- **Collaboration temps réel** : nécessiterait le serveur [excalidraw-room](https://github.com/excalidraw/excalidraw-room) (WebSocket) et la logique de réconciliation (`reconcileElements`) côté client.
|
||||||
- **Historique de versions** : commit git automatique dans `DATA_DIR` à chaque sauvegarde, ou snapshots du volume (ZFS/Btrfs).
|
|
||||||
- **Multi-utilisateurs avec espaces privés** : identité transmise par le reverse proxy (`X-Forwarded-User`) et racine par utilisateur.
|
- **Multi-utilisateurs avec espaces privés** : identité transmise par le reverse proxy (`X-Forwarded-User`) et racine par utilisateur.
|
||||||
- Les schémas avec beaucoup d'images embarquées sont volumineux (base64 dans le JSON) ; ajuster `MAX_BODY_MB`.
|
- Les schémas avec beaucoup d'images embarquées sont volumineux (base64 dans le JSON) ; ajuster `MAX_BODY_MB`.
|
||||||
+25
-1
@@ -50,6 +50,13 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Historique des versions : HISTORY_KEEP versions par schéma (0 : désactivé),
|
||||||
|
// au plus une toutes les HISTORY_INTERVAL, purgées après HISTORY_MAX_AGE (0 : jamais).
|
||||||
|
if store.History, err = historyPolicy(); err != nil {
|
||||||
|
slog.Error("historique", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
@@ -91,7 +98,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("démarrage", "addr", addr, "data", dataDir)
|
slog.Info("démarrage", "addr", addr, "data", dataDir,
|
||||||
|
"history_keep", store.History.Keep, "history_interval", store.History.Interval.String())
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
slog.Error("serveur", "err", err)
|
slog.Error("serveur", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -104,6 +112,22 @@ func main() {
|
|||||||
slog.Info("arrêt")
|
slog.Info("arrêt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func historyPolicy() (storage.HistoryPolicy, error) {
|
||||||
|
p := storage.DefaultHistory
|
||||||
|
keep, err := strconv.Atoi(env("HISTORY_KEEP", strconv.Itoa(p.Keep)))
|
||||||
|
if err != nil || keep < 0 {
|
||||||
|
return p, errors.New("HISTORY_KEEP invalide (entier >= 0)")
|
||||||
|
}
|
||||||
|
p.Keep = keep
|
||||||
|
if p.Interval, err = time.ParseDuration(env("HISTORY_INTERVAL", p.Interval.String())); err != nil || p.Interval < 0 {
|
||||||
|
return p, errors.New("HISTORY_INTERVAL invalide (ex. 10m, 0 : à chaque enregistrement)")
|
||||||
|
}
|
||||||
|
if p.MaxAge, err = time.ParseDuration(env("HISTORY_MAX_AGE", "0")); err != nil || p.MaxAge < 0 {
|
||||||
|
return p, errors.New("HISTORY_MAX_AGE invalide (ex. 2160h, 0 : illimité)")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
func healthcheck(addr string) int {
|
func healthcheck(addr string) int {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ services:
|
|||||||
# Icône « mise à jour d'Excalidraw disponible » (interroge registry.npmjs.org).
|
# Icône « mise à jour d'Excalidraw disponible » (interroge registry.npmjs.org).
|
||||||
UPDATE_CHECK: ${UPDATE_CHECK:-true}
|
UPDATE_CHECK: ${UPDATE_CHECK:-true}
|
||||||
UPDATE_CHECK_INTERVAL: ${UPDATE_CHECK_INTERVAL:-12h}
|
UPDATE_CHECK_INTERVAL: ${UPDATE_CHECK_INTERVAL:-12h}
|
||||||
|
# Historique des versions (DATA_DIR/.history) : versions gardées par schéma
|
||||||
|
# (0 : désactivé), écart minimal entre deux versions, âge max (0 : illimité).
|
||||||
|
HISTORY_KEEP: ${HISTORY_KEEP:-50}
|
||||||
|
HISTORY_INTERVAL: ${HISTORY_INTERVAL:-10m}
|
||||||
|
HISTORY_MAX_AGE: ${HISTORY_MAX_AGE:-0}
|
||||||
volumes:
|
volumes:
|
||||||
# Volume nommé : les droits (uid 65532) sont hérités de l'image.
|
# Volume nommé : les droits (uid 65532) sont hérités de l'image.
|
||||||
# Pour un bind mount (ex. ./data:/data), faire `chown -R 65532:65532 ./data`.
|
# Pour un bind mount (ex. ./data:/data), faire `chown -R 65532:65532 ./data`.
|
||||||
|
|||||||
+47
-1
@@ -16,7 +16,8 @@ import type {
|
|||||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||||
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
||||||
import { Explorer } from "./Explorer";
|
import { Explorer } from "./Explorer";
|
||||||
import { CloseIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, UpdateIcon } from "./icons";
|
import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, UpdateIcon } from "./icons";
|
||||||
|
import { HistoryDialog } from "./HistoryDialog";
|
||||||
import { Splitter } from "./Splitter";
|
import { Splitter } from "./Splitter";
|
||||||
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
|
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
|
||||||
import { pendingVersion, useUpdateStatus } from "./update";
|
import { pendingVersion, useUpdateStatus } from "./update";
|
||||||
@@ -91,6 +92,7 @@ export default function App() {
|
|||||||
const updateStatus = useUpdateStatus();
|
const updateStatus = useUpdateStatus();
|
||||||
const pending = pendingVersion(updateStatus, settings.channel);
|
const pending = pendingVersion(updateStatus, settings.channel);
|
||||||
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
||||||
|
const [historyFor, setHistoryFor] = useState<string | null>(null);
|
||||||
|
|
||||||
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
||||||
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
|
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
|
||||||
@@ -335,6 +337,28 @@ export default function App() {
|
|||||||
[closeFile],
|
[closeFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Historique -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Les modifications en cours sont enregistrées avant d'afficher l'historique. */
|
||||||
|
const showHistory = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
if (path === pathRef.current) await save();
|
||||||
|
setHistoryFor(path);
|
||||||
|
},
|
||||||
|
[save],
|
||||||
|
);
|
||||||
|
|
||||||
|
const restoreVersion = useCallback(
|
||||||
|
async (path: string, id: string) => {
|
||||||
|
const open = path === pathRef.current;
|
||||||
|
if (open) await save();
|
||||||
|
await api.restore(path, id);
|
||||||
|
if (open) await openFile(path, { skipFlush: true });
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
},
|
||||||
|
[save, openFile],
|
||||||
|
);
|
||||||
|
|
||||||
// --- Rendu -----------------------------------------------------------------
|
// --- Rendu -----------------------------------------------------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -378,6 +402,14 @@ export default function App() {
|
|||||||
<SaveIcon />
|
<SaveIcon />
|
||||||
<span className="save-label">Enregistrer</span>
|
<span className="save-label">Enregistrer</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
aria-label="Historique des versions"
|
||||||
|
title="Historique des versions"
|
||||||
|
onClick={() => showHistory(scene.path)}
|
||||||
|
>
|
||||||
|
<HistoryIcon />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
aria-label="Fermer le schéma"
|
aria-label="Fermer le schéma"
|
||||||
@@ -425,6 +457,7 @@ export default function App() {
|
|||||||
onOpen={(p) => openFile(p)}
|
onOpen={(p) => openFile(p)}
|
||||||
onMoved={onMoved}
|
onMoved={onMoved}
|
||||||
onDeleted={onDeleted}
|
onDeleted={onDeleted}
|
||||||
|
onHistory={showHistory}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<Splitter
|
<Splitter
|
||||||
@@ -461,6 +494,19 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{historyFor && (
|
||||||
|
<HistoryDialog
|
||||||
|
path={historyFor}
|
||||||
|
theme={theme}
|
||||||
|
onRestore={(id) => restoreVersion(historyFor, id)}
|
||||||
|
onCopied={(p) => {
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
openFile(p);
|
||||||
|
}}
|
||||||
|
onClose={() => setHistoryFor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{customizing && (
|
{customizing && (
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ interface Props {
|
|||||||
onMoved: (from: string, to: string) => void;
|
onMoved: (from: string, to: string) => void;
|
||||||
/** Un fichier ou dossier a été supprimé. */
|
/** Un fichier ou dossier a été supprimé. */
|
||||||
onDeleted: (path: string) => void;
|
onDeleted: (path: string) => void;
|
||||||
|
/** Affiche l'historique des versions d'un schéma. */
|
||||||
|
onHistory: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type View = "list" | "grid";
|
type View = "list" | "grid";
|
||||||
@@ -78,7 +80,7 @@ const initialView = (): View => {
|
|||||||
const canMoveInto = (path: string, targetDir: string) =>
|
const canMoveInto = (path: string, targetDir: string) =>
|
||||||
dirname(path) !== targetDir && targetDir !== path && !targetDir.startsWith(path + "/");
|
dirname(path) !== targetDir && targetDir !== path && !targetDir.startsWith(path + "/");
|
||||||
|
|
||||||
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Props) {
|
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHistory }: Props) {
|
||||||
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("");
|
||||||
@@ -421,6 +423,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
<button onClick={() => rename(e)}>Renommer</button>
|
<button onClick={() => rename(e)}>Renommer</button>
|
||||||
<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 && (
|
{!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
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { exportToSvg, restoreElements } from "@excalidraw/excalidraw";
|
||||||
|
import type { BinaryFiles } from "@excalidraw/excalidraw/types";
|
||||||
|
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||||
|
import { api, basename, dirname, join, stripExt, withExt, type Version } from "./api";
|
||||||
|
import { DownloadIcon, HistoryIcon } from "./icons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
path: string;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
/** Restaure la version (le parent enregistre et recharge le schéma ouvert). */
|
||||||
|
onRestore: (id: string) => Promise<void>;
|
||||||
|
/** Une copie a été créée à partir d'une version. */
|
||||||
|
onCopied: (path: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scene {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
files: BinaryFiles;
|
||||||
|
background: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type View = "version" | "current" | "diff";
|
||||||
|
|
||||||
|
const CURRENT = "";
|
||||||
|
|
||||||
|
const parse = (text: string): Scene => {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
return {
|
||||||
|
elements: restoreElements(data.elements ?? [], null).filter((e) => !e.isDeleted),
|
||||||
|
files: data.files ?? {},
|
||||||
|
background: data.appState?.viewBackgroundColor ?? "#ffffff",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
|
||||||
|
const fmtAge = (iso: string) => {
|
||||||
|
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||||
|
if (s < 3600) return rtf.format(-Math.max(1, Math.round(s / 60)), "minute");
|
||||||
|
if (s < 86400) return rtf.format(-Math.round(s / 3600), "hour");
|
||||||
|
return rtf.format(-Math.round(s / 86400), "day");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtSize = (n: number) => (n < 1 << 20 ? `${Math.max(1, Math.round(n / 1024))} Ko` : `${(n / (1 << 20)).toFixed(1)} Mo`);
|
||||||
|
|
||||||
|
// Différences élément par élément (identifiants stables dans Excalidraw).
|
||||||
|
const DIFF = {
|
||||||
|
added: { stroke: "#2f9e44", fill: "#b2f2bb", label: "ajouté" },
|
||||||
|
removed: { stroke: "#e03131", fill: "#ffc9c9", label: "supprimé" },
|
||||||
|
changed: { stroke: "#f08c00", fill: "#ffec99", label: "modifié" },
|
||||||
|
} as const;
|
||||||
|
type Change = keyof typeof DIFF;
|
||||||
|
|
||||||
|
function diff(older: Scene, now: Scene) {
|
||||||
|
const before = new Map(older.elements.map((e) => [e.id, e]));
|
||||||
|
const after = new Map(now.elements.map((e) => [e.id, e]));
|
||||||
|
const kind = new Map<string, Change>();
|
||||||
|
for (const [id, e] of after) {
|
||||||
|
const o = before.get(id);
|
||||||
|
if (!o) kind.set(id, "added");
|
||||||
|
else if (o.version !== e.version || o.versionNonce !== e.versionNonce) kind.set(id, "changed");
|
||||||
|
}
|
||||||
|
for (const id of before.keys()) if (!after.has(id)) kind.set(id, "removed");
|
||||||
|
const count = { added: 0, removed: 0, changed: 0 };
|
||||||
|
for (const k of kind.values()) count[k]++;
|
||||||
|
|
||||||
|
// Scène de comparaison : l'état actuel plus les éléments disparus, colorés
|
||||||
|
// selon le changement ; ce qui n'a pas bougé est estompé.
|
||||||
|
const paint = (e: ExcalidrawElement): ExcalidrawElement => {
|
||||||
|
const container = "containerId" in e ? e.containerId : null; // texte d'une forme
|
||||||
|
const k = kind.get(e.id) ?? (container ? kind.get(container) : undefined);
|
||||||
|
if (!k) return { ...e, opacity: 20 };
|
||||||
|
const c = DIFF[k];
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
opacity: 100,
|
||||||
|
strokeColor: c.stroke,
|
||||||
|
backgroundColor: e.backgroundColor === "transparent" ? "transparent" : c.fill,
|
||||||
|
...(k === "removed" ? { strokeStyle: "dashed" as const } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const elements = [...now.elements, ...older.elements.filter((e) => !after.has(e.id))].map(paint);
|
||||||
|
return { count, scene: { elements, files: { ...older.files, ...now.files }, background: now.background } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Historique d'un schéma : aperçu, comparaison avec l'état actuel, restauration. */
|
||||||
|
export function HistoryDialog({ path, theme, onRestore, onCopied, onClose }: Props) {
|
||||||
|
const [versions, setVersions] = useState<Version[] | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [view, setView] = useState<View>("diff");
|
||||||
|
const [scenes, setScenes] = useState<Record<string, Scene>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const preview = useRef<HTMLDivElement>(null);
|
||||||
|
const name = stripExt(basename(path));
|
||||||
|
|
||||||
|
const run = async (op: () => Promise<unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await op();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadList = () =>
|
||||||
|
run(async () => {
|
||||||
|
const [vs, cur] = await Promise.all([api.history(path), api.read(path)]);
|
||||||
|
setScenes((s) => ({ ...s, [CURRENT]: parse(cur.text) }));
|
||||||
|
setVersions(vs);
|
||||||
|
setSelected((sel) => sel ?? vs[0]?.id ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadList();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Contenu de la version choisie (mis en cache : une version ne change pas).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected || scenes[selected]) return;
|
||||||
|
let live = true;
|
||||||
|
api
|
||||||
|
.readVersion(path, selected)
|
||||||
|
.then((t) => live && setScenes((s) => ({ ...s, [selected]: parse(t) })))
|
||||||
|
.catch((e) => live && setError(e.message));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [path, selected, scenes]);
|
||||||
|
|
||||||
|
const older = selected ? scenes[selected] : undefined;
|
||||||
|
const now = scenes[CURRENT];
|
||||||
|
const comparison = useMemo(() => (older && now ? diff(older, now) : null), [older, now]);
|
||||||
|
const shown = view === "current" ? now : view === "version" ? older : comparison?.scene;
|
||||||
|
|
||||||
|
// Aperçu SVG rendu par Excalidraw.
|
||||||
|
useEffect(() => {
|
||||||
|
const box = preview.current;
|
||||||
|
if (!box) return;
|
||||||
|
box.replaceChildren();
|
||||||
|
if (!shown) return;
|
||||||
|
if (shown.elements.length === 0) {
|
||||||
|
box.textContent = "Schéma vide";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
exportToSvg({
|
||||||
|
elements: shown.elements,
|
||||||
|
appState: {
|
||||||
|
viewBackgroundColor: shown.background,
|
||||||
|
exportBackground: true,
|
||||||
|
exportWithDarkMode: theme === "dark",
|
||||||
|
},
|
||||||
|
files: shown.files,
|
||||||
|
exportPadding: 16,
|
||||||
|
})
|
||||||
|
.then((svg: SVGSVGElement) => {
|
||||||
|
if (!live) return;
|
||||||
|
svg.removeAttribute("width");
|
||||||
|
svg.removeAttribute("height");
|
||||||
|
box.replaceChildren(svg);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => live && setError(`Aperçu impossible : ${e.message}`));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [shown, theme]);
|
||||||
|
|
||||||
|
const current = versions?.find((v) => v.id === selected);
|
||||||
|
|
||||||
|
const restore = () =>
|
||||||
|
current &&
|
||||||
|
confirm(`Restaurer la version du ${fmtDate(current.time)} ?\n\nL'état actuel est conservé dans l'historique.`) &&
|
||||||
|
run(async () => {
|
||||||
|
await onRestore(current.id);
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
const copy = () =>
|
||||||
|
current &&
|
||||||
|
run(async () => {
|
||||||
|
const d = new Date(current.time);
|
||||||
|
const stamp = `${d.toLocaleDateString().replace(/\//g, "-")} ${String(d.getHours()).padStart(2, "0")}h${String(d.getMinutes()).padStart(2, "0")}`;
|
||||||
|
const to = join(dirname(path), withExt(`${name} (version du ${stamp})`));
|
||||||
|
await api.write(to, await api.readVersion(path, current.id), "*");
|
||||||
|
onCopied(to);
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = () =>
|
||||||
|
run(async () => {
|
||||||
|
const v = await api.snapshot(path);
|
||||||
|
setSelected(v.id);
|
||||||
|
await loadList();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="modal history"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Historique de ${name}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="history-head">
|
||||||
|
<h3>Historique · {name}</h3>
|
||||||
|
<button disabled={busy} onClick={snapshot} title="Enregistrer l'état actuel comme version">
|
||||||
|
<HistoryIcon />
|
||||||
|
<span>Créer une version</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-body">
|
||||||
|
<ul className="versions-list">
|
||||||
|
{versions?.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
<button className={v.id === selected ? "on" : ""} onClick={() => setSelected(v.id)}>
|
||||||
|
<span>{fmtDate(v.time)}</span>
|
||||||
|
<small className="muted">
|
||||||
|
{fmtAge(v.time)} · {fmtSize(v.size)}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{versions?.length === 0 && (
|
||||||
|
<li className="muted empty">
|
||||||
|
Aucune version pour l'instant. Une version est conservée automatiquement quand le schéma est
|
||||||
|
modifié après une pause ; « Créer une version » en fixe une tout de suite.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="history-preview">
|
||||||
|
<div className="segmented" role="group" aria-label="Aperçu">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["diff", "Différences"],
|
||||||
|
["version", "Cette version"],
|
||||||
|
["current", "Actuelle"],
|
||||||
|
] as [View, string][]
|
||||||
|
).map(([id, label]) => (
|
||||||
|
<button key={id} className={view === id ? "on" : ""} disabled={!selected} onClick={() => setView(id)}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="svg-box" ref={preview} />
|
||||||
|
{view === "diff" && comparison && (
|
||||||
|
<p className="legend">
|
||||||
|
<span className="muted">Depuis cette version :</span>
|
||||||
|
{(Object.keys(DIFF) as Change[]).map((k) => (
|
||||||
|
<span key={k}>
|
||||||
|
<i style={{ background: DIFF[k].stroke }} />
|
||||||
|
{comparison.count[k]} {DIFF[k].label}
|
||||||
|
{comparison.count[k] > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
{current && (
|
||||||
|
<a className="link" href={api.versionUrl(path, current.id)} download={`${name}.excalidraw`} title="Télécharger cette version">
|
||||||
|
<DownloadIcon />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={onClose}>Fermer</button>
|
||||||
|
<button disabled={busy || !current} onClick={copy} title="Créer un nouveau schéma à partir de cette version">
|
||||||
|
Restaurer en copie
|
||||||
|
</button>
|
||||||
|
<button className="primary" disabled={busy || !current} onClick={restore}>
|
||||||
|
Restaurer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,13 @@ export interface UpdateStatus {
|
|||||||
checkedAt?: string;
|
checkedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Version enregistrée d'un schéma (time : date de ce contenu). */
|
||||||
|
export interface Version {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -98,6 +105,22 @@ export const api = {
|
|||||||
await request(`/api/entry?path=${q(path)}&recursive=${recursive}`, { method: "DELETE" });
|
await request(`/api/entry?path=${q(path)}&recursive=${recursive}`, { method: "DELETE" });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
history: async (path: string): Promise<Version[]> =>
|
||||||
|
(await request(`/api/history?path=${q(path)}`)).json(),
|
||||||
|
|
||||||
|
readVersion: async (path: string, id: string): Promise<string> =>
|
||||||
|
(await request(`/api/history/version?path=${q(path)}&id=${q(id)}`)).text(),
|
||||||
|
|
||||||
|
/** Crée une version du contenu courant (sans attendre l'intervalle automatique). */
|
||||||
|
snapshot: async (path: string): Promise<Version> =>
|
||||||
|
(await request("/api/history/snapshot", json({ path }))).json(),
|
||||||
|
|
||||||
|
/** Restaure une version ; le contenu remplacé devient lui-même une version. */
|
||||||
|
restore: async (path: string, id: string): Promise<Entry> =>
|
||||||
|
(await request("/api/history/restore", json({ path, id }))).json(),
|
||||||
|
|
||||||
|
versionUrl: (path: string, id: string) => `/api/history/version?path=${q(path)}&id=${q(id)}`,
|
||||||
|
|
||||||
readLibrary: async (): Promise<unknown> => (await request("/api/library")).json(),
|
readLibrary: async (): Promise<unknown> => (await request("/api/library")).json(),
|
||||||
|
|
||||||
writeLibrary: async (body: string): Promise<void> => {
|
writeLibrary: async (body: string): Promise<void> => {
|
||||||
|
|||||||
@@ -149,3 +149,17 @@ export const CopyIcon = icon(
|
|||||||
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
|
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const HistoryIcon = icon(
|
||||||
|
<>
|
||||||
|
<path d="M12 8v4l2 2" />
|
||||||
|
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DownloadIcon = icon(
|
||||||
|
<>
|
||||||
|
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
||||||
|
<path d="M7 11l5 5 5-5M12 4v12" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
@@ -1005,3 +1005,125 @@ li.active > .row {
|
|||||||
background: var(--ex-primary);
|
background: var(--ex-primary);
|
||||||
color: var(--ex-surface-lowest);
|
color: var(--ex-surface-lowest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Historique des versions ----------------------------------------------- */
|
||||||
|
|
||||||
|
.modal.history {
|
||||||
|
width: min(920px, 100%);
|
||||||
|
height: min(640px, 92dvh);
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.history-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.history-head h3 {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.history-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.versions-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--ex-radius-lg);
|
||||||
|
background: var(--ex-surface-lowest);
|
||||||
|
}
|
||||||
|
.versions-list button {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
.versions-list button.on {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--ex-on-primary-container);
|
||||||
|
}
|
||||||
|
.versions-list small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.versions-list .empty {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.history-preview {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.history-preview .segmented {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.history-preview .segmented button {
|
||||||
|
height: calc(var(--ex-btn-lg) - 6px);
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.history-preview .segmented button.on {
|
||||||
|
background: var(--ex-primary-container);
|
||||||
|
color: var(--ex-on-primary-container);
|
||||||
|
}
|
||||||
|
.svg-box {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--ex-radius-lg);
|
||||||
|
background: var(--ex-surface-lowest);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.svg-box svg {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 14px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.legend i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.modal-actions a.link {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal.history {
|
||||||
|
height: 92dvh;
|
||||||
|
}
|
||||||
|
.history-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(90px, 30%) 1fr;
|
||||||
|
}
|
||||||
|
.history-head button span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
// POST /api/copy {"from":"x","to":"y"} duplication d'un schéma
|
// POST /api/copy {"from":"x","to":"y"} duplication d'un schéma
|
||||||
// DELETE /api/entry?path=x[&recursive=true] mise à la corbeille
|
// DELETE /api/entry?path=x[&recursive=true] mise à la corbeille
|
||||||
// GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma
|
// GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma
|
||||||
|
// GET /api/history?path=x versions d'un schéma
|
||||||
|
// GET /api/history/version?path=x&id=v contenu d'une version
|
||||||
|
// POST /api/history/snapshot {"path"} version du contenu courant
|
||||||
|
// POST /api/history/restore {"path","id"} restauration (ETag en en-tête)
|
||||||
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
||||||
// GET /api/settings / PUT {"title","channel"} paramètres (titre, logo, canal)
|
// GET /api/settings / PUT {"title","channel"} paramètres (titre, logo, canal)
|
||||||
// GET /api/logo / PUT (image/png) / DELETE logo 64x64
|
// GET /api/logo / PUT (image/png) / DELETE logo 64x64
|
||||||
@@ -47,6 +51,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("DELETE /api/entry", h.delete)
|
mux.HandleFunc("DELETE /api/entry", h.delete)
|
||||||
mux.HandleFunc("GET /api/thumb", h.readThumb)
|
mux.HandleFunc("GET /api/thumb", h.readThumb)
|
||||||
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
||||||
|
mux.HandleFunc("GET /api/history", h.history)
|
||||||
|
mux.HandleFunc("GET /api/history/version", h.readVersion)
|
||||||
|
mux.HandleFunc("POST /api/history/snapshot", h.snapshot)
|
||||||
|
mux.HandleFunc("POST /api/history/restore", h.restore)
|
||||||
mux.HandleFunc("GET /api/library", h.readLibrary)
|
mux.HandleFunc("GET /api/library", h.readLibrary)
|
||||||
mux.HandleFunc("PUT /api/library", h.writeLibrary)
|
mux.HandleFunc("PUT /api/library", h.writeLibrary)
|
||||||
mux.HandleFunc("GET /api/settings", h.readSettings)
|
mux.HandleFunc("GET /api/settings", h.readSettings)
|
||||||
@@ -267,6 +275,61 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) history(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vs, err := h.Store.Versions(r.URL.Query().Get("path"))
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
writeJSON(w, http.StatusOK, vs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
data, err := h.Store.ReadVersion(q.Get("path"), q.Get("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Une version ne change jamais.
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=86400, immutable")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) snapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req pathReq
|
||||||
|
if !h.decode(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := h.Store.Snapshot(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type restoreReq struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) restore(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req restoreReq
|
||||||
|
if !h.decode(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, etag, err := h.Store.Restore(req.Path, req.ID)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
writeJSON(w, http.StatusOK, entry)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) readLibrary(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) readLibrary(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := h.Store.ReadLibrary()
|
data, err := h.Store.ReadLibrary()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Historique des versions : avant qu'un schéma soit écrasé, son contenu
|
||||||
|
// précédent est copié dans .history/ en miroir de l'arborescence
|
||||||
|
// (a/b.excalidraw -> .history/a/b.excalidraw/<date>.excalidraw).
|
||||||
|
//
|
||||||
|
// Avec l'enregistrement automatique (toutes les 1,5 s pendant l'édition), une
|
||||||
|
// copie par écriture serait inutilisable : une version n'est prise que si la
|
||||||
|
// précédente date d'au moins HistoryPolicy.Interval. Restaurer une version ou
|
||||||
|
// créer une version à la demande copie toujours le contenu courant.
|
||||||
|
|
||||||
|
const historyDir = ".history"
|
||||||
|
|
||||||
|
// HistoryPolicy règle la prise et la rétention des versions.
|
||||||
|
type HistoryPolicy struct {
|
||||||
|
Keep int // versions conservées par schéma (0 : historique désactivé)
|
||||||
|
Interval time.Duration // écart minimal entre deux versions automatiques
|
||||||
|
MaxAge time.Duration // âge maximal d'une version (0 : illimité)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultHistory : 50 versions par schéma, au plus une toutes les 10 minutes.
|
||||||
|
var DefaultHistory = HistoryPolicy{Keep: 50, Interval: 10 * time.Minute}
|
||||||
|
|
||||||
|
// Version décrit une version enregistrée d'un schéma. Time est la date à
|
||||||
|
// laquelle ce contenu avait été enregistré.
|
||||||
|
type Version struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionLayout = "20060102T150405.000000000Z"
|
||||||
|
|
||||||
|
var versionID = regexp.MustCompile(`^\d{8}T\d{6}\.\d{9}Z$`)
|
||||||
|
|
||||||
|
func (s *Store) historyAbs(c string) string {
|
||||||
|
return filepath.Join(s.root, historyDir, filepath.FromSlash(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
// versions liste les versions d'un schéma, la plus récente en premier.
|
||||||
|
// taken renvoie en plus la date de prise de la plus récente (date du fichier).
|
||||||
|
func (s *Store) versions(c string) ([]Version, time.Time, error) {
|
||||||
|
des, err := os.ReadDir(s.historyAbs(c))
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return []Version{}, time.Time{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
out := []Version{}
|
||||||
|
var taken time.Time
|
||||||
|
for _, de := range des {
|
||||||
|
id, ok := strings.CutSuffix(de.Name(), Ext)
|
||||||
|
if !ok || de.IsDir() || !versionID.MatchString(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(versionLayout, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := de.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Version{ID: id, Time: t, Size: fi.Size()})
|
||||||
|
if fi.ModTime().After(taken) {
|
||||||
|
taken = fi.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||||
|
return out, taken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot enregistre data (contenu d'un schéma daté de mod) comme version.
|
||||||
|
// Sans force, rien n'est pris si la dernière version a moins de Interval.
|
||||||
|
// Une version identique à la plus récente n'est jamais dupliquée.
|
||||||
|
func (s *Store) snapshot(c string, data []byte, mod time.Time, force bool) (Version, error) {
|
||||||
|
p := s.History
|
||||||
|
if p.Keep <= 0 {
|
||||||
|
return Version{}, nil
|
||||||
|
}
|
||||||
|
vs, taken, err := s.versions(c)
|
||||||
|
if err != nil {
|
||||||
|
return Version{}, err
|
||||||
|
}
|
||||||
|
if !force && len(vs) > 0 && time.Since(taken) < p.Interval {
|
||||||
|
return Version{}, nil
|
||||||
|
}
|
||||||
|
if len(vs) > 0 && vs[0].Size == int64(len(data)) {
|
||||||
|
if last, err := os.ReadFile(s.versionAbs(c, vs[0].ID)); err == nil && bytes.Equal(last, data) {
|
||||||
|
return vs[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v := Version{ID: mod.UTC().Format(versionLayout), Time: mod.UTC(), Size: int64(len(data))}
|
||||||
|
dir := s.historyAbs(c)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return Version{}, err
|
||||||
|
}
|
||||||
|
if err := atomicWrite(s.versionAbs(c, v.ID), data); err != nil {
|
||||||
|
return Version{}, err
|
||||||
|
}
|
||||||
|
s.pruneHistory(c)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) versionAbs(c, id string) string {
|
||||||
|
return filepath.Join(s.historyAbs(c), id+Ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneHistory applique la rétention (nombre et âge) aux versions d'un schéma.
|
||||||
|
func (s *Store) pruneHistory(c string) {
|
||||||
|
vs, _, err := s.versions(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, v := range vs {
|
||||||
|
if i >= s.History.Keep || (s.History.MaxAge > 0 && time.Since(v.Time) > s.History.MaxAge) {
|
||||||
|
_ = os.Remove(s.versionAbs(c, v.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSnapshot est appelé par Write avant d'écraser un schéma ; un échec est
|
||||||
|
// journalisé mais n'empêche pas l'enregistrement.
|
||||||
|
func (s *Store) autoSnapshot(c string, cur []byte, mod time.Time) {
|
||||||
|
if _, err := s.snapshot(c, cur, mod, false); err != nil {
|
||||||
|
slog.Warn("historique : version non enregistrée", "path", c, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) schema(rel string) (string, string, error) {
|
||||||
|
c, abs, err := s.resolve(rel)
|
||||||
|
if err == nil {
|
||||||
|
err = requireSchemaPath(c)
|
||||||
|
}
|
||||||
|
return c, abs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versions liste les versions d'un schéma existant, la plus récente en premier.
|
||||||
|
func (s *Store) Versions(rel string) ([]Version, error) {
|
||||||
|
c, abs, err := s.schema(rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(abs); err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
vs, _, err := s.versions(c)
|
||||||
|
return vs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadVersion renvoie le contenu d'une version.
|
||||||
|
func (s *Store) ReadVersion(rel, id string) ([]byte, error) {
|
||||||
|
c, _, err := s.schema(rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !versionID.MatchString(id) {
|
||||||
|
return nil, fmt.Errorf("%w : version %q", ErrInvalidPath, id)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(s.versionAbs(c, id))
|
||||||
|
return b, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot crée une version du contenu courant, quel que soit l'intervalle.
|
||||||
|
func (s *Store) Snapshot(rel string) (Version, error) {
|
||||||
|
c, abs, err := s.schema(rel)
|
||||||
|
if err != nil {
|
||||||
|
return Version{}, err
|
||||||
|
}
|
||||||
|
if s.History.Keep <= 0 {
|
||||||
|
return Version{}, fmt.Errorf("%w : historique désactivé (HISTORY_KEEP=0)", ErrInvalidPath)
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Version{}, mapErr(err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Version{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return s.snapshot(c, b, fi.ModTime(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore remplace le contenu d'un schéma par une de ses versions. Le contenu
|
||||||
|
// courant est d'abord enregistré comme version : une restauration s'annule.
|
||||||
|
func (s *Store) Restore(rel, id string) (Entry, string, error) {
|
||||||
|
data, err := s.ReadVersion(rel, id)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, "", err
|
||||||
|
}
|
||||||
|
c, abs, _ := s.schema(rel)
|
||||||
|
fi, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, "", mapErr(err)
|
||||||
|
}
|
||||||
|
cur, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, "", mapErr(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(cur, data) {
|
||||||
|
if _, err := s.snapshot(c, cur, fi.ModTime(), true); err != nil {
|
||||||
|
return Entry{}, "", err
|
||||||
|
}
|
||||||
|
if err := atomicWrite(abs, data); err != nil {
|
||||||
|
return Entry{}, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fi, err = os.Stat(abs); err != nil {
|
||||||
|
return Entry{}, "", err
|
||||||
|
}
|
||||||
|
return toEntry(c, fi), ETag(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les opérations suivantes accompagnent Move/Delete.
|
||||||
|
|
||||||
|
func (s *Store) moveHistory(from, to string) {
|
||||||
|
src, dst := s.historyAbs(from), s.historyAbs(to)
|
||||||
|
if _, err := os.Stat(src); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(dst)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
|
||||||
|
_ = os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trashHistory range l'historique d'un élément supprimé avec lui dans la
|
||||||
|
// corbeille (trash/.history/<nom>), pour pouvoir le récupérer à la main.
|
||||||
|
func (s *Store) trashHistory(c, trash string) {
|
||||||
|
src := s.historyAbs(c)
|
||||||
|
if _, err := os.Stat(src); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := filepath.Join(trash, historyDir, filepath.Base(src))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
|
||||||
|
_ = os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHistoryInterval(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
s.History = HistoryPolicy{Keep: 3, Interval: time.Hour}
|
||||||
|
p := "a/b.excalidraw"
|
||||||
|
if _, _, err := s.Write(p, []byte(`{"v":1}`), "", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if vs, _ := s.Versions(p); len(vs) != 0 {
|
||||||
|
t.Fatalf("création : aucune version attendue, %+v", vs)
|
||||||
|
}
|
||||||
|
s.Write(p, []byte(`{"v":2}`), "", false) // v1 conservée
|
||||||
|
s.Write(p, []byte(`{"v":3}`), "", false) // < Interval : rien
|
||||||
|
vs, err := s.Versions(p)
|
||||||
|
if err != nil || len(vs) != 1 {
|
||||||
|
t.Fatalf("Versions = %+v, %v", vs, err)
|
||||||
|
}
|
||||||
|
if b, _ := s.ReadVersion(p, vs[0].ID); string(b) != `{"v":1}` {
|
||||||
|
t.Fatalf("version = %s", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intervalle nul : une version par écriture, rétention à 3.
|
||||||
|
s.History.Interval = 0
|
||||||
|
for i := 4; i <= 8; i++ {
|
||||||
|
time.Sleep(2 * time.Millisecond) // dates de fichier distinctes
|
||||||
|
s.Write(p, []byte(`{"v":`+string(rune('0'+i))+`}`), "", false)
|
||||||
|
}
|
||||||
|
vs, _ = s.Versions(p)
|
||||||
|
if len(vs) != 3 {
|
||||||
|
t.Fatalf("rétention : %d versions", len(vs))
|
||||||
|
}
|
||||||
|
if b, _ := s.ReadVersion(p, vs[0].ID); string(b) != `{"v":7}` {
|
||||||
|
t.Fatalf("plus récente = %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryRestoreMoveDelete(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
s.History = HistoryPolicy{Keep: 10, Interval: time.Hour}
|
||||||
|
p := "b.excalidraw"
|
||||||
|
s.Write(p, []byte(`{"v":1}`), "", true)
|
||||||
|
v1, err := s.Snapshot(p)
|
||||||
|
if err != nil || v1.ID == "" {
|
||||||
|
t.Fatalf("Snapshot = %+v, %v", v1, err)
|
||||||
|
}
|
||||||
|
if again, _ := s.Snapshot(p); again.ID != v1.ID {
|
||||||
|
t.Fatalf("version identique dupliquée : %+v", again)
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
s.Write(p, []byte(`{"v":2}`), "", false) // < Interval : pas de version
|
||||||
|
|
||||||
|
_, etag, err := s.Restore(p, v1.ID)
|
||||||
|
if err != nil || etag != ETag([]byte(`{"v":1}`)) {
|
||||||
|
t.Fatalf("Restore : %s, %v", etag, err)
|
||||||
|
}
|
||||||
|
vs, _ := s.Versions(p)
|
||||||
|
if len(vs) != 2 {
|
||||||
|
t.Fatalf("la restauration doit conserver le contenu remplacé : %+v", vs)
|
||||||
|
}
|
||||||
|
if b, _ := s.ReadVersion(p, vs[0].ID); string(b) != `{"v":2}` {
|
||||||
|
t.Fatalf("contenu remplacé = %s", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"../x", "2026", ""} {
|
||||||
|
if _, err := s.ReadVersion(p, bad); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Errorf("ReadVersion(%q) : %v", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'historique suit les déplacements (fichier puis dossier parent).
|
||||||
|
s.Mkdir("d")
|
||||||
|
if _, err := s.Move(p, "d/c.excalidraw"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Move("d", "e")
|
||||||
|
if vs, _ := s.Versions("e/c.excalidraw"); len(vs) != 2 {
|
||||||
|
t.Fatalf("après déplacement : %+v", vs)
|
||||||
|
}
|
||||||
|
// Supprimé : l'historique part dans la corbeille avec le schéma.
|
||||||
|
if err := s.Delete("e/c.excalidraw", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(s.historyAbs("e/c.excalidraw")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("historique resté en place : %v", err)
|
||||||
|
}
|
||||||
|
m, _ := filepath.Glob(filepath.Join(s.root, trashDir, "*", historyDir, "c.excalidraw"))
|
||||||
|
if len(m) != 1 {
|
||||||
|
t.Fatalf("historique absent de la corbeille : %v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryDisabled(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
s.History = HistoryPolicy{}
|
||||||
|
s.Write("x.excalidraw", []byte(`{"v":1}`), "", true)
|
||||||
|
s.Write("x.excalidraw", []byte(`{"v":2}`), "", false)
|
||||||
|
if vs, _ := s.Versions("x.excalidraw"); len(vs) != 0 {
|
||||||
|
t.Fatalf("historique désactivé : %+v", vs)
|
||||||
|
}
|
||||||
|
if _, err := s.Snapshot("x.excalidraw"); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Fatalf("Snapshot désactivé : %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -52,6 +53,9 @@ type Entry struct {
|
|||||||
type Store struct {
|
type Store struct {
|
||||||
root string
|
root string
|
||||||
|
|
||||||
|
// History règle l'historique des versions (DefaultHistory par défaut).
|
||||||
|
History HistoryPolicy
|
||||||
|
|
||||||
settingsMu sync.Mutex // sérialise les lectures-modifications de .settings.json
|
settingsMu sync.Mutex // sérialise les lectures-modifications de .settings.json
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +73,7 @@ func New(root string) (*Store, error) {
|
|||||||
if abs, err = filepath.EvalSymlinks(abs); err != nil {
|
if abs, err = filepath.EvalSymlinks(abs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Store{root: abs}, nil
|
return &Store{root: abs, History: DefaultHistory}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean normalise un chemin relatif : "/a/../b/" -> "b", "" -> "".
|
// Clean normalise un chemin relatif : "/a/../b/" -> "b", "" -> "".
|
||||||
@@ -269,6 +273,10 @@ func (s *Store) Write(rel string, data []byte, ifMatch string, create bool) (Ent
|
|||||||
return Entry{}, "", ErrConflict
|
return Entry{}, "", ErrConflict
|
||||||
case err != nil && !errors.Is(err, fs.ErrNotExist):
|
case err != nil && !errors.Is(err, fs.ErrNotExist):
|
||||||
return Entry{}, "", err
|
return Entry{}, "", err
|
||||||
|
case err == nil && !bytes.Equal(cur, data):
|
||||||
|
if fi, err := os.Stat(abs); err == nil {
|
||||||
|
s.autoSnapshot(c, cur, fi.ModTime())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
return Entry{}, "", err
|
return Entry{}, "", err
|
||||||
@@ -363,6 +371,7 @@ func (s *Store) Move(from, to string) (Entry, error) {
|
|||||||
return Entry{}, mapErr(err)
|
return Entry{}, mapErr(err)
|
||||||
}
|
}
|
||||||
s.moveThumb(cf, ct, fi.IsDir())
|
s.moveThumb(cf, ct, fi.IsDir())
|
||||||
|
s.moveHistory(cf, ct)
|
||||||
fi, err = os.Stat(at)
|
fi, err = os.Stat(at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Entry{}, err
|
return Entry{}, err
|
||||||
@@ -463,6 +472,7 @@ func (s *Store) Delete(rel string, recursive bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.removeThumb(c, fi.IsDir())
|
s.removeThumb(c, fi.IsDir())
|
||||||
|
s.trashHistory(c, trash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user