Backend: - Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/ ServerError); drop the triple-duplicated helpers in docs, suggestions, vocab. ServerError now logs the real error and returns a generic 500 so raw DB/internal errors never reach the client. - vocab capture: validate doc_id ownership (blank -> none, unknown -> 400 instead of a leaked FK 500); rune-safe clamp word/gloss/definition/ phonetic/example. - vocab review(): wrap the read-modify-write in a transaction (TOCTOU). - /api request-size cap via MaxBytesReader middleware (2 MiB), exempting /api/images (own 10 MiB limit). Frontend: - StatusBar: drive the checking/voicing/collocating indicators from one array; llmDown uses !anyBusy. - Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore) on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label. - speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount. Tests: add doc_id-validation and field-clamp coverage; full suite green. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
247 lines
7.2 KiB
Go
247 lines
7.2 KiB
Go
package docs
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
|
)
|
|
|
|
// 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 {
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
v, err := h.insertVersion(doc, db.VersionKindManual)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
current, err := h.fetch(docID)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
doc, err := h.fetch(docID)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.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
|
|
}
|