Code-review follow-ups: httputil, validation caps, a11y
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
This commit is contained in:
@@ -48,6 +48,11 @@ func main() {
|
|||||||
log.Printf("frontend build version %s", version)
|
log.Printf("frontend build version %s", version)
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
|
// Cap request bodies so a runaway or hostile client can't stream an
|
||||||
|
// unbounded payload into a JSON decoder. Image uploads carry their own
|
||||||
|
// (larger) limit inside the images handler, so they're exempt here.
|
||||||
|
api.Use(limitBody(maxAPIBodyBytes, "/api/images"))
|
||||||
|
|
||||||
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
@@ -114,6 +119,32 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxAPIBodyBytes caps a JSON API request body at 2 MiB. That's far above any
|
||||||
|
// real document save (the body is text plus lightweight marks; images upload
|
||||||
|
// separately by reference) while still bounding abuse. Exceeding it makes the
|
||||||
|
// handler's json.Decode fail, which surfaces as a 400.
|
||||||
|
const maxAPIBodyBytes = 2 << 20
|
||||||
|
|
||||||
|
// limitBody wraps each request body in an http.MaxBytesReader so handlers can't
|
||||||
|
// be made to read an unbounded payload. Paths under any of exemptPrefixes are
|
||||||
|
// left alone (e.g. image uploads, which set their own, larger limit).
|
||||||
|
func limitBody(max int64, exemptPrefixes ...string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, p := range exemptPrefixes {
|
||||||
|
if strings.HasPrefix(r.URL.Path, p) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, max)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// buildVersion derives a short, stable identifier for the currently embedded
|
// buildVersion derives a short, stable identifier for the currently embedded
|
||||||
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
|
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
|
||||||
// into that file each build, so the digest is a reliable "did the deploy
|
// into that file each build, so the digest is a reliable "did the deploy
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// exportRoutes registers the download endpoints: one document
|
// exportRoutes registers the download endpoints: one document
|
||||||
@@ -48,7 +49,7 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.LocalUserID,
|
db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -62,12 +63,12 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := format.render(doc)
|
body, err := format.render(doc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
base := sanitizeFilename(doc.Title)
|
base := sanitizeFilename(doc.Title)
|
||||||
@@ -82,20 +83,20 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
seen[key]++
|
seen[key]++
|
||||||
f, err := zw.Create(name)
|
f, err := zw.Create(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := f.Write(body); err != nil {
|
if _, err := f.Write(body); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := zw.Close(); err != nil {
|
if err := zw.Close(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,13 +149,13 @@ func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := format.render(doc)
|
body, err := format.render(doc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds the dependencies shared by every document route.
|
// Handler holds the dependencies shared by every document route.
|
||||||
@@ -62,7 +63,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.LocalUserID,
|
db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -72,20 +73,20 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d docSummary
|
var d docSummary
|
||||||
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out = append(out, d)
|
out = append(out, d)
|
||||||
ids = append(ids, d.ID)
|
ids = append(ids, d.ID)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
byDoc, err := h.tagsByDoc(ids)
|
byDoc, err := h.tagsByDoc(ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i := range out {
|
for i := range out {
|
||||||
@@ -94,7 +95,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
out[i].Tags = []db.Tag{}
|
out[i].Tags = []db.Tag{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// create inserts a fresh blank document and returns it in full.
|
// create inserts a fresh blank document and returns it in full.
|
||||||
@@ -109,10 +110,10 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
|||||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, doc)
|
httputil.WriteJSON(w, http.StatusCreated, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get returns a single full document by id.
|
// get returns a single full document by id.
|
||||||
@@ -123,10 +124,10 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, doc)
|
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRequest is the auto-save payload. Every field is optional (a pointer) so
|
// updateRequest is the auto-save payload. Every field is optional (a pointer) so
|
||||||
@@ -163,7 +164,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
@@ -173,7 +174,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
doc, err := h.fetch(id)
|
doc, err := h.fetch(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +187,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, doc)
|
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete removes a document (suggestions cascade via the FK).
|
// delete removes a document (suggestions cascade via the FK).
|
||||||
@@ -196,7 +197,7 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
chi.URLParam(r, "id"), db.LocalUserID,
|
chi.URLParam(r, "id"), db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
@@ -222,21 +223,12 @@ func (h *Handler) fetch(id string) (db.Document, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- small response helpers -------------------------------------------------
|
// --- small response helpers -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The generic JSON/error helpers live in internal/httputil; these are the
|
||||||
|
// document-specific shorthands that carry domain wording.
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func badRequest(w http.ResponseWriter, msg string) { httputil.BadRequest(w, msg) }
|
||||||
w.Header().Set("Content-Type", "application/json")
|
func notFound(w http.ResponseWriter) {
|
||||||
w.WriteHeader(status)
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
}
|
||||||
|
func notFoundMsg(w http.ResponseWriter, msg string) { httputil.ErrorJSON(w, http.StatusNotFound, msg) }
|
||||||
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func serverError(w http.ResponseWriter, err error) {
|
|
||||||
errorJSON(w, http.StatusInternalServerError, 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) }
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Search snippet shaping.
|
// Search snippet shaping.
|
||||||
@@ -57,7 +58,7 @@ func (h *Handler) SearchRoutes() chi.Router {
|
|||||||
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
if q == "" {
|
if q == "" {
|
||||||
writeJSON(w, http.StatusOK, []searchResult{})
|
httputil.WriteJSON(w, http.StatusOK, []searchResult{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,20 +83,20 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
|||||||
phrase, db.LocalUserID, maxSearchResults,
|
phrase, db.LocalUserID, maxSearchResults,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer sqlRows.Close()
|
defer sqlRows.Close()
|
||||||
for sqlRows.Next() {
|
for sqlRows.Next() {
|
||||||
var rw row
|
var rw row
|
||||||
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows = append(rows, rw)
|
rows = append(rows, rw)
|
||||||
}
|
}
|
||||||
if err := sqlRows.Err(); err != nil {
|
if err := sqlRows.Err(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -112,20 +113,20 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.LocalUserID, like, like, maxSearchResults,
|
db.LocalUserID, like, like, maxSearchResults,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer sqlRows.Close()
|
defer sqlRows.Close()
|
||||||
for sqlRows.Next() {
|
for sqlRows.Next() {
|
||||||
var rw row
|
var rw row
|
||||||
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows = append(rows, rw)
|
rows = append(rows, rw)
|
||||||
}
|
}
|
||||||
if err := sqlRows.Err(); err != nil {
|
if err := sqlRows.Err(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +146,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
byDoc, err := h.tagsByDoc(ids)
|
byDoc, err := h.tagsByDoc(ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i := range out {
|
for i := range out {
|
||||||
@@ -154,7 +155,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
|||||||
out[i].Tags = []db.Tag{}
|
out[i].Tags = []db.Tag{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapeLike escapes the LIKE metacharacters (% and _) and the escape character
|
// escapeLike escapes the LIKE metacharacters (% and _) and the escape character
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validTagColors is the palette a tag may use, mirrored from the design tokens.
|
// validTagColors is the palette a tag may use, mirrored from the design tokens.
|
||||||
@@ -57,7 +58,7 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.LocalUserID,
|
db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -66,16 +67,16 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t db.Tag
|
var t db.Tag
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
|
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out = append(out, t)
|
out = append(out, t)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
type tagRequest struct {
|
type tagRequest struct {
|
||||||
@@ -106,10 +107,10 @@ func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.LocalUserID, name, normalizeColor(req.Color),
|
db.LocalUserID, name, normalizeColor(req.Color),
|
||||||
).Scan(&t.ID, &t.Name, &t.Color)
|
).Scan(&t.ID, &t.Name, &t.Color)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, t)
|
httputil.WriteJSON(w, http.StatusCreated, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateTag renames and/or recolors a tag. Both fields optional via pointers so
|
// updateTag renames and/or recolors a tag. Both fields optional via pointers so
|
||||||
@@ -147,7 +148,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
|||||||
namePtr, colorPtr, id, db.LocalUserID,
|
namePtr, colorPtr, id, db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
@@ -160,10 +161,10 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
|||||||
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
|
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
|
||||||
id, db.LocalUserID,
|
id, db.LocalUserID,
|
||||||
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
|
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, t)
|
httputil.WriteJSON(w, http.StatusOK, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteTag removes a tag; its document assignments cascade away via the FK.
|
// deleteTag removes a tag; its document assignments cascade away via the FK.
|
||||||
@@ -173,7 +174,7 @@ func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
|||||||
chi.URLParam(r, "id"), db.LocalUserID,
|
chi.URLParam(r, "id"), db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
@@ -216,7 +217,7 @@ func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
|
|||||||
ON CONFLICT(doc_id, tag_id) DO NOTHING`,
|
ON CONFLICT(doc_id, tag_id) DO NOTHING`,
|
||||||
docID, req.TagID,
|
docID, req.TagID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -235,7 +236,7 @@ func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
|
|||||||
`DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`,
|
`DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`,
|
||||||
docID, tagID,
|
docID, tagID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version-history tuning.
|
// Version-history tuning.
|
||||||
@@ -29,8 +30,8 @@ const (
|
|||||||
// resolve to /api/docs/{id}/versions...
|
// resolve to /api/docs/{id}/versions...
|
||||||
func (h *Handler) versionRoutes(r chi.Router) {
|
func (h *Handler) versionRoutes(r chi.Router) {
|
||||||
r.Get("/{id}/versions", h.listVersions)
|
r.Get("/{id}/versions", h.listVersions)
|
||||||
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
||||||
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
||||||
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
|
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
|||||||
docID, db.LocalUserID,
|
docID, db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -59,16 +60,16 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var v db.DocumentVersion
|
var v db.DocumentVersion
|
||||||
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
|
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getVersion returns one snapshot in full (including content) for preview.
|
// getVersion returns one snapshot in full (including content) for preview.
|
||||||
@@ -79,10 +80,10 @@ func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, v)
|
httputil.WriteJSON(w, http.StatusOK, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createVersion takes an explicit, user-requested ('manual') restore point from
|
// createVersion takes an explicit, user-requested ('manual') restore point from
|
||||||
@@ -96,16 +97,16 @@ func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err := h.insertVersion(doc, db.VersionKindManual)
|
v, err := h.insertVersion(doc, db.VersionKindManual)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, v)
|
httputil.WriteJSON(w, http.StatusCreated, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreVersion copies a snapshot back onto the live document. Before
|
// restoreVersion copies a snapshot back onto the live document. Before
|
||||||
@@ -121,17 +122,17 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
current, err := h.fetch(docID)
|
current, err := h.fetch(docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
|
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
|
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
@@ -153,10 +154,10 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
doc, err := h.fetch(docID)
|
doc, err := h.fetch(docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, doc)
|
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeAutoSnapshot records a throttled background snapshot of the just-saved
|
// maybeAutoSnapshot records a throttled background snapshot of the just-saved
|
||||||
|
|||||||
37
internal/httputil/json.go
Normal file
37
internal/httputil/json.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Package httputil holds the small HTTP response helpers shared by every API
|
||||||
|
// handler package (docs, suggestions, vocab, …). Keeping them in one place means
|
||||||
|
// JSON encoding and — crucially — error handling behave identically everywhere:
|
||||||
|
// internal errors are logged in full but never leaked to the client.
|
||||||
|
package httputil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteJSON encodes v as the response body with the given status code.
|
||||||
|
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorJSON sends a {"error": msg} body with the given status. The message is
|
||||||
|
// caller-chosen and safe to show the client.
|
||||||
|
func ErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||||
|
WriteJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadRequest is the common 400 shorthand.
|
||||||
|
func BadRequest(w http.ResponseWriter, msg string) {
|
||||||
|
ErrorJSON(w, http.StatusBadRequest, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerError logs the real error (with full detail, for the operator) and
|
||||||
|
// returns a generic 500 to the client — raw database/internal errors must never
|
||||||
|
// reach the browser, where they leak schema and implementation details.
|
||||||
|
func ServerError(w http.ResponseWriter, err error) {
|
||||||
|
log.Printf("internal error: %v", err)
|
||||||
|
ErrorJSON(w, http.StatusInternalServerError, "something went wrong")
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var body chatRequest
|
var body chatRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +38,8 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
// scoped to the local user so a stray id can't read another user's doc.
|
// scoped to the local user so a stray id can't read another user's doc.
|
||||||
var (
|
var (
|
||||||
original, replacement, explanation, typ string
|
original, replacement, explanation, typ string
|
||||||
fromPos int
|
fromPos int
|
||||||
contentText string
|
contentText string
|
||||||
)
|
)
|
||||||
err := h.DB.QueryRow(
|
err := h.DB.QueryRow(
|
||||||
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text
|
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text
|
||||||
@@ -48,11 +49,11 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
sugID, db.LocalUserID,
|
sugID, db.LocalUserID,
|
||||||
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
|
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Flush through; bail with a plain error if somehow they don't.
|
// Flush through; bail with a plain error if somehow they don't.
|
||||||
flusher, ok := w.(http.Flusher)
|
flusher, ok := w.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
serverError(w, errors.New("streaming unsupported"))
|
httputil.ServerError(w, errors.New("streaming unsupported"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
|
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
|
||||||
// still appropriate since we haven't written SSE headers yet.
|
// still appropriate since we haven't written SSE headers yet.
|
||||||
errorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
|
httputil.ErrorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ package suggestions
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,17 +99,17 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
docID, db.LocalUserID,
|
docID, db.LocalUserID,
|
||||||
).Scan(&contentText, &tone)
|
).Scan(&contentText, &tone)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
errorJSON(w, http.StatusNotFound, "document not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing to analyze on an empty document — skip the LLM round-trip.
|
// Nothing to analyze on an empty document — skip the LLM round-trip.
|
||||||
if strings.TrimSpace(contentText) == "" {
|
if strings.TrimSpace(contentText) == "" {
|
||||||
writeJSON(w, http.StatusOK, []db.Suggestion{})
|
httputil.WriteJSON(w, http.StatusOK, []db.Suggestion{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +119,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
// error, so the frontend keeps showing current suggestions.
|
// error, so the frontend keeps showing current suggestions.
|
||||||
existing, err := h.fetchPending(docID)
|
existing, err := h.fetchPending(docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, existing)
|
httputil.WriteJSON(w, http.StatusOK, existing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,12 +132,12 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
// the per-document slot for the full interval — stranding the frontend's
|
// the per-document slot for the full interval — stranding the frontend's
|
||||||
// auto-retry on the throttle path. Release it so a retry can re-run.
|
// auto-retry on the throttle path. Release it so a retry can re-run.
|
||||||
limiter.Release(docID, slotAt)
|
limiter.Release(docID, slotAt)
|
||||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
httputil.ErrorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
|
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,10 +146,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
|||||||
// throttle path above stays consistent with the success path.
|
// throttle path above stays consistent with the success path.
|
||||||
out, err := h.fetchPending(docID)
|
out, err := h.fetchPending(docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingScope describes how one LLM pass touches the shared suggestions table:
|
// pendingScope describes how one LLM pass touches the shared suggestions table:
|
||||||
@@ -261,10 +261,10 @@ func suggestionKey(original, replacement string) string {
|
|||||||
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
out, err := h.fetchPending(chi.URLParam(r, "id"))
|
out, err := h.fetchPending(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
||||||
@@ -310,11 +310,11 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
|
|||||||
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
errorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -341,19 +341,3 @@ func normalizeType(t string) string {
|
|||||||
return db.SuggestionTypeGrammar
|
return db.SuggestionTypeGrammar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- response helpers -------------------------------------------------------
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func serverError(w http.ResponseWriter, err error) {
|
|
||||||
errorJSON(w, http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,17 +37,17 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var body rewriteRequest
|
var body rewriteRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
text := strings.TrimSpace(body.Text)
|
text := strings.TrimSpace(body.Text)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len([]rune(text)) > llm.RewriteMaxRunes {
|
if len([]rune(text)) > llm.RewriteMaxRunes {
|
||||||
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,19 +59,19 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
|
|||||||
docID, db.LocalUserID,
|
docID, db.LocalUserID,
|
||||||
).Scan(&exists)
|
).Scan(&exists)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
errorJSON(w, http.StatusNotFound, "document not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
|
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
|
httputil.ErrorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
|
httputil.WriteJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,25 +34,25 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
|||||||
sugID, db.LocalUserID,
|
sugID, db.LocalUserID,
|
||||||
).Scan(&explanation)
|
).Scan(&explanation)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
explanation = strings.TrimSpace(explanation)
|
explanation = strings.TrimSpace(explanation)
|
||||||
if explanation == "" {
|
if explanation == "" {
|
||||||
writeJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
|
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, translateResponse{Translation: out})
|
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: out})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
|
// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
|
||||||
@@ -81,7 +82,7 @@ func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
|
|||||||
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
||||||
rows, err := h.DB.Query(query, args...)
|
rows, err := h.DB.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -89,16 +90,16 @@ func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
word, err := scanWord(rows)
|
word, err := scanWord(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out = append(out, word)
|
out = append(out, word)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
type captureRequest struct {
|
type captureRequest struct {
|
||||||
@@ -110,6 +111,28 @@ type captureRequest struct {
|
|||||||
DocID *string `json:"doc_id"`
|
DocID *string `json:"doc_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Field-length caps. The captured fields come from the offline lexicon and the
|
||||||
|
// editor selection, not free-typed prose, so we clamp rather than reject — a
|
||||||
|
// lookup should never fail because a definition ran long. Counts are runes so a
|
||||||
|
// Chinese gloss isn't cut mid-character. The word is the upsert key, so an absurd
|
||||||
|
// "word" is bounded too.
|
||||||
|
const (
|
||||||
|
maxWordLen = 128
|
||||||
|
maxGlossLen = 512
|
||||||
|
maxDefinitionLen = 4096
|
||||||
|
maxPhoneticLen = 256
|
||||||
|
maxExampleLen = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// clamp trims s to at most max runes (rune-safe so multibyte glosses survive).
|
||||||
|
func clamp(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:max])
|
||||||
|
}
|
||||||
|
|
||||||
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
|
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
|
||||||
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
|
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
|
||||||
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
|
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
|
||||||
@@ -117,18 +140,47 @@ type captureRequest struct {
|
|||||||
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
||||||
var req captureRequest
|
var req captureRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
errorJSON(w, http.StatusBadRequest, "invalid body")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
|
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
|
||||||
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
|
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
|
||||||
// "Apple" at a sentence start and "apple" mid-line would create two separate
|
// "Apple" at a sentence start and "apple" mid-line would create two separate
|
||||||
// cards on independent schedules.
|
// cards on independent schedules.
|
||||||
word := strings.ToLower(strings.TrimSpace(req.Word))
|
word := clamp(strings.ToLower(strings.TrimSpace(req.Word)), maxWordLen)
|
||||||
if word == "" {
|
if word == "" {
|
||||||
errorJSON(w, http.StatusBadRequest, "word is required")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "word is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.Gloss = clamp(req.Gloss, maxGlossLen)
|
||||||
|
req.Definition = clamp(req.Definition, maxDefinitionLen)
|
||||||
|
req.Phonetic = clamp(req.Phonetic, maxPhoneticLen)
|
||||||
|
req.Example = clamp(req.Example, maxExampleLen)
|
||||||
|
|
||||||
|
// A blank doc_id is the same as none; otherwise the word must belong to a
|
||||||
|
// document the user actually owns. Without this check a stale or forged id
|
||||||
|
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
|
||||||
|
// instead of a clean 400 (and, once auth lands, would let a word be attached
|
||||||
|
// to another user's document).
|
||||||
|
if req.DocID != nil {
|
||||||
|
if strings.TrimSpace(*req.DocID) == "" {
|
||||||
|
req.DocID = nil
|
||||||
|
} else {
|
||||||
|
var ok int
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
|
*req.DocID, db.LocalUserID,
|
||||||
|
).Scan(&ok)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
|
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
|
||||||
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
||||||
@@ -145,16 +197,16 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := h.fetch(word)
|
out, err := h.fetch(word)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, out)
|
httputil.WriteJSON(w, http.StatusCreated, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch loads one word row by its (user, word) key.
|
// fetch loads one word row by its (user, word) key.
|
||||||
@@ -175,25 +227,36 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
|||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
var req reviewRequest
|
var req reviewRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
errorJSON(w, http.StatusBadRequest, "invalid body")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
|
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
|
||||||
errorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
|
httputil.ErrorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The grade is computed in Go (the SM-2-lite scheduler), so the read and the
|
||||||
|
// write must be one atomic unit: a bare SELECT-then-UPDATE could interleave
|
||||||
|
// with a concurrent review of the same card and lose an update. Wrap both in a
|
||||||
|
// transaction.
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // no-op once committed
|
||||||
|
|
||||||
var cur State
|
var cur State
|
||||||
err := h.DB.QueryRow(
|
err = tx.QueryRow(
|
||||||
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
|
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||||
id, db.LocalUserID,
|
id, db.LocalUserID,
|
||||||
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
|
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
errorJSON(w, http.StatusNotFound, "word not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,25 +264,29 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
|||||||
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
|
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
|
||||||
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
|
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
|
||||||
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
|
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
|
||||||
if _, err := h.DB.Exec(
|
if _, err := tx.Exec(
|
||||||
`UPDATE vocab_words SET
|
`UPDATE vocab_words SET
|
||||||
reps = ?, interval_days = ?, ease = ?, lapses = ?,
|
reps = ?, interval_days = ?, ease = ?, lapses = ?,
|
||||||
last_reviewed = datetime('now'), due_at = datetime('now', ?)
|
last_reviewed = datetime('now'), due_at = datetime('now', ?)
|
||||||
WHERE id = ? AND user_id = ?`,
|
WHERE id = ? AND user_id = ?`,
|
||||||
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
|
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := scanWord(h.DB.QueryRow(
|
out, err := scanWord(tx.QueryRow(
|
||||||
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
|
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
|
||||||
))
|
))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
if err := tx.Commit(); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove deletes a word from the garden (e.g. the writer already knows it).
|
// remove deletes a word from the garden (e.g. the writer already knows it).
|
||||||
@@ -229,28 +296,12 @@ func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
|||||||
chi.URLParam(r, "id"), db.LocalUserID,
|
chi.URLParam(r, "id"), db.LocalUserID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
httputil.ServerError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
errorJSON(w, http.StatusNotFound, "word not found")
|
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- response helpers -------------------------------------------------------
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func serverError(w http.ResponseWriter, err error) {
|
|
||||||
errorJSON(w, http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -183,6 +184,42 @@ func TestCaptureCaseInsensitive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCaptureUnknownDocID rejects an unowned/stale doc_id with a clean 400
|
||||||
|
// rather than letting it hit the foreign key and leak a raw 500.
|
||||||
|
func TestCaptureUnknownDocID(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||||
|
`{"word":"quixotic","gloss":"不切实际的","doc_id":"does-not-exist"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unknown doc_id: want 400, got %d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
// A blank doc_id is treated as none, not an error.
|
||||||
|
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"limpid","gloss":"清澈的","doc_id":""}`); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("blank doc_id should be accepted as none: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaptureClampsLongFields proves over-long fields are clamped (not rejected)
|
||||||
|
// so a lookup never fails on length, and the word key stays bounded.
|
||||||
|
func TestCaptureClampsLongFields(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
longWord := strings.Repeat("a", maxWordLen+50)
|
||||||
|
longDef := strings.Repeat("x", maxDefinitionLen+50)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||||
|
`{"word":"`+longWord+`","definition":"`+longDef+`"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var w Word
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||||
|
if len([]rune(w.Word)) != maxWordLen {
|
||||||
|
t.Fatalf("word should clamp to %d runes, got %d", maxWordLen, len([]rune(w.Word)))
|
||||||
|
}
|
||||||
|
if len([]rune(w.Definition)) != maxDefinitionLen {
|
||||||
|
t.Fatalf("definition should clamp to %d runes, got %d", maxDefinitionLen, len([]rune(w.Definition)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
|
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
|
||||||
// garden when its source document is removed.
|
// garden when its source document is removed.
|
||||||
func TestDocLinkSurvivesDocDelete(t *testing.T) {
|
func TestDocLinkSurvivesDocDelete(t *testing.T) {
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ let current: { audio: HTMLAudioElement; url: string } | null = null
|
|||||||
// a newer tap can detect it's stale and bow out instead of double-playing.
|
// a newer tap can detect it's stale and bow out instead of double-playing.
|
||||||
let requestSeq = 0
|
let requestSeq = 0
|
||||||
|
|
||||||
function stopCurrent(): void {
|
// stopSpeech halts any read-aloud in flight — both the server-audio element and
|
||||||
|
// the Web Speech fallback — and bumps requestSeq so a fetch still in flight bows
|
||||||
|
// out instead of playing late. Exported so a panel can cancel audio on unmount,
|
||||||
|
// keeping a word's pronunciation from outliving the panel that started it.
|
||||||
|
export function stopSpeech(): void {
|
||||||
|
requestSeq++
|
||||||
if (current) {
|
if (current) {
|
||||||
current.audio.pause()
|
current.audio.pause()
|
||||||
URL.revokeObjectURL(current.url)
|
URL.revokeObjectURL(current.url)
|
||||||
@@ -77,7 +82,7 @@ export function detectLang(text: string): string {
|
|||||||
// with no configured voice).
|
// with no configured voice).
|
||||||
export function speak(text: string, lang = detectLang(text)): void {
|
export function speak(text: string, lang = detectLang(text)): void {
|
||||||
if (!text.trim()) return
|
if (!text.trim()) return
|
||||||
stopCurrent()
|
stopSpeech()
|
||||||
const seq = ++requestSeq
|
const seq = ++requestSeq
|
||||||
|
|
||||||
fetch('/api/tts', {
|
fetch('/api/tts', {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
||||||
import { speak, speechSupported } from '../../audio/speech'
|
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
|
||||||
|
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
|
|
||||||
// GardenPanel is the vocabulary garden: every word the writer has looked up,
|
// GardenPanel is the vocabulary garden: every word the writer has looked up,
|
||||||
// grown into a blossom that opens further the more she remembers it, plus a
|
// grown into a blossom that opens further the more she remembers it, plus a
|
||||||
@@ -48,6 +49,12 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
|||||||
const [cursor, setCursor] = useState(0)
|
const [cursor, setCursor] = useState(0)
|
||||||
const [revealed, setRevealed] = useState(false)
|
const [revealed, setRevealed] = useState(false)
|
||||||
const [expanded, setExpanded] = useState<string | null>(null)
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
|
const panelRef = useFocusTrap<HTMLElement>()
|
||||||
|
|
||||||
|
// Read-aloud is fire-and-forget, so a word she tapped could still be speaking
|
||||||
|
// when the panel closes. Cancel any in-flight audio on unmount so it can't
|
||||||
|
// outlive the garden.
|
||||||
|
useEffect(() => stopSpeech, [])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(false)
|
setError(false)
|
||||||
@@ -127,6 +134,11 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="词汇花园 · Vocabulary Garden"
|
||||||
|
tabIndex={-1}
|
||||||
className="relative flex h-full w-full max-w-[420px] flex-col"
|
className="relative flex h-full w-full max-w-[420px] flex-col"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { api, type Document, type DocumentVersion } from '../../api/client'
|
import { api, type Document, type DocumentVersion } from '../../api/client'
|
||||||
|
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
|
|
||||||
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
|
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
|
||||||
// document, newest first, with a one-click preview and restore. It's the safety
|
// document, newest first, with a one-click preview and restore. It's the safety
|
||||||
@@ -41,6 +42,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
||||||
const [preview, setPreview] = useState<DocumentVersion | null>(null)
|
const [preview, setPreview] = useState<DocumentVersion | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const panelRef = useFocusTrap<HTMLElement>()
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(false)
|
setError(false)
|
||||||
@@ -99,6 +101,11 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="历史 · History"
|
||||||
|
tabIndex={-1}
|
||||||
className="relative flex h-full w-full max-w-[380px] flex-col"
|
className="relative flex h-full w-full max-w-[380px] flex-col"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||||
import { StatsPanel } from './StatsPanel'
|
import { StatsPanel } from './StatsPanel'
|
||||||
import { SoundToggle } from './SoundToggle'
|
import { SoundToggle } from './SoundToggle'
|
||||||
@@ -30,8 +30,40 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
|||||||
// StatusBar is the slim footer: word count on the left, save state and the
|
// StatusBar is the slim footer: word count on the left, save state and the
|
||||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||||
|
// The live "Petal is working" indicators. Each is a breathing dot + label shown
|
||||||
|
// while its pass is in flight; driving them from one array keeps the markup (and
|
||||||
|
// the "nothing in flight" check below) in lockstep as passes are added.
|
||||||
|
interface Indicator {
|
||||||
|
active: boolean
|
||||||
|
color: string
|
||||||
|
title: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
|
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
|
||||||
const label = SAVE_LABEL[saveStatus]
|
const label = SAVE_LABEL[saveStatus]
|
||||||
|
|
||||||
|
const indicators: Indicator[] = [
|
||||||
|
{
|
||||||
|
active: checking,
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
title: 'Petal is reading your writing…',
|
||||||
|
label: 'Checking…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
active: voicing,
|
||||||
|
color: 'var(--color-honey)',
|
||||||
|
title: 'Petal is reading your voice…',
|
||||||
|
label: 'Reading your voice…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
active: collocating,
|
||||||
|
color: 'var(--color-blossom)',
|
||||||
|
title: 'Petal is looking for more natural word pairings…',
|
||||||
|
label: 'Finding natural phrasing…',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const anyBusy = indicators.some((i) => i.active)
|
||||||
// The expanded stats panel toggles open when the word count is clicked.
|
// The expanded stats panel toggles open when the word count is clicked.
|
||||||
const [statsOpen, setStatsOpen] = useState(false)
|
const [statsOpen, setStatsOpen] = useState(false)
|
||||||
const statsRef = useRef<HTMLDivElement>(null)
|
const statsRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -68,43 +100,21 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
|
|||||||
</button>
|
</button>
|
||||||
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
||||||
</div>
|
</div>
|
||||||
{checking && (
|
{indicators
|
||||||
<>
|
.filter((i) => i.active)
|
||||||
<span aria-hidden>·</span>
|
.map((i) => (
|
||||||
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…">
|
<Fragment key={i.label}>
|
||||||
<span
|
<span aria-hidden>·</span>
|
||||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
<span className="inline-flex items-center gap-1.5" title={i.title}>
|
||||||
style={{ background: 'var(--color-accent)' }}
|
<span
|
||||||
/>
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
Checking…
|
style={{ background: i.color }}
|
||||||
</span>
|
/>
|
||||||
</>
|
{i.label}
|
||||||
)}
|
</span>
|
||||||
{voicing && (
|
</Fragment>
|
||||||
<>
|
))}
|
||||||
<span aria-hidden>·</span>
|
{llmDown && !anyBusy && (
|
||||||
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
|
|
||||||
<span
|
|
||||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
|
||||||
style={{ background: 'var(--color-honey)' }}
|
|
||||||
/>
|
|
||||||
Reading your voice…
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{collocating && (
|
|
||||||
<>
|
|
||||||
<span aria-hidden>·</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5" title="Petal is looking for more natural word pairings…">
|
|
||||||
<span
|
|
||||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
|
||||||
style={{ background: 'var(--color-blossom)' }}
|
|
||||||
/>
|
|
||||||
Finding natural phrasing…
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{llmDown && !checking && !voicing && !collocating && (
|
|
||||||
<>
|
<>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
59
web/src/hooks/useFocusTrap.ts
Normal file
59
web/src/hooks/useFocusTrap.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
// useFocusTrap makes a slide-over / modal keyboard-accessible. Attach the
|
||||||
|
// returned ref to the dialog container and, while it's mounted, it:
|
||||||
|
// • moves focus into the panel on open (so Tab/Escape work without a click),
|
||||||
|
// • keeps Tab/Shift+Tab cycling within the panel instead of escaping to the
|
||||||
|
// page behind the backdrop, and
|
||||||
|
// • restores focus to whatever was focused before it opened on unmount.
|
||||||
|
// Escape handling stays with each panel, which has its own close semantics.
|
||||||
|
export function useFocusTrap<T extends HTMLElement>() {
|
||||||
|
const ref = useRef<T>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = ref.current
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
// Remember where focus was so we can hand it back when the panel closes.
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null
|
||||||
|
|
||||||
|
const focusables = () =>
|
||||||
|
Array.from(
|
||||||
|
node.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
|
||||||
|
),
|
||||||
|
).filter((el) => el.offsetParent !== null)
|
||||||
|
|
||||||
|
// Focus the first interactive element, or the container itself as a fallback.
|
||||||
|
const first = focusables()[0]
|
||||||
|
if (first) first.focus()
|
||||||
|
else node.focus()
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
const items = focusables()
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const firstEl = items[0]
|
||||||
|
const lastEl = items[items.length - 1]
|
||||||
|
const active = document.activeElement
|
||||||
|
if (e.shiftKey && active === firstEl) {
|
||||||
|
e.preventDefault()
|
||||||
|
lastEl.focus()
|
||||||
|
} else if (!e.shiftKey && active === lastEl) {
|
||||||
|
e.preventDefault()
|
||||||
|
firstEl.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => {
|
||||||
|
node.removeEventListener('keydown', onKeyDown)
|
||||||
|
previouslyFocused?.focus?.()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return ref
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user