Phase 8: version history + export (trust foundation)

Version history: new document_versions table (migration 0003) holding
full-body snapshots that cascade with the doc. Throttled auto-snapshots
on save (>=3min apart, max 40/doc, pruned), explicit manual restore
points, and a pre_restore safety copy taken before each restore so
restoring is itself undoable. Endpoints under /api/docs/:id/versions,
all owner-scoped. Empties and bare renames never snapshot.

Export: pure-Go Tiptap-JSON -> Markdown / HTML / plain-text / docx
(no cgo/pandoc, single-binary intact), CJK-safe with RFC 5987
filenames. docx is a hand-built OOXML zip. PDF is handled client-side
via the browser print dialog + an @media print stylesheet so CJK
renders with the reader's own fonts.

Frontend: ExportMenu (downloads + Print/PDF) and HistoryPanel
(snapshot list, preview, restore) wired into the title row; bilingual
zh-first to match chrome. Restore remounts the editor via editorEpoch.

Stop saving empty docs: blank Untitled drafts now reuse-on-create and
self-discard when navigated away from (refs avoid stale closures).

Tests: versions_test.go, export_test.go (incl. valid-zip docx).
go build/vet/test, tsc, vite build all clean; live end-to-end smoke
verified snapshot/throttle/restore/export.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 23:44:15 -07:00
parent cf7720ea77
commit 8e1111d768
13 changed files with 1703 additions and 14 deletions

View File

@@ -7,6 +7,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"github.com/go-chi/chi/v5"
@@ -33,6 +34,8 @@ func (h *Handler) Routes() chi.Router {
r.Get("/{id}", h.get)
r.Put("/{id}", h.update)
r.Delete("/{id}", h.delete)
h.versionRoutes(r)
h.exportRoutes(r)
return r
}
@@ -155,6 +158,16 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
// Capture a throttled history snapshot when a real body save came through
// (not a bare rename) and the document has content. Best-effort: a failed
// snapshot must never fail the save itself.
if req.Content != nil && doc.ContentText != "" {
if err := h.maybeAutoSnapshot(doc); err != nil {
log.Printf("docs: auto-snapshot for %s failed: %v", id, err)
}
}
writeJSON(w, http.StatusOK, doc)
}
@@ -208,3 +221,4 @@ func serverError(w http.ResponseWriter, err error) {
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }
func notFoundMsg(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusNotFound, msg) }