Files
petal/internal/spell/handlers.go
T
prosolis 30d5e691c9 Phase 18: settings that belong to the writer, not the browser
The mute toggle, the falling-petals toggle and the chosen companion lived in
localStorage, which is a property of the machine. Now that two people can sign
in to one Petal, sharing a laptop would have meant sharing a mascot and one
person's silence muting the other. Each key is namespaced by user id.

The awkward part is timing: sounds.ts and petals.ts read their value the moment
they are imported, long before /api/me can have answered. Rather than block
startup on the network for a mute flag, a read before the answer arrives sees
the old un-namespaced key -- on a single-writer browser, exactly the right
value -- and setPrefsScope then adopts it into that account's namespace and
tells every reader to look again. Adoption moves rather than copies, so the
first account inherits what was set before accounts existed and the second
starts from Petal's defaults.

The personal spelling dictionary moves further than that: onto the server. It
is built from her own writing, so it should not be readable by whoever sits
down at the same browser next -- but merely namespacing it would have split the
list she already has between her laptop and her tablet, which is worse than
where we started. A table keyed (user_id, lang, word) follows her instead. The
lang is the dictionary's, not hers: an English exception must not silence a
pt-PT flag once the second pair ships.

Adding a word takes effect in the editor immediately and persists in the
background, so the underline goes away the instant she asks. A browser still
holding the old list hands it over on first load, and only lets go once the
server has taken it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 08:06:08 -07:00

199 lines
5.8 KiB
Go

// Package spell owns the personal spelling dictionary — the words a writer has
// told Petal to stop flagging.
//
// It lived in the browser's localStorage until Phase 18, which was wrong twice
// over: two people sharing a device shared one list (built from one person's
// private writing), and one person writing on a laptop and a tablet had two
// lists that never met. It is a small amount of state, but it is *her* state,
// so it belongs to her account rather than to a browser profile.
//
// Everything here is scoped by `lang` as well as by user. That is the language
// of the *dictionary* that flagged the word, not the writer's own language: an
// en-US personal word must not silence a pt-PT flag once the second pair ships.
package spell
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// DefaultLang is the dictionary assumed when a caller doesn't name one. Only
// en-US ships today; pt-PT arrives with the first Latin pair.
const DefaultLang = "en"
// MaxWordLen bounds a single entry. A personal dictionary holds words, and a
// pasted paragraph is a bug (or an attempt to use the table as storage).
const MaxWordLen = 80
// MaxBatch bounds one request. The only bulk caller is the one-time adoption of
// a browser's pre-Phase-18 list, which is realistically tens of words.
const MaxBatch = 500
// Handler owns the /api/spell routes.
type Handler struct {
DB *db.DB
}
func New(database *db.DB) *Handler { return &Handler{DB: database} }
// Routes mounts the personal-dictionary endpoints under /api/spell.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/words", h.list)
r.Post("/words", h.add)
r.Delete("/words", h.remove)
return r
}
type wordsResponse struct {
Lang string `json:"lang"`
Words []string `json:"words"`
}
type addRequest struct {
Lang string `json:"lang"`
// Word and Words are both accepted so the everyday "add this one word" call
// stays obvious while the one-shot migration of a browser's old list is a
// single request rather than one per word.
Word string `json:"word"`
Words []string `json:"words"`
}
// normLang keeps the dictionary tag in one canonical shape so "EN", "en" and a
// missing value can never split one list into three.
func normLang(lang string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
if lang == "" {
return DefaultLang
}
return lang
}
// list returns the caller's words for one dictionary, alphabetically so the
// order is stable between requests.
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
lang := normLang(r.URL.Query().Get("lang"))
words, err := h.fetch(auth.UserID(r.Context()), lang)
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, wordsResponse{Lang: lang, Words: words})
}
// add inserts one or more words, idempotently, and answers with the resulting
// full list — so the client never has to merge two views of the same set.
func (h *Handler) add(w http.ResponseWriter, r *http.Request) {
var req addRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputil.BadRequest(w, "invalid JSON body")
return
}
lang := normLang(req.Lang)
incoming := req.Words
if req.Word != "" {
incoming = append(incoming, req.Word)
}
clean := make([]string, 0, len(incoming))
for _, word := range incoming {
word = strings.TrimSpace(word)
if word == "" || len([]rune(word)) > MaxWordLen {
continue
}
clean = append(clean, word)
}
if len(clean) == 0 {
httputil.BadRequest(w, "no word given")
return
}
if len(clean) > MaxBatch {
httputil.BadRequest(w, "too many words in one request")
return
}
userID := auth.UserID(r.Context())
tx, err := h.DB.Begin()
if err != nil {
httputil.ServerError(w, err)
return
}
defer func() { _ = tx.Rollback() }()
for _, word := range clean {
if _, err := tx.Exec(
`INSERT INTO personal_words (user_id, lang, word) VALUES (?, ?, ?)
ON CONFLICT(user_id, lang, word) DO NOTHING`,
userID, lang, word,
); err != nil {
httputil.ServerError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
httputil.ServerError(w, err)
return
}
words, err := h.fetch(userID, lang)
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, wordsResponse{Lang: lang, Words: words})
}
// remove forgets one word. Deleting something that was never there is a success:
// the caller's intent — "this word is not in my dictionary" — already holds.
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
word := strings.TrimSpace(r.URL.Query().Get("word"))
if word == "" {
httputil.BadRequest(w, "no word given")
return
}
lang := normLang(r.URL.Query().Get("lang"))
userID := auth.UserID(r.Context())
if _, err := h.DB.Exec(
`DELETE FROM personal_words WHERE user_id = ? AND lang = ? AND word = ?`,
userID, lang, word,
); err != nil {
httputil.ServerError(w, err)
return
}
words, err := h.fetch(userID, lang)
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, wordsResponse{Lang: lang, Words: words})
}
// fetch reads one (user, dictionary) list. Both keys are always bound — an
// unscoped read here would hand one writer another's private vocabulary.
func (h *Handler) fetch(userID, lang string) ([]string, error) {
rows, err := h.DB.Query(
`SELECT word FROM personal_words WHERE user_id = ? AND lang = ? ORDER BY word`,
userID, lang,
)
if err != nil {
return nil, err
}
defer rows.Close()
words := []string{} // never nil: the client expects a list, not null
for rows.Next() {
var word string
if err := rows.Scan(&word); err != nil {
return nil, err
}
words = append(words, word)
}
return words, rows.Err()
}