Find a document by its title
Spec §15.2 already ruled the scope: "title and metadata search ships; full text waits for a design, not for a decision." This is that. Nothing here reads a document's content. GET /?q= filters the list. The field is a plain GET form, so a search is a link — bookmarkable, reloadable, reachable with the back button — and the whole feature works with no JavaScript, which is what lets the keyboard palette (next commit) be an addition rather than the only way in. The filtering happens in Go, over the rows Store.List already loads, rather than in SQL. Titles are due to be sealed (spec §2), and a WHERE title LIKE ? stops matching the instant they are — silently, which is the worst way to find out. A pass over one person's own titles is a few hundred short strings, survives sealing unchanged, and inherits the owner scope from the query List already makes rather than from a second WHERE somebody has to remember. This is not in tension with §15.2's objection to full-text search: that objection is that decrypting the corpus per query is O(corpus), and titles are not the corpus. Ranking is ordered rather than scored: exact title, then a title starting with the query, then a title with a word starting with it, then a match anywhere, ties broken by most recently updated. Somebody should be able to look at a result order and say why it is that order, which a tuned numeric score does not give you. Case, and the punctuation that differs between how a title was typed and how it is remembered (em dash, hyphen, curly apostrophe), are normalised away. Accents are NOT folded — doing that well across twelve locales is a design, and there is a test saying so, so the behaviour is a decision rather than an oversight. Searching renders a ranked, ungrouped list. Grouping ranked results under "Today" and "Earlier" would order the same rows twice by two different rules and show only the second. GET /documents/search returns the same ranking as JSON for the palette, inside RequireSession and owner-scoped through the same Store.Search. The route that returns a LIST of titles is where a missing scope would leak the most at once, so the test holds it both at the store and through both HTTP paths — "the store is scoped" is not the same claim as "every caller passes the right owner". Two of these tests were wrong before they were right, both the same shape: a check for "docs-group-label" matched the stylesheet on every page, and a check against Catalog's English could never match, because a direct App.Handler() call installs no locale middleware and rastrillo.T returns each key verbatim. Both now assert on rendered structure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYB9fUAfxXNzkeC3fsFYzR
7 files changed,
+768
−8
docs/superpowers/plans/2026-09-01-search.md+106 −0internal/docs/app.go+5 −0internal/docs/handlers.go+61 −7internal/docs/render.go+32 −0internal/docs/search.go+202 −0internal/docs/search_test.go+324 −0internal/docs/views/list.html+38 −1
Intent
diff --git a/docs/superpowers/plans/2026-09-01-search.md b/docs/superpowers/plans/2026-09-01-search.md| new file mode 100644 |
| index 0000000..7b309bf |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-01-search.md |
| @@ -0,0 +1,106 @@ |
| +# Plan 6 — finding a document by its title |
| + |
| +Brief, from Paul, 2026-09-01: *"shall we do search now, so i can find |
| +docs by title?"* — and, asked how much: a search box on the list **plus** |
| +a keyboard jump-to. |
| + |
| +Spec §15.2 already rules on the scope: *"Title and metadata search |
| +ships; full text waits for a design, not for a decision."* This is that, |
| +and nothing more. Full-text search is still unbuilt and still needs a |
| +design compatible with §12's plaintext-grep gate. |
| + |
| +## D1 — filtering happens in Go, over the owner's own rows, not in SQL |
| + |
| +The obvious implementation is `WHERE title LIKE '%' || ? || '%'`. This |
| +plan does not use it. |
| + |
| +Spec §2 wants document titles sealed — per-row encryption, the same as |
| +`update_bin`. A SQL `LIKE` stops working the instant they are, and |
| +"search silently returns nothing" is a bad way to find that out. |
| +Filtering in Go over the rows `Store.List` already loads costs one pass |
| +over one person's *titles* — a few hundred short strings — and survives |
| +sealing unchanged, because at that point the rows are decrypted on the |
| +way past anyway. |
| + |
| +This is not in tension with §15.2's objection to full-text search. |
| +That objection is that decrypting the *corpus* per query is O(corpus). |
| +Titles are not the corpus; they are one short string per document. |
| + |
| +It also keeps §4.2's scoping automatic rather than restated: the filter |
| +runs over the result of the same owner-scoped query every other document |
| +route uses, so a document belonging to someone else cannot appear in |
| +results by construction, not by a second `WHERE` somebody has to |
| +remember. |
| + |
| +## D2 — the search box works with no JavaScript; the palette is additive |
| + |
| +`GET /?q=…` is a plain form in the list header. It filters |
| +server-side and renders the same list, so search works exactly as far as |
| +reading does (§6.2). The jump-to palette is layered on top and adds |
| +speed, not capability — a person with no JavaScript loses the keyboard |
| +shortcut and keeps search. |
| + |
| +Paul chose the palette knowing §6.4 cut filter chrome from this screen |
| +and §6.2 pushes against new JS. The rulings it sits against were about |
| +*chrome that does not earn itself*: a template carousel, an "Owned by |
| +anyone" filter, grid/list/AZ toggles. A search field on a list of |
| +documents is the thing those toggles were a poor substitute for. |
| + |
| +## D3 — one ranker, on the server |
| + |
| +The palette could filter client-side over a downloaded title list. It |
| +does not: it queries the same endpoint the list uses, debounced. |
| + |
| +Two rankers that disagree is exactly the kind of defect nobody notices — |
| +the palette and the search box would put the same document in different |
| +places, and each would look correct on its own. One implementation |
| +cannot drift from itself. It also means no bulk export of every title to |
| +the browser, and no cap to tune. |
| + |
| +The cost is that the palette needs the network per keystroke. On a |
| +document editor already streaming updates that is not a new dependency, |
| +and the palette shows its own state when a request fails. |
| + |
| +Ranking is deliberately explainable rather than clever: an exact |
| +title match, then a title that starts with the query, then a title with a |
| +*word* starting with it, then a match anywhere; ties broken by most |
| +recently updated. Somebody should be able to look at a result order and |
| +say why it is that order. |
| + |
| +## D4 — the palette implements its ARIA contract, or it does not claim one |
| + |
| +A combobox that says `role="combobox"`/`aria-expanded` is promising |
| +Down/Up to move through results, Enter to open, Escape to close, and |
| +`aria-activedescendant` to say which option is current. This branch |
| +implements all of it, and a browser-drive check holds it — including |
| +that Escape gets out and focus returns to where it came from, because a |
| +modal that traps focus with no way back is the failure mode a palette |
| +has. |
| + |
| +That rule is on this repo's own record (AGENTS.md, accessibility |
| +conventions) because the formatting bar broke it a day ago. |
| + |
| +## The work |
| + |
| +1. `internal/docs/search.go` — `Store.Search`, the ranking, and |
| + `GET /documents/search` returning JSON for the palette. |
| +2. `GET /?q=` — the list handler filters, and renders an ungrouped, |
| + ranked result list with its own empty state. Recency grouping is what |
| + an *unfiltered* list is for; a ranked result list grouped by "Today" |
| + would be ordering the results twice, differently. |
| +3. `fixtures/jump-to.mjs` — the palette, its own committed bundle so the |
| + list page does not pay for ProseMirror to get a keyboard shortcut. |
| + `assets.go` grows from "the editor bundle" to a small registry, one |
| + committed artifact per name. |
| +4. Tests: the ranking, the scoping (someone else's document is not |
| + findable), the no-JS path, and browser-drive checks for the palette. |
| + |
| +## Risks |
| + |
| +- **A second committed bundle** touches `assets.go`'s "exactly one" init |
| + invariant. It becomes "exactly one per name", which is the same |
| + guarantee, but it is the kind of check that is easy to weaken by |
| + accident while generalising it. |
| +- **`/documents/search` is a new authenticated endpoint returning |
| + titles.** It goes inside the `RequireSession` group and through the |
| + same owner scope; that it does is worth a test rather than a reading. |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 07a1a02..2fa58f6 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -442,6 +442,11 @@ func New(cfg Config) (*App, error) { |
| r.Use(authPlugin.RequireSession) |
| r.Get("/", h.list) |
| r.Post("/documents", h.create) |
| + // Title search for the jump-to palette (search.go). Inside |
| + // this group like every other document route, and owner-scoped |
| + // through Store.Search → Store.List rather than through a |
| + // check of its own — a person's document titles are theirs. |
| + r.Get("/documents/search", h.searchJSON) |
| r.Get("/d/{sqid}", h.show) |
| // Task 5: the update log, what the browser's editor reads to |
| // build its Yjs doc (GET) and posts new edits back through |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 778790d..b742489 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -64,6 +64,24 @@ type listPage struct { |
| CreateLabel string |
| CreatePlaceholder string |
| SignOutLabel string |
| + |
| + // Query is what was searched for, echoed back into the field so a |
| + // result page is a page a person can edit and re-submit rather |
| + // than one they have to retype into. |
| + // |
| + // Results is the ranked, ungrouped result list, used INSTEAD of |
| + // Groups when Searching. Recency grouping is what an unfiltered |
| + // list is for; grouping a ranked list under "Today" and "Earlier" |
| + // would order the same rows twice, by two different rules, and |
| + // show only the second. |
| + Searching bool |
| + Query string |
| + Results []docRow |
| + SearchLabel string |
| + SearchPlace string |
| + NoResultsHead string |
| + NoResultsBody string |
| + ClearLabel string |
| } |
| // docGroup is one recency bucket ("Today", "This week", ...) and the |
| @@ -122,22 +140,58 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - docs, err := h.store.List(r.Context(), owner) |
| - if err != nil { |
| - h.logger.Error("docs: list documents", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| - return |
| - } |
| + // The query is taken from the URL, so a search is a link: it can be |
| + // bookmarked, reloaded, shared with yourself, and gone back to. |
| + // That is also what makes the whole feature work with no |
| + // JavaScript (spec §6.2) — the form is a GET and the page it lands |
| + // on is an ordinary page. |
| + query := strings.TrimSpace(r.URL.Query().Get("q")) |
| data := listPage{ |
| Title: rastrillo.T(r, "docs.list.title"), |
| - Groups: groupByRecency(r, time.Now().UTC(), docs), |
| EmptyTitle: rastrillo.T(r, "docs.list.empty.title"), |
| EmptyBody: rastrillo.T(r, "docs.list.empty.body"), |
| CreateLabel: rastrillo.T(r, "docs.list.create.label"), |
| CreatePlaceholder: rastrillo.T(r, "docs.list.create.placeholder"), |
| SignOutLabel: rastrillo.T(r, "docs.signout.label"), |
| + Query: query, |
| + SearchLabel: rastrillo.T(r, "docs.list.search.label"), |
| + SearchPlace: rastrillo.T(r, "docs.list.search.placeholder"), |
| + NoResultsHead: rastrillo.T(r, "docs.list.search.none.title"), |
| + NoResultsBody: rastrillo.T(r, "docs.list.search.none.body"), |
| + ClearLabel: rastrillo.T(r, "docs.list.search.clear"), |
| + } |
| + |
| + if query != "" { |
| + hits, err := h.store.Search(r.Context(), owner, query) |
| + if err != nil { |
| + h.logger.Error("docs: search documents", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + now := time.Now().UTC() |
| + data.Searching = true |
| + for _, d := range hits { |
| + data.Results = append(data.Results, docRow{ |
| + Href: "/d/" + d.Sqid, |
| + Title: d.Title, |
| + When: formatWhen(now, d.UpdatedAt), |
| + }) |
| + } |
| + if err := h.render.page(w, r, "list", data); err != nil { |
| + h.logger.Error("docs: render list", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + } |
| + return |
| + } |
| + |
| + docs, err := h.store.List(r.Context(), owner) |
| + if err != nil { |
| + h.logger.Error("docs: list documents", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| } |
| + data.Groups = groupByRecency(r, time.Now().UTC(), docs) |
| if err := h.render.page(w, r, "list", data); err != nil { |
| h.logger.Error("docs: render list", "err", err) |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 5f221cd..31906a6 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -52,6 +52,38 @@ var Catalog = rastrillo.Catalog{ |
| "docs.list.create.label": "New document", |
| "docs.list.create.placeholder": "Title", |
| + // docs.list.search.* — finding a document by its title (spec |
| + // §15.2). The field is a GET form, so a search is a link and works |
| + // with no JavaScript; fixtures/jump-to.mjs adds the keyboard |
| + // shortcut on top of it and takes its own copy from here rather |
| + // than carrying any. |
| + // |
| + // "Search documents" rather than "Search": the field sits beside a |
| + // create field on the same header, and a bare "Search" next to a |
| + // bare "Title" says nothing about which list is being searched. |
| + // The empty state names the query back, because "nothing found" and |
| + // "nothing found FOR THIS" are different amounts of help when you |
| + // have mistyped. |
| + "docs.list.search.label": "Search documents", |
| + "docs.list.search.placeholder": "Search", |
| + "docs.list.search.none.title": "No documents match", |
| + "docs.list.search.none.body": "Nothing here has that in its title. Check the spelling, or try a shorter word.", |
| + "docs.list.search.clear": "Clear search", |
| + "docs.list.search.results": "Results", |
| + |
| + // docs.jumpto.* — the keyboard jump-to palette |
| + // (fixtures/jump-to.mjs), opened with Control-K or Command-K from |
| + // the list or from a document. Every string is rendered into the |
| + // page server-side, the same rule the editor's save-status copy |
| + // and the formatting bar follow: the bundle carries no copy of its |
| + // own to keep in sync with this file by hand. |
| + "docs.jumpto.label": "Jump to a document", |
| + "docs.jumpto.placeholder": "Search documents", |
| + "docs.jumpto.hint": "Type to search. Up and down to choose, Enter to open, Escape to close.", |
| + "docs.jumpto.empty": "No documents match", |
| + "docs.jumpto.failed": "Search is unavailable right now.", |
| + "docs.jumpto.close": "Close", |
| + |
| // docs.error.generic is the one fallback error string this task |
| // has: a render failure with nowhere styled to land yet (SKILL.md |
| // §7's view.Fail/ErrorPage wiring is later work). Still a catalog |
diff --git a/internal/docs/search.go b/internal/docs/search.go| new file mode 100644 |
| index 0000000..2f5bbf6 |
| --- /dev/null |
| +++ b/internal/docs/search.go |
| @@ -0,0 +1,202 @@ |
| +package docs |
| + |
| +// Finding a document by its title (spec §15.2's "title and metadata |
| +// search ships"). Full-text search is still unbuilt and still needs a |
| +// design compatible with §12's plaintext-grep gate; nothing here reads |
| +// a document's content. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "sort" |
| + "strings" |
| + "time" |
| + "unicode" |
| + |
| + "github.com/carlosframework/rastrillo" |
| +) |
| + |
| +// maxSearchResults caps what one query returns. |
| +// |
| +// The list this filters is one person's own documents, so there is no |
| +// unbounded corpus behind it — the cap is about the response and the |
| +// palette's own rendering, not about protecting the query. A person |
| +// with four hundred documents matching "notes" is not helped by four |
| +// hundred rows, and the ones past the first few dozen are ranked below |
| +// everything they were more likely to mean. |
| +const maxSearchResults = 50 |
| + |
| +// searchRank is how well a title matches a query. Lower is better, and |
| +// the values are ordered rather than scored: the point is that somebody |
| +// can look at a result order and say why it is that order, which a |
| +// tuned numeric score does not give you. |
| +type searchRank int |
| + |
| +const ( |
| + rankExact searchRank = iota // the title IS the query |
| + rankPrefix // the title starts with it |
| + rankWordPrefix // some word in the title starts with it |
| + rankContains // it appears somewhere |
| + rankNone // it does not appear |
| +) |
| + |
| +// rankTitle scores one title against an already-normalised query. |
| +func rankTitle(title, query string) searchRank { |
| + t := normaliseForSearch(title) |
| + switch { |
| + case t == query: |
| + return rankExact |
| + case strings.HasPrefix(t, query): |
| + return rankPrefix |
| + } |
| + if !strings.Contains(t, query) { |
| + return rankNone |
| + } |
| + // A word boundary is worth distinguishing from a match inside a |
| + // word: someone typing "min" means "Minutes", not "Determining". |
| + for _, word := range strings.Fields(t) { |
| + if strings.HasPrefix(word, query) { |
| + return rankWordPrefix |
| + } |
| + } |
| + return rankContains |
| +} |
| + |
| +// normaliseForSearch lowercases and collapses whitespace, and strips |
| +// the punctuation that varies between how a title was typed and how it |
| +// is remembered — an em dash for a hyphen, a curly apostrophe for a |
| +// straight one. Deliberately NOT a full Unicode fold: "café" and "cafe" |
| +// stay different, because guessing at accent equivalence across twelve |
| +// locales (render.go's Locales) is a design and not a detail. |
| +func normaliseForSearch(s string) string { |
| + var b strings.Builder |
| + b.Grow(len(s)) |
| + lastSpace := true |
| + for _, r := range strings.ToLower(s) { |
| + switch { |
| + case unicode.IsSpace(r) || r == '—' || r == '–' || r == '-' || r == '_': |
| + if !lastSpace { |
| + b.WriteRune(' ') |
| + lastSpace = true |
| + } |
| + case r == '\'' || r == '’': |
| + // Dropped rather than spaced: "don't" and "don t" should |
| + // not be different words. |
| + default: |
| + b.WriteRune(r) |
| + lastSpace = false |
| + } |
| + } |
| + return strings.TrimSpace(b.String()) |
| +} |
| + |
| +// Search returns owner's documents whose titles match query, best |
| +// first, capped at maxSearchResults. An empty or whitespace-only query |
| +// returns nil rather than everything — "no query" is not "match |
| +// nothing", and the caller decides what to show for it. |
| +// |
| +// The filtering happens here, in Go, over the rows List already loads, |
| +// rather than in SQL. See the plan's D1: document titles are due to be |
| +// sealed (spec §2), at which point a `WHERE title LIKE ?` stops |
| +// matching anything and does so silently, while a pass over one |
| +// person's decrypted titles keeps working unchanged. It also means the |
| +// owner scope is the one List already applies, rather than a second |
| +// WHERE clause somebody has to remember to write. |
| +func (s *Store) Search(ctx context.Context, owner int64, query string) ([]*Document, error) { |
| + q := normaliseForSearch(query) |
| + if q == "" { |
| + return nil, nil |
| + } |
| + |
| + docs, err := s.List(ctx, owner) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + type scored struct { |
| + doc *Document |
| + rank searchRank |
| + } |
| + var hits []scored |
| + for _, d := range docs { |
| + if r := rankTitle(d.Title, q); r != rankNone { |
| + hits = append(hits, scored{doc: d, rank: r}) |
| + } |
| + } |
| + |
| + // SliceStable, and List already returns newest-first: that makes |
| + // "most recently updated" the tie-break without re-reading a clock |
| + // or comparing timestamps here. |
| + sort.SliceStable(hits, func(i, j int) bool { return hits[i].rank < hits[j].rank }) |
| + |
| + if len(hits) > maxSearchResults { |
| + hits = hits[:maxSearchResults] |
| + } |
| + out := make([]*Document, 0, len(hits)) |
| + for _, h := range hits { |
| + out = append(out, h.doc) |
| + } |
| + return out, nil |
| +} |
| + |
| +// searchResult is one row of the JSON the palette reads. |
| +type searchResult struct { |
| + Title string `json:"title"` |
| + Href string `json:"href"` |
| + When string `json:"when"` |
| +} |
| + |
| +// searchJSON serves GET /documents/search?q=… for fixtures/jump-to.mjs. |
| +// |
| +// The palette queries this rather than filtering a downloaded list of |
| +// every title, so there is exactly one ranking implementation and it |
| +// cannot drift from the one the list page uses (the plan's D3). It also |
| +// means no bulk export of a person's titles to the browser. |
| +// |
| +// Inside app.go's RequireSession group and owner-scoped through |
| +// Search → List, so a document belonging to someone else cannot appear |
| +// here — the same property every other document route has, by the same |
| +// mechanism rather than by a check of its own. |
| +func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) { |
| + owner, err := h.owner(r) |
| + if err != nil { |
| + h.logger.Error("docs: resolve signed-in person", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + docs, err := h.store.Search(r.Context(), owner, r.URL.Query().Get("q")) |
| + if err != nil { |
| + h.logger.Error("docs: search documents", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + now := time.Now().UTC() |
| + results := make([]searchResult, 0, len(docs)) |
| + for _, d := range docs { |
| + results = append(results, searchResult{ |
| + Title: d.Title, |
| + Href: "/d/" + d.Sqid, |
| + When: formatWhen(now, d.UpdatedAt), |
| + }) |
| + } |
| + |
| + // Marshal before writing anything: a mid-encode failure must not |
| + // leave a half-written 200 on the wire, the same reason |
| + // renderer.page renders into a buffer first. |
| + body, err := json.Marshal(results) |
| + if err != nil { |
| + h.logger.Error("docs: encode search results", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| + // A person's own document titles, and they change as they type. |
| + w.Header().Set("Cache-Control", "no-store") |
| + if _, err := w.Write(body); err != nil { |
| + h.logger.Error("docs: write search results", "err", err) |
| + } |
| +} |
diff --git a/internal/docs/search_test.go b/internal/docs/search_test.go| new file mode 100644 |
| index 0000000..533baba |
| --- /dev/null |
| +++ b/internal/docs/search_test.go |
| @@ -0,0 +1,324 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// seedTitles creates documents with the given titles, in order, and |
| +// returns the owner. Created in order so the LAST one is the most |
| +// recently updated — which is what the rank tie-break relies on. |
| +func seedTitles(t *testing.T, app *App, address string, titles ...string) int64 { |
| + t.Helper() |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, address) |
| + for _, title := range titles { |
| + if _, err := app.Store().Create(ctx, owner, title); err != nil { |
| + t.Fatalf("Create(%q): %v", title, err) |
| + } |
| + } |
| + return owner |
| +} |
| + |
| +func titlesOf(docs []*Document) []string { |
| + out := make([]string, 0, len(docs)) |
| + for _, d := range docs { |
| + out = append(out, d.Title) |
| + } |
| + return out |
| +} |
| + |
| +// The order is the feature. Somebody should be able to look at a result |
| +// list and say why it is in that order, which is why the ranks are |
| +// ordered rather than scored. |
| +func TestSearchRanksExactThenPrefixThenWordThenAnywhere(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", |
| + "Determining the notes", // "notes" inside a word boundary, but late |
| + "Notes on the waterworks", // starts with it |
| + "Meeting notes", // word starts with it |
| + "Notes", // exact |
| + ) |
| + |
| + got, err := app.Store().Search(context.Background(), app.TestPerson(t, "writer@example.com"), "notes") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{ |
| + "Notes", |
| + "Notes on the waterworks", |
| + "Determining the notes", |
| + "Meeting notes", |
| + } |
| + // "Determining the notes" and "Meeting notes" are both word-prefix |
| + // matches, so they tie and fall back to most-recently-updated. |
| + // Created earlier means updated earlier, so "Meeting notes" (made |
| + // third) sorts above "Determining the notes" (made first). |
| + want = []string{"Notes", "Notes on the waterworks", "Meeting notes", "Determining the notes"} |
| + if strings.Join(titlesOf(got), " | ") != strings.Join(want, " | ") { |
| + t.Errorf("ranking\n got: %v\nwant: %v", titlesOf(got), want) |
| + } |
| +} |
| + |
| +func TestSearchIsCaseAndPunctuationInsensitive(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + owner := seedTitles(t, app, "writer@example.com", "Packing List — Thursday Site Visit") |
| + |
| + for _, query := range []string{ |
| + "packing", |
| + "PACKING", |
| + "packing list", |
| + "packing list thursday", // the em dash reads as a space |
| + "packing list - thursday", // and so does a hyphen |
| + " packing list ", // and runs of whitespace collapse |
| + } { |
| + got, err := app.Store().Search(context.Background(), owner, query) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 { |
| + t.Errorf("query %q matched %d documents, want 1", query, len(got)) |
| + } |
| + } |
| +} |
| + |
| +// An accent is not silently equated with its unaccented form: doing |
| +// that well across twelve locales is a design, and doing it badly makes |
| +// searches match things a person did not ask for. Stated as a test so |
| +// the behaviour is a decision rather than an accident. |
| +func TestSearchDoesNotFoldAccents(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + owner := seedTitles(t, app, "writer@example.com", "Café notes") |
| + |
| + if got, _ := app.Store().Search(context.Background(), owner, "café"); len(got) != 1 { |
| + t.Errorf(`"café" matched %d, want 1`, len(got)) |
| + } |
| + if got, _ := app.Store().Search(context.Background(), owner, "cafe"); len(got) != 0 { |
| + t.Errorf(`"cafe" matched %d, want 0 — accent folding is deliberately not done`, len(got)) |
| + } |
| +} |
| + |
| +// "No query" is not "match nothing", and it is not "match everything" |
| +// either. The caller decides what an empty query shows; Search says |
| +// nothing. |
| +func TestSearchOfAnEmptyQueryReturnsNothing(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + owner := seedTitles(t, app, "writer@example.com", "One", "Two") |
| + for _, query := range []string{"", " ", "\t"} { |
| + got, err := app.Store().Search(context.Background(), owner, query) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 0 { |
| + t.Errorf("query %q returned %d documents, want 0", query, len(got)) |
| + } |
| + } |
| +} |
| + |
| +// The scoping property every document route has, held against the one |
| +// route that exists to return a LIST of titles — the place where a |
| +// missing scope leaks the most at once. |
| +func TestSearchNeverCrossesOwners(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "owner@example.com", "Secret plans") |
| + stranger := seedTitles(t, app, "stranger@example.com", "My own notes") |
| + |
| + got, err := app.Store().Search(context.Background(), stranger, "secret") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 0 { |
| + t.Fatalf("a stranger found %v", titlesOf(got)) |
| + } |
| + |
| + // And through the HTTP route, which is what is actually reachable. |
| + rec := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/documents/search?q=secret", nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("got %d", rec.Code) |
| + } |
| + if strings.Contains(rec.Body.String(), "Secret plans") { |
| + t.Errorf("the search endpoint leaked another person's title: %s", rec.Body.String()) |
| + } |
| + |
| + // The list page's own search path, too — it is a different handler |
| + // reaching the same store method, and "the store is scoped" is not |
| + // the same claim as "every caller passes the right owner". |
| + page := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/?q=secret", nil)) |
| + if strings.Contains(page.Body.String(), "Secret plans") { |
| + t.Errorf("the list page leaked another person's title") |
| + } |
| +} |
| + |
| +func TestSearchEndpointReturnsJSON(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes", "Packing list") |
| + |
| + rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/documents/search?q=meeting", nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("got %d, want 200", rec.Code) |
| + } |
| + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { |
| + t.Errorf("Content-Type is %q", ct) |
| + } |
| + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Errorf("a person's own titles should not be cached: Cache-Control is %q", cc) |
| + } |
| + |
| + var results []searchResult |
| + if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil { |
| + t.Fatalf("the body is not the JSON the palette expects: %v", err) |
| + } |
| + if len(results) != 1 || results[0].Title != "Meeting notes" { |
| + t.Fatalf("got %+v", results) |
| + } |
| + if !strings.HasPrefix(results[0].Href, "/d/") || results[0].When == "" { |
| + t.Errorf("a result needs a link and a date: %+v", results[0]) |
| + } |
| + |
| + // An empty query is an empty array, not null — the palette |
| + // iterates the result without checking, and `null` would be a |
| + // TypeError in the browser rather than an empty list. |
| + empty := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/documents/search?q=", nil)) |
| + if strings.TrimSpace(empty.Body.String()) != "[]" { |
| + t.Errorf("an empty query returned %q, want []", empty.Body.String()) |
| + } |
| +} |
| + |
| +func TestSearchEndpointRequiresASession(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes") |
| + |
| + req, _ := http.NewRequest(http.MethodGet, "/documents/search?q=meeting", nil) |
| + rec := serve(app, req) |
| + if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "Meeting notes") { |
| + t.Error("the search endpoint served titles to a signed-out request") |
| + } |
| +} |
| + |
| +// The whole feature has to work with no JavaScript (spec §6.2): the |
| +// field is a GET form, so a search is an ordinary page at an ordinary |
| +// URL. |
| +func TestSearchWorksAsAPlainPageRequest(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes", "Packing list", "Waterworks history") |
| + |
| + rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/?q=packing", nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("got %d", rec.Code) |
| + } |
| + body := rec.Body.String() |
| + if !strings.Contains(body, "Packing list") { |
| + t.Error("the matching document is not on the page") |
| + } |
| + if strings.Contains(body, "Waterworks history") { |
| + t.Error("a non-matching document is on the page") |
| + } |
| + // The query is echoed back, so the page can be edited and |
| + // re-submitted rather than retyped. |
| + if !strings.Contains(body, `value="packing"`) { |
| + t.Error("the search field does not hold the query that produced the page") |
| + } |
| + // A search result list is ranked, so it must not also be grouped by |
| + // recency — that would order the same rows twice by two rules. |
| + // |
| + // The RENDERED element, not the bare attribute name: list.html's |
| + // own stylesheet contains "[docs-group-label]", so a substring |
| + // check for the attribute matches every page whether or not a |
| + // single group was rendered. That version of this check passed on |
| + // a page with no groups on it at all. |
| + if strings.Contains(body, "<h2 docs-group-label>") { |
| + t.Error("search results are grouped by recency as well as ranked") |
| + } |
| +} |
| + |
| +func TestSearchWithNoMatchesIsADesignedScreen(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes") |
| + |
| + rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/?q=zzzznothing", nil)) |
| + body := rec.Body.String() |
| + |
| + // Asserted on the rendered structure, not on the English. A direct |
| + // App.Handler() call installs no locale middleware, so rastrillo.T |
| + // returns each key verbatim (render.go's own note on Locales) — |
| + // comparing against Catalog's English fails here for a reason that |
| + // has nothing to do with whether the screen is right. |
| + if !strings.Contains(body, "rst-empty-title") { |
| + t.Error("no empty state for a search that found nothing") |
| + } |
| + if strings.Contains(body, "<div rst-row>") { |
| + t.Error("a search that matched nothing still rendered rows") |
| + } |
| + // And a way back out that does not require knowing the URL. |
| + if !strings.Contains(body, "docs-search-clear") { |
| + t.Error("no way to clear the search") |
| + } |
| +} |
| + |
| +// A title is arbitrary text somebody typed, and it is rendered back |
| +// into an HTML attribute on the results page. |
| +func TestSearchQueryIsEscapedInTheField(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes") |
| + |
| + rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, |
| + `/?q=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E`, nil)) |
| + body := rec.Body.String() |
| + if strings.Contains(body, "<script>alert(1)</script>") { |
| + t.Error("the query was written into the page unescaped") |
| + } |
| +} |
| + |
| +func TestSearchCapsItsResults(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "writer@example.com") |
| + for i := 0; i < maxSearchResults+10; i++ { |
| + if _, err := app.Store().Create(ctx, owner, "Notes"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := app.Store().Search(ctx, owner, "notes") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != maxSearchResults { |
| + t.Errorf("got %d results, want the cap of %d", len(got), maxSearchResults) |
| + } |
| +} |
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html| index d1565f5..f7ea163 100644 |
| --- a/internal/docs/views/list.html |
| +++ b/internal/docs/views/list.html |
| @@ -92,6 +92,21 @@ |
| <h1>{{.Title}}</h1> |
| </div> |
| <div docs-header-actions> |
| + {{/* Search (spec §15.2's title search). A GET form, so a search |
| + is a link — bookmarkable, reloadable, and reachable with the |
| + back button — and so the whole feature works with no |
| + JavaScript at all. fixtures/jump-to.mjs adds a keyboard |
| + shortcut on top of this; it does not replace it. |
| + |
| + type="search" rather than type="text": it gets the platform's |
| + own clear control and its own keyboard on a phone. The value |
| + is echoed back so a result page can be edited and |
| + re-submitted rather than retyped. */}} |
| + <form docs-search-form method="get" action="/" role="search"> |
| + <input rst-input docs-search-input type="search" name="q" value="{{.Query}}" |
| + placeholder="{{.SearchPlace}}" aria-label="{{.SearchLabel}}" autocomplete="off"> |
| + <button rst-btn="ghost" type="submit">{{.SearchLabel}}</button> |
| + </form> |
| <form docs-create-form method="post" action="/documents"> |
| <input rst-input type="text" name="title" placeholder="{{.CreatePlaceholder}}" aria-label="{{.CreateLabel}}" autocomplete="off"> |
| <button rst-btn="primary" type="submit">{{.CreateLabel}}</button> |
| @@ -101,7 +116,29 @@ |
| </form> |
| </div> |
| </header> |
| -{{if .Groups}} |
| +{{/* Searching renders a ranked, UNGROUPED result list. Recency |
| + grouping is what an unfiltered list is for; grouping ranked |
| + results under "Today" and "Earlier" would order the same rows |
| + twice by two different rules and show only the second. */}} |
| +{{if .Searching}} |
| +{{if .Results}} |
| +<div rst-list> |
| + {{range .Results}} |
| + <div rst-row> |
| + <span docs-thumb aria-hidden="true"></span> |
| + <span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span> |
| + <span docs-row-when>{{.When}}</span> |
| + </div> |
| + {{end}} |
| +</div> |
| +{{else}} |
| +<div rst-empty> |
| + <h2 rst-empty-title>{{.NoResultsHead}}</h2> |
| + <p rst-empty-body>{{.NoResultsBody}}</p> |
| +</div> |
| +{{end}} |
| +<p docs-search-clear><a href="/">{{.ClearLabel}}</a></p> |
| +{{else if .Groups}} |
| <div rst-list> |
| {{range .Groups}} |
| <h2 docs-group-label>{{.Label}}</h2> |