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
246 lines
7.1 KiB
Go
246 lines
7.1 KiB
Go
package docs
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
)
|
|
|
|
// Version-history tuning.
|
|
const (
|
|
// autoSnapshotInterval is the minimum gap between background ('auto')
|
|
// snapshots. Auto-save fires every ~1.5s; without a floor we'd store a
|
|
// version per keystroke-burst. A few minutes keeps history useful for
|
|
// recovery without unbounded growth.
|
|
autoSnapshotInterval = 3 * time.Minute
|
|
|
|
// maxAutoVersions caps how many 'auto' snapshots we retain per document.
|
|
// 'manual' and 'pre_restore' versions are never pruned — they're explicit
|
|
// restore points the writer (or a restore) deliberately created.
|
|
maxAutoVersions = 40
|
|
)
|
|
|
|
// versionRoutes registers the history endpoints on the docs sub-router. Paths
|
|
// resolve to /api/docs/{id}/versions...
|
|
func (h *Handler) versionRoutes(r chi.Router) {
|
|
r.Get("/{id}/versions", h.listVersions)
|
|
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
|
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
|
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
|
|
}
|
|
|
|
// listVersions returns the document's snapshots, newest first, without the heavy
|
|
// content fields (those load on preview/restore).
|
|
func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
|
docID := chi.URLParam(r, "id")
|
|
|
|
// Scope through the documents table so a snapshot is only visible to the
|
|
// owner of its parent document.
|
|
rows, err := h.DB.Query(
|
|
`SELECT v.id, v.doc_id, v.title, v.word_count, v.kind, v.created_at
|
|
FROM document_versions v
|
|
JOIN documents d ON d.id = v.doc_id
|
|
WHERE v.doc_id = ? AND d.user_id = ?
|
|
ORDER BY v.created_at DESC`,
|
|
docID, db.LocalUserID,
|
|
)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []db.DocumentVersion{} // non-nil so an empty history serializes as []
|
|
for rows.Next() {
|
|
var v db.DocumentVersion
|
|
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// getVersion returns one snapshot in full (including content) for preview.
|
|
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
|
|
v, err := h.fetchVersion(chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
notFoundMsg(w, "version not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
// createVersion takes an explicit, user-requested ('manual') restore point from
|
|
// the document's current saved state.
|
|
func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
|
|
docID := chi.URLParam(r, "id")
|
|
|
|
doc, err := h.fetch(docID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
v, err := h.insertVersion(doc, db.VersionKindManual)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, v)
|
|
}
|
|
|
|
// restoreVersion copies a snapshot back onto the live document. Before
|
|
// overwriting, it captures the current state as a 'pre_restore' version so the
|
|
// restore is itself undoable. Returns the restored document.
|
|
func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
|
docID := chi.URLParam(r, "id")
|
|
vid := chi.URLParam(r, "vid")
|
|
|
|
v, err := h.fetchVersion(docID, vid)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
notFoundMsg(w, "version not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
current, err := h.fetch(docID)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
res, err := h.DB.Exec(
|
|
`UPDATE documents
|
|
SET title = ?, content = ?, content_text = ?, word_count = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ? AND user_id = ?`,
|
|
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
|
|
)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
doc, err := h.fetch(docID)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, doc)
|
|
}
|
|
|
|
// maybeAutoSnapshot records a throttled background snapshot of the just-saved
|
|
// document. It no-ops when the newest snapshot is younger than
|
|
// autoSnapshotInterval, or when the body is unchanged since the last snapshot,
|
|
// so an idle or rename-only save costs nothing. Best-effort: callers log but do
|
|
// not fail the save if this errors. Prunes old 'auto' versions on success.
|
|
func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
|
|
var (
|
|
lastAt time.Time
|
|
lastText string
|
|
hasPrev bool
|
|
)
|
|
err := h.DB.QueryRow(
|
|
`SELECT created_at, content_text FROM document_versions
|
|
WHERE doc_id = ? ORDER BY created_at DESC LIMIT 1`,
|
|
doc.ID,
|
|
).Scan(&lastAt, &lastText)
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
hasPrev = false
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
hasPrev = true
|
|
}
|
|
|
|
if hasPrev {
|
|
if lastText == doc.ContentText {
|
|
return nil // nothing meaningful changed
|
|
}
|
|
if time.Since(lastAt) < autoSnapshotInterval {
|
|
return nil // too soon; let edits accumulate
|
|
}
|
|
}
|
|
|
|
if _, err := h.insertVersion(doc, db.VersionKindAuto); err != nil {
|
|
return err
|
|
}
|
|
return h.pruneAutoVersions(doc.ID)
|
|
}
|
|
|
|
// insertVersion writes a snapshot row of the given kind and returns it (without
|
|
// the heavy content fields, matching the list shape).
|
|
func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
|
|
var v db.DocumentVersion
|
|
err := h.DB.QueryRow(
|
|
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
RETURNING id, doc_id, title, word_count, kind, created_at`,
|
|
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind,
|
|
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt)
|
|
return v, err
|
|
}
|
|
|
|
// pruneAutoVersions trims a document's 'auto' snapshots to the newest
|
|
// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
|
|
func (h *Handler) pruneAutoVersions(docID string) error {
|
|
_, err := h.DB.Exec(
|
|
`DELETE FROM document_versions
|
|
WHERE doc_id = ? AND kind = 'auto'
|
|
AND id NOT IN (
|
|
SELECT id FROM document_versions
|
|
WHERE doc_id = ? AND kind = 'auto'
|
|
ORDER BY created_at DESC LIMIT ?
|
|
)`,
|
|
docID, docID, maxAutoVersions,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// fetchVersion loads one full snapshot, scoped to its owner via the parent doc.
|
|
func (h *Handler) fetchVersion(docID, vid string) (db.DocumentVersion, error) {
|
|
var v db.DocumentVersion
|
|
err := h.DB.QueryRow(
|
|
`SELECT v.id, v.doc_id, v.title, v.content, v.content_text, v.word_count, v.kind, v.created_at
|
|
FROM document_versions v
|
|
JOIN documents d ON d.id = v.doc_id
|
|
WHERE v.id = ? AND v.doc_id = ? AND d.user_id = ?`,
|
|
vid, docID, db.LocalUserID,
|
|
).Scan(
|
|
&v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText,
|
|
&v.WordCount, &v.Kind, &v.CreatedAt,
|
|
)
|
|
return v, err
|
|
}
|