She's submitting work that gets run through an AI detector and wants to pre-check she won't be wrongly flagged. Petal should not answer that with a detector of its own: they misfire badly on non-native English (Stanford 2023 found >50% of TOEFL essays flagged as AI vs. near-zero for native writers), so a percentage aimed at an ESL writer is worse than nothing — it either scares her off her own voice or gives false comfort. So the artifact is provenance, not a verdict. Petal already snapshots every ~3 minutes; this turns that history into a standalone printable report: session breakdown, word-count growth, span, active time. No score is emitted anywhere. Two schema additions back it. preserve_history opts a document out of the 40-snapshot prune cap — right for recovery, wrong for provenance, where you want the whole span including the oldest rows. content_hash/prev_hash chain each snapshot to the one before it, so a history edited or thinned after the fact fails verification. Pruning legitimately severs links, so a link break reports as "gaps" unless preserve_history is on; only a hash that fails against its own contents is unconditionally "broken". The chart's x axis is snapshot order, not wall-clock, and that is the load -bearing decision. On a linear time axis an essay written in three sittings across three days renders as three vertical cliffs separated by empty space — visually identical to text pasted in three chunks, i.e. the report would have argued the opposite of the truth. Breaks are compressed into explicitly labelled gutters instead. TestChartGivesWidthToWriting pins it. The report volunteers its largest single word-count jump and states its own limits: it cannot show who was at the keyboard, or whether typed text was composed or copied in. Overclaiming would be self-defeating — a reader who catches it overstating discounts all of it. HTML rather than server-rendered PDF, as with the other exports: a CJK-safe PDF needs an embedded Unicode font or a headless browser. Print styles are there so the browser's Save as PDF is the handoff path. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
242 lines
7.0 KiB
Go
242 lines
7.0 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"
|
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
|
)
|
|
|
|
// 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 {
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
out = append(out, d)
|
|
ids = append(ids, d.ID)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
byDoc, err := h.tagsByDoc(ids)
|
|
if err != nil {
|
|
httputil.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{}
|
|
}
|
|
}
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.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 {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.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"`
|
|
|
|
// PreserveHistory toggles the passport's keep-everything mode. Sent alone
|
|
// by the History panel's toggle, never by the auto-save path.
|
|
PreserveHistory *bool `json:"preserve_history"`
|
|
}
|
|
|
|
// 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),
|
|
preserve_history = COALESCE(?, preserve_history),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ? AND user_id = ?`,
|
|
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount,
|
|
req.PreserveHistory, id, db.LocalUserID,
|
|
)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
doc, err := h.fetch(id)
|
|
if err != nil {
|
|
httputil.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)
|
|
}
|
|
}
|
|
|
|
httputil.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 {
|
|
httputil.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, preserve_history
|
|
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, &doc.PreserveHistory,
|
|
)
|
|
return doc, err
|
|
}
|
|
|
|
// --- small response helpers -------------------------------------------------
|
|
//
|
|
// The generic JSON/error helpers live in internal/httputil; these are the
|
|
// document-specific shorthands that carry domain wording.
|
|
|
|
func badRequest(w http.ResponseWriter, msg string) { httputil.BadRequest(w, msg) }
|
|
func notFound(w http.ResponseWriter) {
|
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
|
}
|
|
func notFoundMsg(w http.ResponseWriter, msg string) { httputil.ErrorJSON(w, http.StatusNotFound, msg) }
|