Files
petal/internal/docs/handlers.go
prosolis 9e141e4169 Phase 10: organization & polish — cross-doc search, tags, touch, warm failures
Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.

Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.

Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.

Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).

Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 06:29:56 -07:00

243 lines
6.8 KiB
Go

// Package docs implements the document CRUD HTTP handlers — the create / list /
// read / update / delete surface that backs the editor and its 1.5s auto-save.
// All access is scoped to the single hardcoded local user while auth is deferred.
package docs
import (
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// Handler holds the dependencies shared by every document route.
type Handler struct {
DB *db.DB
}
// New constructs a Handler.
func New(database *db.DB) *Handler {
return &Handler{DB: database}
}
// Routes returns a router mounting the document CRUD endpoints. Mount it under
// "/docs" so the full paths are /api/docs, /api/docs/{id}, etc.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/", h.list)
r.Post("/", h.create)
r.Get("/{id}", h.get)
r.Put("/{id}", h.update)
r.Delete("/{id}", h.delete)
h.versionRoutes(r)
h.exportRoutes(r)
h.registerTagRoutes(r)
return r
}
// docSummary is the lightweight shape returned by the list endpoint — enough to
// render the DocList sidebar without shipping every document's full body. Tags
// ride along so the sidebar can show chips and filter without a second request.
type docSummary struct {
ID string `json:"id"`
Title string `json:"title"`
WordCount int `json:"word_count"`
UpdatedAt string `json:"updated_at"`
Tags []db.Tag `json:"tags"`
}
// list returns the local user's documents, most-recently-updated first, each
// decorated with its tags.
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
rows, err := h.DB.Query(
`SELECT id, title, word_count, updated_at
FROM documents
WHERE user_id = ?
ORDER BY updated_at DESC`,
db.LocalUserID,
)
if err != nil {
serverError(w, err)
return
}
defer rows.Close()
out := []docSummary{} // non-nil so an empty list serializes as [] not null
ids := []string{}
for rows.Next() {
var d docSummary
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
serverError(w, err)
return
}
out = append(out, d)
ids = append(ids, d.ID)
}
if err := rows.Err(); err != nil {
serverError(w, err)
return
}
byDoc, err := h.tagsByDoc(ids)
if err != nil {
serverError(w, err)
return
}
for i := range out {
out[i].Tags = byDoc[out[i].ID] // nil → JSON null is fine; client treats as none
if out[i].Tags == nil {
out[i].Tags = []db.Tag{}
}
}
writeJSON(w, http.StatusOK, out)
}
// create inserts a fresh blank document and returns it in full.
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var doc db.Document
err := h.DB.QueryRow(
`INSERT INTO documents (user_id) VALUES (?)
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
db.LocalUserID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusCreated, doc)
}
// get returns a single full document by id.
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
doc, err := h.fetch(chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
}
if err != nil {
serverError(w, err)
return
}
writeJSON(w, http.StatusOK, doc)
}
// updateRequest is the auto-save payload. Every field is optional (a pointer) so
// the same endpoint serves both the editor's full save ({content, content_text,
// word_count, title}) and a DocList rename ({title} alone).
type updateRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
ContentText *string `json:"content_text"`
Tone *string `json:"tone"`
WordCount *int `json:"word_count"`
}
// update applies the provided fields to a document and returns the saved row.
// content and content_text are kept in sync by the client and written together.
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req updateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
badRequest(w, "invalid JSON body")
return
}
res, err := h.DB.Exec(
`UPDATE documents
SET title = COALESCE(?, title),
content = COALESCE(?, content),
content_text = COALESCE(?, content_text),
tone = COALESCE(?, tone),
word_count = COALESCE(?, word_count),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
)
if err != nil {
serverError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
notFound(w)
return
}
doc, err := h.fetch(id)
if err != nil {
serverError(w, err)
return
}
// Capture a throttled history snapshot when a real body save came through
// (not a bare rename) and the document has content. Best-effort: a failed
// snapshot must never fail the save itself.
if req.Content != nil && doc.ContentText != "" {
if err := h.maybeAutoSnapshot(doc); err != nil {
log.Printf("docs: auto-snapshot for %s failed: %v", id, err)
}
}
writeJSON(w, http.StatusOK, doc)
}
// delete removes a document (suggestions cascade via the FK).
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
res, err := h.DB.Exec(
`DELETE FROM documents WHERE id = ? AND user_id = ?`,
chi.URLParam(r, "id"), db.LocalUserID,
)
if err != nil {
serverError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
notFound(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
// fetch loads one full document scoped to the local user.
func (h *Handler) fetch(id string) (db.Document, error) {
var doc db.Document
err := h.DB.QueryRow(
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
FROM documents
WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
return doc, err
}
// --- small 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())
}
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) }