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
78 lines
2.3 KiB
Go
78 lines
2.3 KiB
Go
package suggestions
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
|
)
|
|
|
|
// rewriteRequest is the body the selection bubble posts: the selected passage
|
|
// and the target style ("natural", "academic", …). The text is the client's
|
|
// live selection — unlike a checkpoint, there is nothing to anchor server-side,
|
|
// so the rewrite is stateless and never persisted (the editor applies it
|
|
// directly, and the version history captures the resulting document change).
|
|
type rewriteRequest struct {
|
|
Text string `json:"text"`
|
|
Style string `json:"style"`
|
|
}
|
|
|
|
type rewriteResponse struct {
|
|
Rewrite string `json:"rewrite"`
|
|
}
|
|
|
|
// rewrite runs a one-shot tone-rewrite over a selected passage and returns the
|
|
// rewritten text for the editor to preview. The document id scopes the request
|
|
// to the owner (and reserves room to feed document context to the model later),
|
|
// even though the passage itself rides in the body.
|
|
func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
|
|
docID := chi.URLParam(r, "id")
|
|
|
|
var body rewriteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
text := strings.TrimSpace(body.Text)
|
|
if text == "" {
|
|
httputil.ErrorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
|
return
|
|
}
|
|
if len([]rune(text)) > llm.RewriteMaxRunes {
|
|
httputil.ErrorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
|
|
return
|
|
}
|
|
|
|
// Scope to the owner so a stray id can't drive rewrites against another
|
|
// user's document (and 404 cleanly when it doesn't exist).
|
|
var exists int
|
|
err := h.DB.QueryRow(
|
|
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
|
docID, db.LocalUserID,
|
|
).Scan(&exists)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
|
|
if err != nil {
|
|
httputil.ErrorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
httputil.WriteJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
|
|
}
|