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
|
||||
UPDATE_CHECK=true
|
||||
UPDATE_CHECK_INTERVAL=12h
|
||||
HISTORY_KEEP=50
|
||||
HISTORY_INTERVAL=10m
|
||||
HISTORY_MAX_AGE=0
|
||||
EXCALIDRAW_VERSION=
|
||||
@@ -1,6 +1,6 @@
|
||||
# excalidraw-go
|
||||
|
||||
Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), recherche, 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.
|
||||
- **Frontend** : React 19 + composant officiel [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw) 0.18.1 (licence MIT), build Vite **embarqué dans le binaire Go** (`embed`).
|
||||
@@ -20,6 +20,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs
|
||||
├─ .library.excalidrawlib ← bibliothèque partagée
|
||||
├─ .settings.json, .logo.png ← titre et logo de la barre
|
||||
├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables)
|
||||
├─ .history/projets/réseau/sdn.excalidraw/<date>.excalidraw ← versions
|
||||
└─ .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_INTERVAL` | `12h` | Intervalle entre deux vérifications (durée Go, minimum `1m`) |
|
||||
| `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` |
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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` |
|
||||
| POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` |
|
||||
| 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 | `/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 |
|
||||
@@ -145,7 +167,7 @@ Arborescence :
|
||||
|
||||
```
|
||||
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
|
||||
web/embed.go embarque web/dist (produit par le build Vite)
|
||||
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/Thumb.tsx vignette chargée à l'affichage, générée si absente
|
||||
frontend/src/thumbs.ts rendu PNG des miniatures et file de génération
|
||||
frontend/src/HistoryDialog.tsx historique : aperçu, différences, restauration
|
||||
```
|
||||
|
||||
## Sécurité
|
||||
@@ -165,6 +188,5 @@ frontend/src/thumbs.ts rendu PNG des miniatures et file de génération
|
||||
## 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.
|
||||
- **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.
|
||||
- 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
defer stop()
|
||||
|
||||
@@ -91,7 +98,8 @@ func main() {
|
||||
}
|
||||
|
||||
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) {
|
||||
slog.Error("serveur", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -104,6 +112,22 @@ func main() {
|
||||
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 {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,6 +21,11 @@ services:
|
||||
# Icône « mise à jour d'Excalidraw disponible » (interroge registry.npmjs.org).
|
||||
UPDATE_CHECK: ${UPDATE_CHECK:-true}
|
||||
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:
|
||||
# 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`.
|
||||
|
||||
+47
-1
@@ -16,7 +16,8 @@ import type {
|
||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
||||
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 { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
|
||||
import { pendingVersion, useUpdateStatus } from "./update";
|
||||
@@ -91,6 +92,7 @@ export default function App() {
|
||||
const updateStatus = useUpdateStatus();
|
||||
const pending = pendingVersion(updateStatus, settings.channel);
|
||||
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
||||
const [historyFor, setHistoryFor] = useState<string | null>(null);
|
||||
|
||||
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
||||
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
|
||||
@@ -335,6 +337,28 @@ export default function App() {
|
||||
[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 -----------------------------------------------------------------
|
||||
|
||||
return (
|
||||
@@ -378,6 +402,14 @@ export default function App() {
|
||||
<SaveIcon />
|
||||
<span className="save-label">Enregistrer</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Historique des versions"
|
||||
title="Historique des versions"
|
||||
onClick={() => showHistory(scene.path)}
|
||||
>
|
||||
<HistoryIcon />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Fermer le schéma"
|
||||
@@ -425,6 +457,7 @@ export default function App() {
|
||||
onOpen={(p) => openFile(p)}
|
||||
onMoved={onMoved}
|
||||
onDeleted={onDeleted}
|
||||
onHistory={showHistory}
|
||||
/>
|
||||
</aside>
|
||||
<Splitter
|
||||
@@ -461,6 +494,19 @@ export default function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{historyFor && (
|
||||
<HistoryDialog
|
||||
path={historyFor}
|
||||
theme={theme}
|
||||
onRestore={(id) => restoreVersion(historyFor, id)}
|
||||
onCopied={(p) => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
openFile(p);
|
||||
}}
|
||||
onClose={() => setHistoryFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{customizing && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
|
||||
@@ -52,6 +52,8 @@ interface Props {
|
||||
onMoved: (from: string, to: string) => void;
|
||||
/** Un fichier ou dossier a été supprimé. */
|
||||
onDeleted: (path: string) => void;
|
||||
/** Affiche l'historique des versions d'un schéma. */
|
||||
onHistory: (path: string) => void;
|
||||
}
|
||||
|
||||
type View = "list" | "grid";
|
||||
@@ -78,7 +80,7 @@ const initialView = (): View => {
|
||||
const canMoveInto = (path: string, targetDir: string) =>
|
||||
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 [entries, setEntries] = useState<Entry[]>([]);
|
||||
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={() => setMoving([e.path])}>Déplacer…</button>
|
||||
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
||||
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
|
||||
{!e.isDir && (
|
||||
<a href={api.downloadUrl(e.path)} download={e.name}>
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
@@ -98,6 +105,22 @@ export const api = {
|
||||
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(),
|
||||
|
||||
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" />
|
||||
</>,
|
||||
);
|
||||
|
||||
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);
|
||||
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
|
||||
// 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/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/settings / PUT {"title","channel"} paramètres (titre, logo, canal)
|
||||
// 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("GET /api/thumb", h.readThumb)
|
||||
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("PUT /api/library", h.writeLibrary)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
data, err := h.Store.ReadLibrary()
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -52,6 +53,9 @@ type Entry struct {
|
||||
type Store struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -69,7 +73,7 @@ func New(root string) (*Store, error) {
|
||||
if abs, err = filepath.EvalSymlinks(abs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{root: abs}, nil
|
||||
return &Store{root: abs, History: DefaultHistory}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
case err != nil && !errors.Is(err, fs.ErrNotExist):
|
||||
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 {
|
||||
return Entry{}, "", err
|
||||
@@ -363,6 +371,7 @@ func (s *Store) Move(from, to string) (Entry, error) {
|
||||
return Entry{}, mapErr(err)
|
||||
}
|
||||
s.moveThumb(cf, ct, fi.IsDir())
|
||||
s.moveHistory(cf, ct)
|
||||
fi, err = os.Stat(at)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
@@ -463,6 +472,7 @@ func (s *Store) Delete(rel string, recursive bool) error {
|
||||
return err
|
||||
}
|
||||
s.removeThumb(c, fi.IsDir())
|
||||
s.trashHistory(c, trash)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user