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
199 lines
5.8 KiB
Go
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()
|
|
}
|