// 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* the word was accepted against, not the writer's own. // // Phase 18 justified that key by saying an en-US personal word must not silence // a pt-PT flag once the second pair shipped. Phase 21 shipped it and the // justification did not survive: under the both-dictionaries rule // (SUGGESTIONS.md §3a) a word is only ever flagged when *every* loaded // dictionary rejected it, so there is no such thing as a pt-PT flag an English // exception could silence. What the key is actually good for is narrower and // still worth having — the rows say which dictionary each acceptance was made // against, so a pair that later loses or gains a dictionary keeps a truthful // record instead of one merged list of unknown provenance. The browser writes a // row per loaded dictionary when she accepts a word; see useSpellChecker. 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. English // is in every pair, so it is the safe assumption; pt-PT is named explicitly by // the pt-PT pair's second dictionary. 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() }