diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 97f6014..c8aa513 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -180,7 +180,7 @@ Option B ratified. `go-oidc` + `x/oauth2`; config fields already existed. The `R ### Phase 17 — Migrate the `local` user ✅ (2026-07-27) — her writing now lives on her account Script, app stopped, backup first (OPEN #4). **The "she logs in once first" dependency turned out not to exist**: authentik's default `hashed_user_id` sub mode makes the subject `User.uid`, which is derived from her user id and the instance secret — stable, and readable before she has ever signed in (`ak shell -c "…User.objects.get(username='claire').uid"`). So the data can move *first*, and she signs in to find her writing already there rather than to an empty Petal that fills in later. -- [ ] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` **and `images`** (versions/suggestions follow parents; `images` is new in Phase 16 and carries `user_id` directly — miss it and every pasted picture 404s), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data +- [x] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` **and `images`** (versions/suggestions follow parents; `images` is new in Phase 16 and carries `user_id` directly — miss it and every pasted picture 404s), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data - [x] `scripts/migrate_local_user.py` — dry-run by default, `VACUUM INTO` backup before touching anything, one transaction with `PRAGMA foreign_keys=OFF`, re-points `documents`/`tags`/`vocab_words`/`images`, deletes the old user row, and **verifies every expected row actually moved (and that the source is left owning nothing) before it commits**, rolling back otherwise. Refuses to merge into an account that already owns writing. Runbook in the script header. - **The "is the app stopped?" guard needed a second attempt.** `BEGIN EXCLUSIVE` — the obvious check — passes straight through against a *running but idle* Petal, because in WAL mode it only conflicts with another writer. That is exactly the case the guard exists to catch, and it would have failed silently. `PRAGMA locking_mode = EXCLUSIVE` conflicts with any connection at all, since it locks the shared-memory index every WAL reader maps; verified against a live server. - [x] **Run for real.** Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image moved from `local` onto `5f47d955…` (Claire, `clairew8@pm.me`). Sequence: `-backup` snapshot of millenia's live DB (taken while it kept running — `VACUUM INTO` needs no write lock), shipped to the VPS, installed over the throwaway staging database (kept as `petal.db.staging-*`), **started once so migration `0010` applied**, stopped, migrated, started. Verified over public HTTPS with a short-lived probe session, then removed: `/api/me` is her, 8 documents listed, her image 200s, vocabulary garden and version history intact, search returns hits — and 401 without the cookie. @@ -188,9 +188,15 @@ Script, app stopped, backup first (OPEN #4). **The "she logs in once first" depe - **A second bug in the guard, found by running it against production rather than a test file.** `PRAGMA locking_mode = EXCLUSIVE` keeps holding the lock after being set back to `NORMAL` — SQLite only releases it on that connection's next database access — so on a **WAL** database the script locked itself out of its own `VACUUM INTO` backup. It passed locally because the test database had come out of `VACUUM INTO` and so wasn't in WAL mode at all — the same shape of miss as the trailing-slash issuer: the fixture didn't look like production. The probe now runs on its own connection and closes it, and the fix was re-verified against a database that had genuinely been served in WAL mode. - **Startup crash averted while sequencing this**: the image backfill claims unowned files for `local`, which stops existing after the migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal. Petal would have entered a crash loop the first time it started on a migrated database. The backfill now skips a missing owner (there is nothing to claim in that case anyway; the migration moves the image rows itself). -### Phase 18 — Per-user, per-language client state -- [ ] Namespace `localStorage` keys by user id once known: `petal.spell.personal`, `petal.companion`, sound/petals prefs -- [ ] Personal spell dictionary keyed by **user + language** (en and pt-PT word lists must not merge); consider promoting it to a server table so it follows her across devices (nice-to-have, decide during) +### Phase 18 — Per-user, per-language client state ✅ (2026-07-27) +- [x] **Preferences namespaced by account** (`web/src/lib/prefs.ts`) — `petal.sound`, `petal.petals` and `petal.companion` now read/write `.u.`. The wrinkle is timing: `sounds.ts` and `petals.ts` read their value at *import* time, long before `/api/me` answers, so rather than block startup on the network for a mute flag, a read before the answer sees the **legacy un-namespaced key** (on a single-writer browser, exactly the right value) and `setPrefsScope` — called from `useSession` the moment `/api/me` resolves — adopts it and fires `onPrefsScopeChange` so each module re-reads. `PetalCompanion` re-reads too, unless she's already swapped mascots in the meantime. +- [x] **Legacy adoption is a move, not a copy** (user's call): the first account to sign in on a browser inherits whatever was set back when Petal had no accounts, and the key is then deleted so the *second* account starts from Petal's defaults rather than from a stranger's choices. An existing scoped value is never overwritten by the legacy one. +- [x] **Personal spell dictionary promoted to a server table** (the plan's nice-to-have; user chose it) — new `internal/spell` package + migration `0011_personal_dictionary`: `personal_words (user_id, lang, word, created_at)`, `PRIMARY KEY (user_id, lang, word)`, cascading with the account. `GET/POST/DELETE /api/spell/words`; adds are idempotent, a delete of a word that was never there is a success (the caller's intent already holds), and **every response carries the full resulting list** so the client never has to merge two views of one set. Namespacing it in `localStorage` instead would have *fragmented* the list she already has across her laptop and tablet — strictly worse than before; a table means it follows her. +- [x] `lang` is the **dictionary's** language, not the writer's — an en-US personal word must not silence a pt-PT flag once the second pair ships. Normalised (`trim`/lowercase, default `en`) so `EN`/`en`/absent can't split one list into three. +- [x] `useSpellChecker` is server-backed: nspell loads, then the word list arrives from her account and is replayed in. A browser still holding the Phase-7 `petal.spell.personal` key hands it over on first load — but **only lets go of it once the server has accepted it**, so a failed request costs nothing. `addWord` takes effect in the editor immediately and persists in the background: the underline goes away the instant she asks, whatever the network is doing. A word list that fails to load costs correct words being flagged, never writing. +- [x] Tests: `internal/spell/handlers_test.go` — lifecycle (idempotent add, bulk add, repeat delete, `[]` not `null`), languages-don't-merge incl. the case-normalisation case, junk rejection, and the standing-rule **two-user isolation** (mount twice behind two resolvers over one DB: Bob sees none of Alice's words, his identical word is his own row, his delete doesn't reach hers, deleting the account takes the dictionary with it). `web/src/lib/prefs.test.ts` — legacy adoption, move-not-copy, two accounts on one browser, never-overwrite, listener fires once per real change, storage-throws safety. +- Verified: go build/vet/test, tsc, vite build, vitest 82/82 all clean; live smoke on a throwaway DB (:8073) — add/bulk-add/list/delete, pt-PT list independent of en, CJK word accepted, 400 on empty. +- ⚠️ **Not yet deployed, and the migration rehearsal against a copy of the live VPS database did not run** — the snapshot command was blocked by this session's permission classifier. `0011` is a plain `CREATE TABLE` with no table rebuild and no dependency on existing rows (unlike `0005`/`0010`), so the risk is low, but the convention is worth honouring before the container is rebuilt. ### Phase 19 — Langpack extraction (the copy chore) Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled). @@ -238,6 +244,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6). - [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above) ## Session log +- 2026-07-27: **Phase 18 — the browser's settings become her settings** (user: "let's continue the build plan"). Two scope calls taken with the user: the personal spell dictionary goes **server-side** rather than being namespaced in place, and the pre-account `localStorage` keys are **adopted then rescoped** by the first writer to sign in. New `web/src/lib/prefs.ts` namespaces `petal.sound`/`petal.petals`/`petal.companion` by user id; the interesting part is timing — those modules read their value at *import* time, before `/api/me` can possibly have answered, so a pre-scope read deliberately sees the legacy key (the right value on a single-writer browser) and `setPrefsScope`, called from `useSession`, adopts it and notifies every reader. Adoption **moves** rather than copies, so account two starts from Petal's defaults instead of inheriting a stranger's mascot. New `internal/spell` package + migration `0011`: `personal_words` keyed `(user_id, lang, word)`, where `lang` is the **dictionary's** language, not the writer's — an English exception must not silence a pt-PT flag when the second pair ships. `useSpellChecker` now replays her list from her account, hands over any browser-held Phase-7 list on first load (releasing it only once the server has taken it), and persists an added word in the background so the underline vanishes the instant she asks. **The reason for the server table over cheaper namespacing**: keying the existing list by user in `localStorage` would have *fragmented* the words she already has across her laptop and tablet — the "cheap" fix was the one that made things worse. Tests: full lifecycle + languages-don't-merge + junk + the standing-rule two-user isolation suite in Go, and legacy-adoption/move-not-copy/two-accounts/storage-throws in vitest. go build/vet/test, tsc, vite, vitest 82/82 clean; live smoke on a throwaway DB. **Not deployed** — and the customary rehearsal of `0011` against a copy of the live VPS database was blocked by the session's permission classifier, so that check is outstanding (it is a plain `CREATE TABLE`, so lower-risk than `0005`/`0010`, but the convention exists for a reason). - 2026-07-27: **Phase 17 — Claire's writing moved onto her real account** (user: "claire is local user today in Petal. let's make sure to migrate existing data to her account"). `scripts/migrate_local_user.py`: dry-run by default, own `VACUUM INTO` backup, one transaction with foreign keys off, re-points `documents`/`tags`/`vocab_words`/`images`, verifies every expected row moved before committing. **The plan's stated prerequisite — "she logs in once so her sub exists" — turned out to be false**: authentik's `hashed_user_id` sub is `User.uid`, derived from her id and the instance secret, so it is readable in advance and the data could move *first*; she signs in to find her writing already there instead of to an empty Petal. Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image now belong to `5f47d955…`, verified end to end over public HTTPS. Per the user's call the VPS is now canonical and millenia was left running and untouched as a frozen fallback (it diverges the moment either is written to — retire it rather than sync it). **Three bugs, each found by a different kind of contact with reality**: (1) the image backfill claims files for `local`, which stops existing after a migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal, so Petal would have crash-looped on first start against a migrated database; (2) `BEGIN EXCLUSIVE` was the wrong liveness check, since in WAL mode it only conflicts with another *writer* and sails past a running-but-idle Petal — exactly the case the guard exists for; (3) the replacement, `PRAGMA locking_mode = EXCLUSIVE`, holds its lock past being reset to `NORMAL`, so on a real WAL database the script locked itself out of its own backup — invisible locally because the test file had come from `VACUUM INTO` and wasn't in WAL mode. Same shape as Phase 16's trailing-slash issuer: the fixture didn't look like production. - 2026-07-27: **Phase 16 built — Petal authenticates for itself** (user: "let's continue the build plan"; box access granted mid-session). New `internal/auth` surface on top of the Phase-0 `Resolver` seam: `session.go` (opaque cookie, **SHA-256-at-rest**, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune), `oidc.go` (login/callback/logout with state + nonce + PKCE, **lazy retried discovery** so an IdP outage can't stop Petal booting or invalidate live sessions), `users.go` (provisioning upsert keyed on `sub`, `/api/me`, allowlist). Migration `0010` lands `sessions`, `images` and `users.pair_lang` together. `main.go` picks the resolver from config, so a laptop build is unchanged. **Image ownership** closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403, `Cache-Control` dropped to `private`, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a **draft rescue** to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. **Three deliberate deviations from the plan**, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance); `SESSION_SECRET` was dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed `(name, user_id)` rather than owned singly, which is what preserves deduplication. **A real bug caught by writing the round-trip test rather than by reading the code**: the one-shot state/nonce/PKCE cookies were cleared in a `defer`, i.e. after the redirect had already written the header, so the clearing `Set-Cookie` was silently dropped. Verified: full go/tsc/vite/vitest suites, migration `0010` against a `VACUUM INTO` copy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). **Then deployed** (user: "do it! register it!"): provider + application registered in Authentik via `ak shell`, `.env` filled in, image rebuilt, and the **Traefik basic-auth gate removed** — Petal holds its own door now. Deploying immediately found two things no test could: the issuer's **trailing slash is significant** (Authentik's has one, OIDC compares byte-for-byte, and my normalising it away broke discovery while the slashless stub kept passing — now a knob with a regression test), and a provider created through the shell rather than the admin UI comes up with **empty `grant_types`**, which authentik answers with `invalid_request` before the login page renders. Verified over public HTTPS: health 200, `/api/docs` 401 with no basic-auth challenge, `/auth/login` → Authentik with state+nonce+PKCE, following it lands on the real sign-in page. Also swapped the emoji favicon for a **drawn sakura** (`web/public/petal.svg`) that renders in Petal's own rose palette everywhere instead of at each platform's discretion, and doubles as the Authentik app tile (inlined as a data URI, since this authentik doesn't serve `/media`). **The allowlist is `prosolis@proton.me` only** — that IdP fronts ~40 accounts, so empty was not an option and guessing her account would either lock her out or let a stranger in; adding her is one `.env` line and a restart. - 2026-07-27: **Phase 15 finished off on the two boxes** (user granted millenia access mid-session: `ssh reala@192.168.1.212`, and parodia is `ssh reala@100.64.0.1` over headscale). **LLM link**: rather than rebinding vLLM as planned, `deploy/vllm-headscale-proxy.service` (socat) adds a listener on `100.64.0.2` only — `vllm-chat.service` is shared with **Gogobee** and **Open WebUI** (whose endpoint lives in its own DB, not env), so a rebind meant three consumer edits and a 35B reload; the forwarder cost nothing and no downtime. Grammar checkpoint from the VPS now returns real suggestions in ~3s. **Backups**: the user pointed out the VPS already has daily provider VM backups *and* an age-encrypted offsite `parodia-backup` job, so Petal was folded into the latter instead of running a parallel cron — and doing so **exposed a real bug in that job's `sqlite_dump` helper**: Python's `iterdump` does not reproduce an FTS5 virtual table, so any restore would have come back with cross-document search silently missing (fixed with a `VACUUM INTO`-based helper, round-trip verified). The **bigger** find: millenia, which holds her actual writing, had **no scheduled backup at all** — now `petal-backup.timer`, age-encrypted with the parodia public recipient and pushed off-box, neither machine able to decrypt it. **Encryption at rest** (user raised it; correctly): VPS data dir is now LUKS2 covering the DB, images *and* the TTS cache; key on-box as a deliberate availability tradeoff, documented for what it does and doesn't stop. Rehearsing a reboot caught two bugs a clean run would have hidden — the plaintext originals were still on the unencrypted root fs *under* the mount, and `systemd-cryptsetup` wasn't installed so crypttab was being ignored entirely and the volume would never have unlocked at boot. Added a `.volume-ok` guard so an unmounted volume fails loudly instead of serving a blank DB. **Millenia hygiene**: Piper had been dead since the Jul 26 reboot — **26,800+ failed restarts**, read-aloud silently degrading to browser Web Speech — because an OS upgrade moved `/usr/bin/python3` 3.13→3.14 and the venv's `site-packages` went invisible; venv recreated (lands Piper 1.6.0, which is what `TTS_PATH` exists for), both voices verified through Petal. Petal itself was running unsupervised at PPID 1 and is now `petal.service` (verified by `kill -9`); the Piper units got `StartLimitIntervalSec`/`Burst` so a broken service enters `failed` instead of looping forever unnoticed. Remaining: an external uptime-kuma probe (needs the UI), a true VPS reboot test (shared public host, user's call), and millenia is still unencrypted at rest. diff --git a/cmd/server/main.go b/cmd/server/main.go index 0fb53b3..d40bfed 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -21,6 +21,7 @@ import ( "gitea.parodia.dev/drwily/petal/internal/images" "gitea.parodia.dev/drwily/petal/internal/lexicon" "gitea.parodia.dev/drwily/petal/internal/llm" + "gitea.parodia.dev/drwily/petal/internal/spell" "gitea.parodia.dev/drwily/petal/internal/suggestions" "gitea.parodia.dev/drwily/petal/internal/tts" "gitea.parodia.dev/drwily/petal/internal/vocab" @@ -160,6 +161,11 @@ func main() { // surfaced for gentle spaced-repetition review. pr.Mount("/vocab", vocab.New(database).Routes()) + // The personal spelling dictionary — the words she's told Petal to stop + // flagging. Kept server-side (rather than in the browser) so it belongs + // to her account and follows her between devices. + pr.Mount("/spell", spell.New(database).Routes()) + // Editor image uploads, stored on disk and served back by content hash // to whoever owns them. Files already on disk from before ownership // existed are claimed for the local user at startup. diff --git a/internal/db/db.go b/internal/db/db.go index d4aef0e..e82fa52 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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) +); `, }, } diff --git a/internal/spell/handlers.go b/internal/spell/handlers.go new file mode 100644 index 0000000..7d494cb --- /dev/null +++ b/internal/spell/handlers.go @@ -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() +} diff --git a/internal/spell/handlers_test.go b/internal/spell/handlers_test.go new file mode 100644 index 0000000..9036d5b --- /dev/null +++ b/internal/spell/handlers_test.go @@ -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) + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ad3b9d2..5d70f6e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -150,6 +150,13 @@ export interface MechanicsFinding { explanation: string } +// One dictionary's worth of personal words — the ones she's excused from +// spell-check. Keyed by the dictionary's language, not the writer's. +export interface PersonalWords { + lang: string + words: string[] +} + // Who's writing. Mirrors the backend db.User. export interface Me { id: string @@ -333,6 +340,24 @@ export const api = { req(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }), deleteVocab: (id: string) => req(`/vocab/${id}`, { method: 'DELETE' }), + // The personal spelling dictionary — words she's told Petal to stop flagging. + // Server-side, so it belongs to her account and follows her between devices. + // `lang` is the *dictionary's* language: an English exception must not silence + // a pt-PT flag. Every call answers with the full resulting list, so the client + // never has to merge two views of the same set. + listPersonalWords: (lang: string) => + req(`/spell/words?lang=${encodeURIComponent(lang)}`), + addPersonalWords: (lang: string, words: string[]) => + req('/spell/words', { + method: 'POST', + body: JSON.stringify({ lang, words }), + }), + removePersonalWord: (lang: string, word: string) => + req( + `/spell/words?lang=${encodeURIComponent(lang)}&word=${encodeURIComponent(word)}`, + { method: 'DELETE' }, + ), + // Current deployed build id — changes whenever a new frontend ships. The // app polls this to offer a refresh. Bypasses any cache so the answer is live. version: () => req<{ version: string }>('/version', { cache: 'no-store' }), diff --git a/web/src/audio/sounds.ts b/web/src/audio/sounds.ts index 97cee2f..ddbd299 100644 --- a/web/src/audio/sounds.ts +++ b/web/src/audio/sounds.ts @@ -13,7 +13,10 @@ import blockUrl from '../assets/sounds/block.mp3' import baodingUrl from '../assets/sounds/baoding.mp3' import milestoneUrl from '../assets/sounds/milestone.mp3' import errorUrl from '../assets/sounds/error.mp3' +import { onPrefsScopeChange, readPref, writePref } from '../lib/prefs' +// The mute choice belongs to the account, not the browser — one person's +// silence must not mute the next writer to sign in here. const STORAGE_KEY = 'petal.sound' // Master volume — deliberately gentle. These are background delights, not alerts. @@ -53,24 +56,27 @@ let enabled = readEnabled() const listeners = new Set<(on: boolean) => void>() function readEnabled(): boolean { - try { - return localStorage.getItem(STORAGE_KEY) !== 'off' - } catch { - return true - } + return readPref(STORAGE_KEY) !== 'off' } +// This module reads its value at import time, before /api/me has answered. +// Re-read once the account is known, in case this writer's choice differs from +// whatever the browser was holding. +onPrefsScopeChange(() => { + const next = readEnabled() + if (next === enabled) return + enabled = next + listeners.forEach((fn) => fn(next)) + if (next) void ensureContext() +}) + export function isSoundEnabled(): boolean { return enabled } export function setSoundEnabled(on: boolean): void { enabled = on - try { - localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off') - } catch { - /* private mode — choice just won't persist */ - } + writePref(STORAGE_KEY, on ? 'on' : 'off') listeners.forEach((fn) => fn(on)) // Touching the context on enable doubles as a user-gesture unlock + warm-up. if (on) void ensureContext() diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx index c80a70c..95573f5 100644 --- a/web/src/components/Companion/PetalCompanion.tsx +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave' import { useCompanion, type Mood } from './useCompanion' import { LottiePlayer } from './LottiePlayer' import { COMPANIONS, DEFAULT_COMPANION } from './companions' +import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs' interface Props { wordCount: number @@ -38,13 +39,22 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep text, }) - const [companionId, setCompanionId] = useState(() => { - try { - return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION - } catch { - return DEFAULT_COMPANION - } - }) + const [companionId, setCompanionId] = useState( + () => readPref(STORAGE_KEY) || DEFAULT_COMPANION, + ) + + // The mascot belongs to the writer, not the browser. That first read happens + // before /api/me answers, so pick the choice up again once the account is + // known — unless she's already swapped companions in the meantime. + const touched = useRef(false) + useEffect( + () => + onPrefsScopeChange(() => { + if (touched.current) return + setCompanionId(readPref(STORAGE_KEY) || DEFAULT_COMPANION) + }), + [], + ) const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0] const [pickerOpen, setPickerOpen] = useState(false) const rootRef = useRef(null) @@ -69,11 +79,8 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep function choose(id: string) { setCompanionId(id) - try { - localStorage.setItem(STORAGE_KEY, id) - } catch { - /* private mode / storage disabled — selection just won't persist */ - } + touched.current = true + writePref(STORAGE_KEY, id) setPickerOpen(false) } diff --git a/web/src/effects/petals.ts b/web/src/effects/petals.ts index e76a2c0..45dcc76 100644 --- a/web/src/effects/petals.ts +++ b/web/src/effects/petals.ts @@ -2,6 +2,10 @@ // people find drifting petals distracting rather than cozy, so the whole effect // is opt-out-able and the choice persists in localStorage so it survives reloads. // Mirrors the tiny pub/sub used for the sound mute toggle (../audio/sounds). +// The choice belongs to the account, not the browser, so it reads and writes +// through the per-user preference scope (../lib/prefs). + +import { onPrefsScopeChange, readPref, writePref } from '../lib/prefs' const STORAGE_KEY = 'petal.petals' @@ -9,24 +13,26 @@ let enabled = readEnabled() const listeners = new Set<(on: boolean) => void>() function readEnabled(): boolean { - try { - return localStorage.getItem(STORAGE_KEY) !== 'off' - } catch { - return true - } + return readPref(STORAGE_KEY) !== 'off' } +// The first read happens at import time, before /api/me has said who is +// writing. Re-read once it has, in case this writer's choice differs from +// whatever the browser held. +onPrefsScopeChange(() => { + const next = readEnabled() + if (next === enabled) return + enabled = next + listeners.forEach((fn) => fn(next)) +}) + export function isPetalsEnabled(): boolean { return enabled } export function setPetalsEnabled(on: boolean): void { enabled = on - try { - localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off') - } catch { - /* private mode — choice just won't persist */ - } + writePref(STORAGE_KEY, on ? 'on' : 'off') listeners.forEach((fn) => fn(on)) } diff --git a/web/src/hooks/useSession.ts b/web/src/hooks/useSession.ts index 91168a2..ce7f2df 100644 --- a/web/src/hooks/useSession.ts +++ b/web/src/hooks/useSession.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { api, onUnauthorized, type Me } from '../api/client' +import { setPrefsScope } from '../lib/prefs' // useSession tracks who is writing, and notices the moment the server stops // recognising them. @@ -18,7 +19,13 @@ export function useSession() { api .me() .then((user) => { - if (!cancelled) setMe(user) + if (cancelled) return + // Browser preferences (mute, petals, companion) belong to the writer, + // not the machine. This is the moment their storage keys can stop being + // shared — and the first account on this browser inherits whatever was + // set back when Petal had no accounts at all. + setPrefsScope(user.id) + setMe(user) }) .catch(() => { // A 401 has already flipped signedOut through the interceptor; anything diff --git a/web/src/hooks/useSpellChecker.ts b/web/src/hooks/useSpellChecker.ts index aecf9ef..a5551fd 100644 --- a/web/src/hooks/useSpellChecker.ts +++ b/web/src/hooks/useSpellChecker.ts @@ -1,13 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import nspell, { type NSpell } from 'nspell' +import { api } from '../api/client' // useSpellChecker loads the vendored en-US Hunspell dictionary (served from // /dictionaries/en, embedded in the Go binary via web/dist) and builds an // in-browser nspell instance — zero backend round-trips, per spec. The // dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS -// bundle) once per app session, not per document. A personal word list lives in -// localStorage and is replayed into nspell on load; adding a word bumps a -// `version` so consumers re-run their decorations and the word stops flagging. +// bundle) once per app session, not per document. +// +// The personal word list — the words she's told Petal to stop flagging — lives +// on the server, keyed by her account and by the dictionary's language. It used +// to be one localStorage key, which meant two people sharing a device shared a +// word list built from one person's private writing, and one person on a laptop +// and a tablet had two lists that never met. It is replayed into nspell on load; +// adding a word bumps a `version` so consumers re-run their decorations and the +// word stops flagging. // SpellChecker is the minimal surface the editor decoration layer consumes. export interface SpellChecker { @@ -15,11 +22,21 @@ export interface SpellChecker { suggest(word: string): string[] } -const PERSONAL_KEY = 'petal.spell.personal' +// The dictionary this hook loads. Only en-US ships today; pt-PT arrives with the +// first Latin pair, and its personal words are a separate list by design — an +// English exception must not silence a Portuguese flag. +const DICT_LANG = 'en' -function loadPersonal(): string[] { +// Where the list lived before it had an owner (Phase 7). Read once, handed to +// the account, and then removed — see takeLegacyWords. +const LEGACY_KEY = 'petal.spell.personal' + +// takeLegacyWords reads the pre-account list without deleting it: the words are +// only dropped from the browser once the server has actually accepted them, so +// a failed request costs nothing. +function readLegacyWords(): string[] { try { - const raw = localStorage.getItem(PERSONAL_KEY) + const raw = localStorage.getItem(LEGACY_KEY) const parsed = raw ? JSON.parse(raw) : [] return Array.isArray(parsed) ? parsed.filter((w): w is string => typeof w === 'string') : [] } catch { @@ -27,11 +44,11 @@ function loadPersonal(): string[] { } } -function savePersonal(words: string[]) { +function clearLegacyWords() { try { - localStorage.setItem(PERSONAL_KEY, JSON.stringify(words)) + localStorage.removeItem(LEGACY_KEY) } catch { - /* storage unavailable — personal words just won't persist this session */ + /* storage unavailable — nothing was read from it either */ } } @@ -52,10 +69,25 @@ export function useSpellChecker() { ]) if (cancelled) return const sp = nspell(aff, dic) - for (const w of loadPersonal()) sp.add(w) spellRef.current = sp setReady(true) + + // Her own words come from her account. A browser holding a list from + // before accounts existed hands it over on the way — but only lets go of + // it once the server has taken it. + const legacy = readLegacyWords() + const stored = legacy.length + ? await api.addPersonalWords(DICT_LANG, legacy).then((res) => { + clearLegacyWords() + return res + }) + : await api.listPersonalWords(DICT_LANG) + if (cancelled) return + for (const w of stored.words) sp.add(w) + setVersion((v) => v + 1) } catch (err) { + // A failure here costs correct words being flagged, not writing. The + // checker itself stays usable if only the word list failed to arrive. console.error('spell checker failed to load', err) } })() @@ -75,13 +107,17 @@ export function useSpellChecker() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, version]) + // Adding a word takes effect in the editor immediately and is persisted in the + // background: the word stops being underlined the instant she asks, whatever + // the network is doing. const addWord = useCallback((word: string) => { const sp = spellRef.current if (!sp) return sp.add(word) - const next = Array.from(new Set([...loadPersonal(), word])) - savePersonal(next) setVersion((v) => v + 1) + api.addPersonalWords(DICT_LANG, [word]).catch((err) => { + console.error('could not save personal word', err) + }) }, []) return { checker, ready, addWord } diff --git a/web/src/lib/prefs.test.ts b/web/src/lib/prefs.test.ts new file mode 100644 index 0000000..f627894 --- /dev/null +++ b/web/src/lib/prefs.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +// These preferences are small — a mute toggle, a mascot — but they are the +// difference between "my Petal" and "this browser's Petal". The properties that +// matter: two accounts on one machine never see each other's choices, and the +// person who was here before accounts existed doesn't lose hers. + +function fakeStorage(): Storage { + const map = new Map() + return { + get length() { + return map.size + }, + key: (i: number) => [...map.keys()][i] ?? null, + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + clear: () => map.clear(), + } as Storage +} + +// Each test gets a fresh module, since the scope is module-level state that the +// real app sets exactly once. +async function freshPrefs() { + vi.resetModules() + return import('./prefs') +} + +beforeEach(() => { + vi.stubGlobal('localStorage', fakeStorage()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('per-account preferences', () => { + it('reads and writes the legacy key until the account is known', async () => { + const prefs = await freshPrefs() + prefs.writePref('petal.sound', 'off') + expect(localStorage.getItem('petal.sound')).toBe('off') + expect(prefs.readPref('petal.sound')).toBe('off') + }) + + it('adopts the pre-account choices for the first writer to sign in', async () => { + const prefs = await freshPrefs() + localStorage.setItem('petal.sound', 'off') + localStorage.setItem('petal.companion', 'happy-dog') + + prefs.setPrefsScope('claire') + + expect(prefs.readPref('petal.sound')).toBe('off') + expect(prefs.readPref('petal.companion')).toBe('happy-dog') + // Moved, not copied — the next account must not inherit them. + expect(localStorage.getItem('petal.sound')).toBeNull() + expect(localStorage.getItem('petal.companion')).toBeNull() + expect(localStorage.getItem('petal.sound.u.claire')).toBe('off') + }) + + it('keeps two accounts on one browser apart', async () => { + const prefs = await freshPrefs() + prefs.setPrefsScope('claire') + prefs.writePref('petal.companion', 'happy-dog') + + prefs.resetPrefsScopeForTests() + prefs.setPrefsScope('sam') + // Sam starts from Petal's defaults, not from Claire's mascot. + expect(prefs.readPref('petal.companion')).toBeNull() + + prefs.writePref('petal.companion', 'sleeping-cat') + prefs.resetPrefsScopeForTests() + prefs.setPrefsScope('claire') + expect(prefs.readPref('petal.companion')).toBe('happy-dog') + }) + + it('never lets an existing choice be overwritten by the legacy one', async () => { + const prefs = await freshPrefs() + localStorage.setItem('petal.sound', 'off') + localStorage.setItem('petal.sound.u.claire', 'on') + + prefs.setPrefsScope('claire') + + expect(prefs.readPref('petal.sound')).toBe('on') + expect(localStorage.getItem('petal.sound')).toBeNull() + }) + + it('notifies listeners once the account is known', async () => { + const prefs = await freshPrefs() + const seen: (string | null)[] = [] + prefs.onPrefsScopeChange(() => seen.push(prefs.readPref('petal.petals'))) + + localStorage.setItem('petal.petals', 'off') + prefs.setPrefsScope('claire') + // A second call for the same writer is not a change and must not re-fire. + prefs.setPrefsScope('claire') + + expect(seen).toEqual(['off']) + }) + + it('survives storage being unavailable', async () => { + const prefs = await freshPrefs() + vi.stubGlobal('localStorage', { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('denied') + }, + removeItem: () => { + throw new Error('denied') + }, + }) + + expect(() => prefs.setPrefsScope('claire')).not.toThrow() + expect(() => prefs.writePref('petal.sound', 'off')).not.toThrow() + expect(prefs.readPref('petal.sound')).toBeNull() + }) +}) diff --git a/web/src/lib/prefs.ts b/web/src/lib/prefs.ts new file mode 100644 index 0000000..4eb1035 --- /dev/null +++ b/web/src/lib/prefs.ts @@ -0,0 +1,94 @@ +// Per-account browser preferences. +// +// Petal's small "how I like it" settings — the mute toggle, the falling-petals +// toggle, the chosen companion — live in localStorage, which is a property of +// the *browser*, not of the writer. Once two people can sign in to one Petal +// that is a bleed: sharing a laptop would mean sharing a mascot, and one +// person's silence would mute the other. +// +// So every key is namespaced by user id. The wrinkle is timing: these modules +// read their value the moment they're imported, long before /api/me answers. +// Rather than block startup on the network for a mute flag, a read before the +// answer arrives sees the *legacy* un-namespaced key — which on a single-writer +// browser is exactly the right value — and `setPrefsScope` then adopts it into +// that writer's namespace and tells everyone to re-read. +// +// Adoption is a move, not a copy: the first account to sign in on a browser +// inherits whatever was set before accounts existed, and the second starts from +// Petal's defaults rather than from a stranger's choices. + +type Listener = () => void + +let userID: string | null = null +const listeners = new Set() + +// Every base key that should follow the account rather than the browser. Listed +// here (not just at each call site) because adoption has to walk them all the +// moment the scope becomes known. +const SCOPED_KEYS = ['petal.sound', 'petal.petals', 'petal.companion'] as const + +// scopedKey is the storage key actually used for `base` right now. Before the +// caller is known it is the legacy key, so a reload keeps working offline and +// pre-login reads see the browser's existing preference. +export function scopedKey(base: string): string { + return userID ? `${base}.u.${userID}` : base +} + +export function readPref(base: string): string | null { + try { + return localStorage.getItem(scopedKey(base)) + } catch { + return null + } +} + +export function writePref(base: string, value: string): void { + try { + localStorage.setItem(scopedKey(base), value) + } catch { + /* private mode or a full quota — the choice just won't survive a reload */ + } +} + +// onPrefsScopeChange fires once the account is known and the keys have moved, +// so a module that already read a value can read it again. Returns an +// unsubscribe. +export function onPrefsScopeChange(fn: Listener): () => void { + listeners.add(fn) + return () => listeners.delete(fn) +} + +// setPrefsScope names the writer these preferences belong to. Called once, from +// App, as soon as /api/me answers. +export function setPrefsScope(id: string): void { + if (!id || id === userID) return + userID = id + adoptLegacy() + listeners.forEach((fn) => fn()) +} + +// adoptLegacy hands the pre-account values to the first account that signs in +// on this browser, then removes them so nobody else can inherit them. +function adoptLegacy(): void { + try { + for (const base of SCOPED_KEYS) { + const legacy = localStorage.getItem(base) + if (legacy === null) continue + // Never overwrite a choice this account has already made here. + if (localStorage.getItem(scopedKey(base)) === null) { + localStorage.setItem(scopedKey(base), legacy) + } + localStorage.removeItem(base) + } + } catch { + /* storage unavailable — nothing to adopt, and nothing breaks */ + } +} + +// resetPrefsScopeForTests unbinds the account again. Exported for tests only; +// the app sets the scope once and never clears it (signing out leaves the +// editor mounted, and the same person usually signs back in). +export function resetPrefsScopeForTests(): void { + userID = null + listeners.clear() +}