From 9f9ec3b3a4a86c27b501e4d283cc72b1792f630a Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 18:03:08 +0200 Subject: [PATCH] =?UTF-8?q?Historique=20des=20versions=20:=20snapshots=20h?= =?UTF-8?q?orodat=C3=A9s,=20diff=C3=A9rences,=20restauration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Avant d'écraser un schéma, le contenu précédent est copié dans DATA_DIR/.history//.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) Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm --- .env.example | 3 + README.md | 28 ++- cmd/server/main.go | 26 ++- docker-compose.yml | 5 + frontend/src/App.tsx | 48 ++++- frontend/src/Explorer.tsx | 5 +- frontend/src/HistoryDialog.tsx | 298 +++++++++++++++++++++++++++++++ frontend/src/api.ts | 23 +++ frontend/src/icons.tsx | 14 ++ frontend/src/styles.css | 122 +++++++++++++ internal/api/api.go | 63 +++++++ internal/storage/history.go | 253 ++++++++++++++++++++++++++ internal/storage/history_test.go | 112 ++++++++++++ internal/storage/storage.go | 12 +- 14 files changed, 1005 insertions(+), 7 deletions(-) create mode 100644 frontend/src/HistoryDialog.tsx create mode 100644 internal/storage/history.go create mode 100644 internal/storage/history_test.go diff --git a/.env.example b/.env.example index efa5744..abcc81c 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 05d3217..475ccba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # excalidraw-go -Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), recherche, 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/.excalidraw ← versions └─ .trash//... ← é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//.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//.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=` | | 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":""}` | `Entry` + `ETag` | | GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | | | GET | `/api/settings` | | `{"title":"...","logo":"","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`. diff --git a/cmd/server/main.go b/cmd/server/main.go index d3322cf..4bdb994 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 { diff --git a/docker-compose.yml b/docker-compose.yml index 8f3c5d4..23ac194 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6324758..0c0f9e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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); + const [historyFor, setHistoryFor] = useState(null); const excalidraw = useRef(null); const pathRef = useRef(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() { Enregistrer + {!e.isDir && } + {!e.isDir && } {!e.isDir && ( Télécharger diff --git a/frontend/src/HistoryDialog.tsx b/frontend/src/HistoryDialog.tsx new file mode 100644 index 0000000..86ecdf0 --- /dev/null +++ b/frontend/src/HistoryDialog.tsx @@ -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; + /** 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(); + 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(null); + const [selected, setSelected] = useState(null); + const [view, setView] = useState("diff"); + const [scenes, setScenes] = useState>({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const preview = useRef(null); + const name = stripExt(basename(path)); + + const run = async (op: () => Promise) => { + 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 ( +
+
e.stopPropagation()} + > +
+

Historique · {name}

+ +
+ +
+
    + {versions?.map((v) => ( +
  • + +
  • + ))} + {versions?.length === 0 && ( +
  • + 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. +
  • + )} +
+ +
+
+ {( + [ + ["diff", "Différences"], + ["version", "Cette version"], + ["current", "Actuelle"], + ] as [View, string][] + ).map(([id, label]) => ( + + ))} +
+
+ {view === "diff" && comparison && ( +

+ Depuis cette version : + {(Object.keys(DIFF) as Change[]).map((k) => ( + + + {comparison.count[k]} {DIFF[k].label} + {comparison.count[k] > 1 ? "s" : ""} + + ))} +

+ )} +
+
+ + {error &&
{error}
} + +
+ {current && ( + + + + )} + + + + +
+
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7c1f76d..883a804 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 => + (await request(`/api/history?path=${q(path)}`)).json(), + + readVersion: async (path: string, id: string): Promise => + (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 => + (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 => + (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 => (await request("/api/library")).json(), writeLibrary: async (body: string): Promise => { diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx index df79f7f..38809b0 100644 --- a/frontend/src/icons.tsx +++ b/frontend/src/icons.tsx @@ -149,3 +149,17 @@ export const CopyIcon = icon( , ); + +export const HistoryIcon = icon( + <> + + + , +); + +export const DownloadIcon = icon( + <> + + + , +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 24b67a8..3e193e5 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 23f4db6..4b0f11d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 { diff --git a/internal/storage/history.go b/internal/storage/history.go new file mode 100644 index 0000000..b433300 --- /dev/null +++ b/internal/storage/history.go @@ -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/.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/), 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) + } +} diff --git a/internal/storage/history_test.go b/internal/storage/history_test.go new file mode 100644 index 0000000..fe2f9c1 --- /dev/null +++ b/internal/storage/history_test.go @@ -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) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index b28ee59..0ce96ab 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 }