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
This commit is contained in:
prosolis
2026-07-27 08:06:08 -07:00
parent ddc4164228
commit 30d5e691c9
13 changed files with 795 additions and 49 deletions
+23
View File
@@ -436,6 +436,29 @@ CREATE TABLE images (
CREATE INDEX idx_images_user_id ON images(user_id);
ALTER TABLE users ADD COLUMN pair_lang TEXT NOT NULL DEFAULT 'zh';
`,
},
{
// The personal spelling dictionary moves off the browser. It used to be a
// single `petal.spell.personal` key in localStorage, which meant two
// people sharing a device shared a word list built from one person's
// private writing — and one person writing on two devices had two
// unrelated lists.
//
// `lang` is the *dictionary's* language, not the writer's: a word is only
// ever added while a particular Hunspell dictionary flagged it, and an
// en-US personal word must not silence a pt-PT flag (or vice versa) once
// the second pair ships. `word` is stored as typed; matching is exact,
// because case carries meaning to a speller ("polish" vs "Polish").
name: "0011_personal_dictionary",
stmt: `
CREATE TABLE personal_words (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lang TEXT NOT NULL,
word TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, lang, word)
);
`,
},
}
+198
View File
@@ -0,0 +1,198 @@
// 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()
}
+213
View File
@@ -0,0 +1,213 @@
package spell
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// newTestServer mounts the routes behind the same auth middleware main.go
// installs — a bare router resolves no caller, so every scoped query would
// silently match nothing.
func newTestServer(t *testing.T) (http.Handler, *db.DB) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
r := chi.NewRouter()
r.Mount("/spell", New(database).Routes())
return auth.Middleware(auth.StaticResolver(db.LocalUserID))(r), database
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
} else {
r = httptest.NewRequest(method, path, nil)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, r)
return rec
}
func decodeWords(t *testing.T, rec *httptest.ResponseRecorder) wordsResponse {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body)
}
var got wordsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v (body %s)", err, rec.Body)
}
return got
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestLifecycle walks add → list → re-add → delete, and asserts the two
// properties the client relies on: adds are idempotent, and every response
// carries the full resulting list so the browser never has to merge.
func TestLifecycle(t *testing.T) {
srv, _ := newTestServer(t)
// Empty to start, and a list, never null.
got := decodeWords(t, do(t, srv, http.MethodGet, "/spell/words", ""))
if got.Lang != "en" || len(got.Words) != 0 {
t.Fatalf("fresh list = %+v, want empty en", got)
}
if !bytes.Contains(
do(t, srv, http.MethodGet, "/spell/words", "").Body.Bytes(), []byte(`"words":[]`),
) {
t.Fatal("empty list encoded as null, not []")
}
// One word, then a bulk add (the shape the browser's one-time adoption uses).
got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words", `{"word":"Petal"}`))
if !equal(got.Words, []string{"Petal"}) {
t.Fatalf("after add = %v", got.Words)
}
got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words",
`{"words":["hanfu","qipao","Petal"]}`))
if !equal(got.Words, []string{"Petal", "hanfu", "qipao"}) {
t.Fatalf("after bulk add = %v, want sorted and de-duplicated", got.Words)
}
// Re-adding an existing word must not error or duplicate it.
got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words", `{"word":"hanfu"}`))
if !equal(got.Words, []string{"Petal", "hanfu", "qipao"}) {
t.Fatalf("re-add changed the list: %v", got.Words)
}
// Delete, then delete again — forgetting a word Petal never knew is a
// success, since the caller's intent already holds.
got = decodeWords(t, do(t, srv, http.MethodDelete, "/spell/words?word=qipao", ""))
if !equal(got.Words, []string{"Petal", "hanfu"}) {
t.Fatalf("after delete = %v", got.Words)
}
got = decodeWords(t, do(t, srv, http.MethodDelete, "/spell/words?word=qipao", ""))
if !equal(got.Words, []string{"Petal", "hanfu"}) {
t.Fatalf("repeat delete = %v", got.Words)
}
}
// TestLanguagesDoNotMerge is the reason `lang` is in the primary key: a word the
// writer excused in English must not silence the pt-PT dictionary too.
func TestLanguagesDoNotMerge(t *testing.T) {
srv, _ := newTestServer(t)
do(t, srv, http.MethodPost, "/spell/words", `{"word":"tarde"}`)
got := decodeWords(t, do(t, srv, http.MethodPost, "/spell/words",
`{"lang":"pt-PT","word":"tarde"}`))
if !equal(got.Words, []string{"tarde"}) || got.Lang != "pt-pt" {
t.Fatalf("pt list = %+v", got)
}
// Removing it from one dictionary leaves the other alone.
do(t, srv, http.MethodDelete, "/spell/words?lang=pt-PT&word=tarde", "")
if got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words?lang=pt-PT", "")); len(got.Words) != 0 {
t.Fatalf("pt list after delete = %v", got.Words)
}
if got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words", "")); !equal(got.Words, []string{"tarde"}) {
t.Fatalf("en list collaterally damaged: %v", got.Words)
}
// Case and whitespace in the tag must not split one list into three.
got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words?lang=EN", ""))
if !equal(got.Words, []string{"tarde"}) {
t.Fatalf("uppercase lang tag saw a different list: %v", got.Words)
}
}
func TestRejectsJunk(t *testing.T) {
srv, _ := newTestServer(t)
cases := []struct{ name, method, path, body string }{
{"empty word", http.MethodPost, "/spell/words", `{"word":" "}`},
{"no word at all", http.MethodPost, "/spell/words", `{"lang":"en"}`},
{"not json", http.MethodPost, "/spell/words", `nonsense`},
{"delete without a word", http.MethodDelete, "/spell/words", ""},
}
for _, c := range cases {
if rec := do(t, srv, c.method, c.path, c.body); rec.Code != http.StatusBadRequest {
t.Errorf("%s: code=%d, want 400", c.name, rec.Code)
}
}
// An over-long entry is dropped rather than stored — a pasted paragraph is
// not a word. Dropping the only entry leaves nothing to add, hence 400.
long := `{"word":"` + string(bytes.Repeat([]byte("a"), MaxWordLen+1)) + `"}`
if rec := do(t, srv, http.MethodPost, "/spell/words", long); rec.Code != http.StatusBadRequest {
t.Errorf("over-long word: code=%d, want 400", rec.Code)
}
}
// TestTwoUsersDoNotShare is the standing rule for every user-scoped endpoint:
// mount the same routes twice behind two resolvers over one database.
func TestTwoUsersDoNotShare(t *testing.T) {
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
if _, err := database.Exec(
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
"bob", "bob@petal.local", "Bob",
); err != nil {
t.Fatalf("seed second user: %v", err)
}
mount := func(userID string) http.Handler {
r := chi.NewRouter()
r.Mount("/spell", New(database).Routes())
return auth.Middleware(auth.StaticResolver(userID))(r)
}
alice, bob := mount(db.LocalUserID), mount("bob")
do(t, alice, http.MethodPost, "/spell/words", `{"words":["Xiaolan","hanfu"]}`)
// Bob sees none of it — a personal dictionary is built from private writing.
if got := decodeWords(t, do(t, bob, http.MethodGet, "/spell/words", "")); len(got.Words) != 0 {
t.Fatalf("bob sees alice's words: %v", got.Words)
}
// Bob's own identical word is his own row, and deleting it leaves hers.
do(t, bob, http.MethodPost, "/spell/words", `{"word":"hanfu"}`)
do(t, bob, http.MethodDelete, "/spell/words?word=hanfu", "")
if got := decodeWords(t, do(t, alice, http.MethodGet, "/spell/words", "")); !equal(got.Words, []string{"Xiaolan", "hanfu"}) {
t.Fatalf("bob's delete reached alice's list: %v", got.Words)
}
// Deleting the account takes the dictionary with it.
if _, err := database.Exec(`DELETE FROM users WHERE id = 'bob'`); err != nil {
t.Fatalf("delete user: %v", err)
}
var n int
if err := database.QueryRow(
`SELECT COUNT(*) FROM personal_words WHERE user_id = 'bob'`).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Fatalf("%d orphaned rows after the user was deleted", n)
}
}