From 321961f64fc67f880d5e1c522040e6afe85f8c58 Mon Sep 17 00:00:00 2001 From: MaDnh Date: Thu, 6 Aug 2026 00:06:45 +0700 Subject: [PATCH 1/4] [ui-search] fix(search): cut a hit around its match, so the row contains the word it reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hit's text was cut to 140 runes from the FRONT of the line. Agent prose arrives as one long line, so a word matched at character 500 was not in the row the search returned — a wrong answer wearing the shape of a truncated one. The window now centres on the match, and an ellipsis at either end says the line goes on. Getting there means the store has to know WHERE it matched, so `matcher` grew `find` and `locate`, and `Hit` carries the match's rune offsets. They are published rather than left to each surface to recompute, because recomputing means a second matcher, and a second one disagrees: `--word`'s boundaries are Unicode classes that `\b` does not mean, and folding is on by default here and off in most regex dialects. A caller that highlighted its own idea of the match would draw the box around the wrong characters. Two details that are easy to get wrong, and are tested: - `--word` spells its boundaries as CONSUMING character classes (RE2 has no lookaround), so the reported range comes from submatch 1. Reporting the whole match would hand every caller the spaces either side of the word. - `locate` re-finds against the CLEANED text rather than reusing an offset from the raw line: cleanLine turns a tab into four spaces, which moves every byte after it. It may legitimately fail, and -1 then means "cannot say", not "no match" — the hit stands and only the highlight is dropped. The CLI passes its own 140 as TextWidth instead of truncating afterwards, so `truncateRunes` is gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a --- cmd/scratchpad/pad.go | 19 ++--- internal/store/search.go | 146 ++++++++++++++++++++++++++++++++-- internal/store/search_test.go | 118 +++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 19 deletions(-) diff --git a/cmd/scratchpad/pad.go b/cmd/scratchpad/pad.go index 3ef61d5..6329cbb 100644 --- a/cmd/scratchpad/pad.go +++ b/cmd/scratchpad/pad.go @@ -444,6 +444,10 @@ func newPadReadCmd() *cobra.Command { // searchTextWidth caps how much of a matching line is printed. Agent prose runs to whole // paragraphs on one line, and a search is a way to FIND a section, not to read it — the // ellipsis says the line goes on and `pad read --section` is how to see the rest. +// +// The store takes this window AROUND the match. It used to be applied here, to the front +// of the line, which printed a row that did not contain the word being reported whenever +// a paragraph matched past its 140th character — the common case in a pad. const searchTextWidth = 140 func newPadSearchCmd() *cobra.Command { @@ -499,7 +503,7 @@ func newPadSearchCmd() *cobra.Command { Query: args[0], Project: project, Ref: ref, Password: password, ExcludePads: excludePad, Regexp: asRegexp, Word: word, CaseSensitive: matchCase, Author: author, Kind: kind, Before: beforeTS, After: afterTS, - Oldest: oldest, Limit: limit, + Oldest: oldest, Limit: limit, TextWidth: searchTextWidth, }) if err != nil { return err @@ -521,8 +525,7 @@ func newPadSearchCmd() *cobra.Command { if h.InTitle { where = "title" } - fmt.Fprintf(w, "%s\t§%d\t%s\t%s\t%s\n", h.Ref, h.Section, where, h.Author, - truncateRunes(h.Text, searchTextWidth)) + fmt.Fprintf(w, "%s\t§%d\t%s\t%s\t%s\n", h.Ref, h.Section, where, h.Author, h.Text) } if err := w.Flush(); err != nil { return err @@ -592,16 +595,6 @@ func parseWhen(s string) (int64, error) { return 0, fmt.Errorf("invalid time %q (want a date like 2026-07-01 or an age like 30d)", s) } -// truncateRunes shortens a line for the table without cutting a multi-byte character in -// half — the searches this exists for are frequently not ASCII. -func truncateRunes(s string, max int) string { - r := []rune(s) - if len(r) <= max { - return s - } - return string(r[:max]) + "…" -} - func newPadWaitCmd() *cobra.Command { var ( dir dirFlags diff --git a/internal/store/search.go b/internal/store/search.go index b1186a0..23c5f34 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -10,6 +10,7 @@ import ( "regexp" "sort" "strings" + "unicode/utf8" "golang.org/x/sys/unix" @@ -69,6 +70,13 @@ type SearchRequest struct { // Limit caps the hits RETURNED, not the bytes read: a pad whose scan has begun is // finished, and only the pads after it are skipped. Result.Truncated says the cap bound. Limit int + + // TextWidth caps a hit's line in RUNES, 0 for the whole line. The cut is taken AROUND + // the match rather than from the start of the line, which is the only cut that works + // on the prose this searches: an agent writes a paragraph as one line, and a word + // matched at character 500 is not in the first 140 of them. Cutting from the front + // therefore produced a result row that did not contain the word it was reporting. + TextWidth int } // Hit is one matching line, addressed the way the rest of the tool addresses things: by @@ -89,6 +97,17 @@ type Hit struct { // index a person reads a long pad by, so a search that ignored them would miss the // most deliberate statement of what a section is about. Line is 0 for these. InTitle bool `json:"in_title,omitempty"` + + // MatchStart and MatchEnd locate the first match INSIDE Text, in runes, half-open. + // Both are -1 when the match could not be located there (see matcher.locate). + // + // They are published rather than left to each caller to recompute because + // recomputing means a SECOND matcher, and a second one disagrees: --word spells its + // boundaries with Unicode classes that \b does not mean, and case folding is on by + // default here and off in most regex dialects. A surface that highlighted its own + // idea of the match would draw a box around the wrong characters. + MatchStart int `json:"match_start"` + MatchEnd int `json:"match_end"` } // SearchResult is what a search found, plus what it did NOT look at. Skipped and @@ -112,6 +131,14 @@ type SearchResult struct { type matcher struct { lit []byte re *regexp.Regexp + + // group says the pattern was wrapped in a capturing group to carry --word's + // boundaries, so what MATCHED is submatch 1 rather than the whole match. The + // boundaries are consuming character classes (RE2 has no lookaround), and they were + // harmless while the result was only ever a boolean — the moment a caller highlights + // the range, reporting them as part of the match paints the space either side of the + // word. + group bool } func newMatcher(req SearchRequest) (matcher, error) { @@ -126,10 +153,12 @@ func newMatcher(req SearchRequest) (matcher, error) { if !req.Regexp { expr = regexp.QuoteMeta(q) } + group := false if req.Word { - // No lookaround in RE2, so the boundaries are consuming character classes. That is - // harmless here: the result is only ever used as a boolean. - expr = `(?:^|[^\p{L}\p{N}_])(?:` + expr + `)(?:[^\p{L}\p{N}_]|$)` + // The capturing group opens BEFORE any group the caller's own pattern contains, + // so submatch 1 is always this one — never theirs. + expr = `(?:^|[^\p{L}\p{N}_])((?:` + expr + `))(?:[^\p{L}\p{N}_]|$)` + group = true } if !req.CaseSensitive { expr = `(?i)` + expr @@ -138,7 +167,7 @@ func newMatcher(req SearchRequest) (matcher, error) { if err != nil { return matcher{}, fmt.Errorf("bad search pattern: %w", err) } - return matcher{re: re}, nil + return matcher{re: re, group: group}, nil } func (m matcher) match(b []byte) bool { @@ -148,6 +177,52 @@ func (m matcher) match(b []byte) bool { return bytes.Contains(b, m.lit) } +// find returns the byte range of the first match in b. It is kept apart from match +// because match is the hot path — every line of every pad goes through it — while this +// runs only for the lines that already matched. +func (m matcher) find(b []byte) (start, end int, ok bool) { + if m.re == nil { + i := bytes.Index(b, m.lit) + if i < 0 { + return 0, 0, false + } + return i, i + len(m.lit), true + } + loc := m.re.FindSubmatchIndex(b) + if loc == nil { + return 0, 0, false + } + if m.group && len(loc) >= 4 && loc[2] >= 0 { + return loc[2], loc[3], true + } + return loc[0], loc[1], true +} + +// locate returns the first match's RUNE range inside s, or (-1, -1). +// +// It runs against the CLEANED text rather than reusing an offset taken from the raw +// line, because cleanLine rewrites the line: a tab becomes four spaces, which moves +// every byte after it. Re-finding on the string the caller will actually display is +// what keeps the two in step. It can legitimately fail — a pattern that matches a tab +// no longer matches the spaces it became — and -1 then means "cannot say", not "no +// match": the hit stands, only the highlight is dropped. +func (m matcher) locate(s string) (int, int) { + bs, be, ok := m.find([]byte(s)) + if !ok { + return -1, -1 + } + return utf8.RuneCountInString(s[:bs]), utf8.RuneCountInString(s[:be]) +} + +// excerptOf turns a matching line into what a hit carries: the text to show and where +// the match sits in it. Locating and cutting are one step because they are one decision +// — the cut is taken around the match — and splitting them across the two call sites +// below is how a title hit and a body hit would end up measured differently. +func (m matcher) excerptOf(s string, width int) (string, int, int) { + start, end := m.locate(s) + return excerpt(s, start, end, width) +} + // padHits groups one pad's hits so the result can be ordered by the pad's last activity, // the way `pad list` orders pads — a search across a store is read newest conversation // first, not alphabetically. @@ -372,9 +447,11 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad if !selects(sec) || !m.match(line) { return } + text, ms, me := m.excerptOf(cleanLine(line), req.TextWidth) hits = append(hits, Hit{ Ref: ref, Section: sec.N, Author: sec.Author, Title: sec.Title, - Kind: sec.Kind, TS: sec.TS, Line: lineNo, Text: cleanLine(line), + Kind: sec.Kind, TS: sec.TS, Line: lineNo, Text: text, + MatchStart: ms, MatchEnd: me, }) }) if err != nil { @@ -394,9 +471,14 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad if !selects(&sec) || !m.match([]byte(sec.Title)) { continue } + // A title is not cut. It is one short line by rule, and it is the thing a person + // scans a result list by — trimming it to a window would hide the very statement + // that made the section worth returning. + text, ms, me := m.excerptOf(sec.Title, 0) hits = append(hits, Hit{ Ref: ref, Section: sec.N, Author: sec.Author, Title: sec.Title, - Kind: sec.Kind, TS: sec.TS, Text: sec.Title, InTitle: true, + Kind: sec.Kind, TS: sec.TS, Text: text, InTitle: true, + MatchStart: ms, MatchEnd: me, }) } sort.SliceStable(hits, func(i, j int) bool { @@ -413,3 +495,55 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad func cleanLine(line []byte) string { return strings.TrimRight(strings.ReplaceAll(string(line), "\t", " "), "\r") } + +// ellipsis marks an excerpt that starts or ends mid-line. One rune, so the offsets it +// shifts move by exactly one. +const ellipsis = "…" + +// excerpt cuts s down to width runes AROUND the match at [start, end), returning the +// text and the match's range within it. +// +// Centring on the match is the whole point: an agent's paragraph arrives as a single +// line, and the answer to "where was this said" is usually in the middle of it. A cut +// taken from the front returns a row that does not contain the word being reported, +// which reads as a wrong result rather than as a truncated one. +// +// A match that is itself wider than the window is shown from its start — cutting it in +// half would be the same failure one level down. start < 0 means the match could not be +// located, and the line is then cut from the front, since there is no better place. +func excerpt(s string, start, end, width int) (string, int, int) { + if width <= 0 { + return s, start, end + } + r := []rune(s) + if len(r) <= width { + return s, start, end + } + if start < 0 { + return string(r[:width]) + ellipsis, -1, -1 + } + + // Centre the window on the match, then push it back inside the line. The clamp order + // matters: pinning to the end first and to the start second keeps `from` at 0 for a + // line shorter than the window, which the length check above has already excluded but + // which a later change could reintroduce. + from := start - max(0, (width-(end-start))/2) + from = min(from, len(r)-width) + from = max(from, 0) + to := min(from+width, len(r)) + + out := string(r[from:to]) + ns, ne := start-from, min(end, to)-from + if from > 0 { + out = ellipsis + out + ns++ + ne++ + } + if to < len(r) { + out += ellipsis + } + // A match running past the window is reported only as far as the text goes; the + // range must never point outside the string a caller is about to highlight. + ne = min(ne, len([]rune(out))) + return out, ns, max(ns, ne) +} diff --git a/internal/store/search_test.go b/internal/store/search_test.go index e6cd9c5..5b2f826 100644 --- a/internal/store/search_test.go +++ b/internal/store/search_test.go @@ -425,3 +425,121 @@ func TestSearchExcludePad(t *testing.T) { t.Fatal("an unparsable --exclude-pad must be refused") } } + +// A match is REPORTED WHERE IT IS, and the cut is taken around it. Agent prose arrives +// as one long line, so a window applied to the front of it returned a row that did not +// contain the word being reported — a wrong answer wearing the shape of a truncated one. +func TestSearchExcerptIsCutAroundTheMatch(t *testing.T) { + s := testStore(t) + line := strings.Repeat("a", 500) + " needle " + strings.Repeat("b", 500) + ref := writePadAt(t, s, "projectx", "longline", "erp", "Long", + line+"\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "needle", TextWidth: 60}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 { + t.Fatalf("want one hit in %s, got %+v", ref, res.Hits) + } + h := res.Hits[0] + if !strings.Contains(h.Text, "needle") { + t.Fatalf("the excerpt does not contain the word it reports: %q", h.Text) + } + if n := len([]rune(h.Text)); n > 62 { + t.Errorf("excerpt is %d runes, want the 60-rune window plus at most two ellipses", n) + } + // The offsets must point at the word inside the text that is actually returned. + r := []rune(h.Text) + if h.MatchStart < 0 || h.MatchEnd > len(r) || string(r[h.MatchStart:h.MatchEnd]) != "needle" { + t.Fatalf("offsets %d..%d do not select the match in %q", h.MatchStart, h.MatchEnd, h.Text) + } + // Cut on both sides, so both ellipses are there. + if !strings.HasPrefix(h.Text, "…") || !strings.HasSuffix(h.Text, "…") { + t.Errorf("a line cut at both ends must say so: %q", h.Text) + } +} + +// The offsets survive a multi-byte line: they are RUNE indices, and a Vietnamese pad is +// the ordinary case here rather than the exotic one. +func TestSearchOffsetsAreRunesNotBytes(t *testing.T) { + s := testStore(t) + writePadAt(t, s, "projectx", "nonascii", "erp", "Tiêu đề", + "phần điều tra về địa chỉ hai cấp\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "địa chỉ"}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 { + t.Fatalf("want one hit, got %+v", res.Hits) + } + h := res.Hits[0] + r := []rune(h.Text) + if got := string(r[h.MatchStart:h.MatchEnd]); got != "địa chỉ" { + t.Fatalf("offsets %d..%d gave %q, want %q", h.MatchStart, h.MatchEnd, got, "địa chỉ") + } +} + +// --word's boundaries are consuming character classes (RE2 has no lookaround), so the +// reported range must come from the submatch. Reporting the whole match would hand every +// caller a range covering the space either side of the word. +func TestSearchWordOffsetsExcludeTheBoundaries(t *testing.T) { + s := testStore(t) + writePadAt(t, s, "projectx", "wordy", "erp", "Word", + "the cache is warm\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "cache", Word: true}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 { + t.Fatalf("want one hit, got %+v", res.Hits) + } + h := res.Hits[0] + r := []rune(h.Text) + if got := string(r[h.MatchStart:h.MatchEnd]); got != "cache" { + t.Fatalf("--word reported %q as the match; the boundaries must not be part of it", got) + } +} + +// A title is never cut: it is one short line by rule and it is what a person scans the +// result list by. +func TestSearchTitleHitCarriesOffsetsAndIsNotCut(t *testing.T) { + s := testStore(t) + title := "Decision about the retry budget that runs on and on and on and on and on and on" + writePadAt(t, s, "projectx", "titled", "erp", title, + "body says nothing\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "budget", TextWidth: 20}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 || !res.Hits[0].InTitle { + t.Fatalf("want one title hit, got %+v", res.Hits) + } + h := res.Hits[0] + if h.Text != title { + t.Errorf("a title must not be cut by TextWidth, got %q", h.Text) + } + r := []rune(h.Text) + if string(r[h.MatchStart:h.MatchEnd]) != "budget" { + t.Errorf("offsets %d..%d do not select the match in the title", h.MatchStart, h.MatchEnd) + } +} + +// TextWidth 0 is the whole line, which is what a caller with room asks for. +func TestSearchTextWidthZeroKeepsTheWholeLine(t *testing.T) { + s := testStore(t) + line := strings.Repeat("a", 300) + " needle" + writePadAt(t, s, "projectx", "whole", "erp", "Long", + line+"\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "needle"}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 || res.Hits[0].Text != line { + t.Fatalf("TextWidth 0 must keep the line intact, got %+v", res.Hits) + } +} From 04e09d50d59af33312ee0e40e36cc286299a39bd Mon Sep 17 00:00:00 2001 From: MaDnh Date: Thu, 6 Aug 2026 00:06:59 +0700 Subject: [PATCH 2/4] =?UTF-8?q?[ui-search]=20feat(ui):=20GET=20/api/search?= =?UTF-8?q?=20=E2=80=94=20the=20store's=20content=20search,=20over=20HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three scopes a person asks for are not three endpoints: neither `project` nor `ref` is the whole store, `project` is one project, `ref` is one pad. `ref` is also the only form that carries a password, exactly as `--pad` is in the CLI. What differs from the CLI, and why: - The password comes from the SESSION that already unlocked that pad, never from the request, so it cannot end up in history or in a link someone copies. `sess.unlocked` is an exact-ref lookup, so anything else fails closed. - `limit` has a ceiling. `--limit 0` at a terminal is a person waiting for their own command; over HTTP it is a request to serialise the store into one response. - `before`/`after` are unix seconds, not "30d". The age spelling belongs where a person types the bound; a browser knows the clock, and a wire format that parses prose is one more place for two surfaces to disagree about what "a month ago" means. A store-wide search still skips protected pads and still says which — that list is handed straight through, because an empty result must never be readable as "the word is nowhere in the store". A misspelled flag is refused rather than read as false: a filter that silently turns itself off answers a question nobody asked. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a --- internal/webui/api.go | 133 ++++++++++++++++++++++++ internal/webui/webui.go | 3 + internal/webui/webui_test.go | 190 +++++++++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+) diff --git a/internal/webui/api.go b/internal/webui/api.go index 45c54c9..c970509 100644 --- a/internal/webui/api.go +++ b/internal/webui/api.go @@ -3,6 +3,7 @@ package webui import ( "encoding/json" "net/http" + "net/url" "strconv" "strings" "time" @@ -665,6 +666,138 @@ func (s *Server) handleSetPadRules(r *http.Request, sess *session) (any, error) return s.store.PadRules(ref, password) } +// Search bounds. The CLI's `--limit 0` means "no cap", which is safe at a terminal +// where the person waits for their own command; over HTTP it is a way to ask the server +// to serialise the whole store into one response, so this surface always has a ceiling. +const ( + defaultSearchLimit = 100 + maxSearchLimit = 500 + + // searchTextWidth is how much of a matching line travels, in runes. Wider than the + // CLI's 140 because a browser has room to wrap, still a window rather than the line: + // a hit is a place to go, not the section itself. + searchTextWidth = 320 +) + +// handleSearch is the one read here that selects by WHAT WAS WRITTEN. The three scopes a +// person asks for are not three endpoints — they are which of `project` and `ref` is set: +// neither is the whole store, `project` is one project, `ref` is one pad. +// +// A protected pad is reached exactly the way the CLI reaches one: by being NAMED, with +// its password. The difference is only where the password comes from — the session that +// already unlocked it, rather than a flag typed again. Without `ref` the store skips +// every protected pad and says which, and this hands that list straight to the UI: an +// empty result must never be readable as "the word is nowhere in the store". +func (s *Server) handleSearch(r *http.Request, sess *session) (any, error) { + q := r.URL.Query() + query := strings.TrimSpace(q.Get("q")) + if query == "" { + return nil, badInput("q is required: there is nothing to search for") + } + + limit, err := queryInt(q, "limit", defaultSearchLimit) + if err != nil { + return nil, err + } + if limit <= 0 { + return nil, badInput("limit must be a positive integer") + } + + before, err := queryInt64(q, "before", 0) + if err != nil { + return nil, err + } + after, err := queryInt64(q, "after", 0) + if err != nil { + return nil, err + } + + flags := map[string]bool{} + for _, name := range []string{"oldest", "regexp", "word", "case"} { + v, err := queryBool(q, name) + if err != nil { + return nil, err + } + flags[name] = v + } + + ref := q.Get("ref") + req := store.SearchRequest{ + Query: query, + Project: q.Get("project"), + Ref: ref, + // Only a NAMED pad carries a password, which is the store's rule and not a + // simplification made here: a store-wide search that read through every pad this + // session happens to have unlocked would be a way to read one noun at a time. + Password: sess.unlocked(ref), + ExcludePads: q["exclude"], + Author: q.Get("author"), + Kind: q.Get("kind"), + Before: before, + After: after, + Oldest: flags["oldest"], + Regexp: flags["regexp"], + Word: flags["word"], + CaseSensitive: flags["case"], + Limit: min(limit, maxSearchLimit), + TextWidth: searchTextWidth, + } + + res, err := s.store.Search(req) + if err != nil { + return nil, err + } + if res.Hits == nil { + res.Hits = []store.Hit{} + } + return res, nil +} + +// queryBool reads a flag written either way a URL spells one: `?word` on its own, or +// `?word=true`. A value that is neither is refused rather than read as false — a +// misspelled flag that silently turns a filter off returns a result to a question +// nobody asked. +func queryBool(q url.Values, name string) (bool, error) { + if !q.Has(name) { + return false, nil + } + v := q.Get(name) + if v == "" { + return true, nil + } + b, err := strconv.ParseBool(v) + if err != nil { + return false, badInput(name + " must be true or false") + } + return b, nil +} + +func queryInt(q url.Values, name string, def int) (int, error) { + if q.Get(name) == "" { + return def, nil + } + n, err := strconv.Atoi(q.Get(name)) + if err != nil { + return 0, badInput(name + " must be an integer") + } + return n, nil +} + +// queryInt64 reads a unix timestamp. Seconds, not "30d": the age spelling belongs to the +// CLI, where a person types the bound themselves — a browser knows the clock and can +// subtract, and a wire format that parses prose is one more place for the two surfaces +// to disagree about what "a month ago" means. +func queryInt64(q url.Values, name string, def int64) (int64, error) { + if q.Get(name) == "" { + return def, nil + } + n, err := strconv.ParseInt(q.Get(name), 10, 64) + if err != nil { + return 0, badInput(name + " must be a unix timestamp in seconds") + } + return n, nil +} + // badInput builds the store-shaped error for a malformed request parameter, so the // UI sees the same code vocabulary the CLI and MCP use. func badInput(msg string) error { diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 1be70b8..e3a6eb3 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -166,6 +166,9 @@ func (s *Server) handler() http.Handler { mux.HandleFunc("GET /api/pads/{ref}/sections/{n}/preview", s.api(s.handleSectionPreview)) mux.HandleFunc("GET /api/pads/{ref}/tasks", s.api(s.handleTasks)) mux.HandleFunc("GET /api/stuck", s.api(s.handleStuck)) + // Content search. GET because it reads — which also makes a search a URL, and a URL + // is what lets a person send someone else the question rather than the answer. + mux.HandleFunc("GET /api/search", s.api(s.handleSearch)) mux.HandleFunc("POST /api/pads/{ref}/unlock", s.api(s.handleUnlock)) mux.HandleFunc("DELETE /api/pads/{ref}", s.api(s.handleDelete)) diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go index 20e71e8..c7dd255 100644 --- a/internal/webui/webui_test.go +++ b/internal/webui/webui_test.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -958,3 +959,192 @@ func TestListingSeparatesLastWriterFromTurnHolder(t *testing.T) { t.Fatalf("last writer %q / turn holder %q, want %q / alice", got.LastAuthor, got.TurnAuthor, store.SystemAuthor) } } + +// searchResponse mirrors what the endpoint returns, which is store.SearchResult itself. +type searchResponse struct { + Hits []struct { + Ref string `json:"ref"` + Section int `json:"section"` + Author string `json:"author"` + Line int `json:"line"` + Text string `json:"text"` + InTitle bool `json:"in_title"` + MatchStart int `json:"match_start"` + MatchEnd int `json:"match_end"` + } `json:"hits"` + Skipped []string `json:"skipped"` + Truncated bool `json:"truncated"` + Scanned int `json:"scanned"` +} + +func TestSearchScopes(t *testing.T) { + srv, ts, client, st := newTestServer(t) + authenticate(t, srv, ts, client) + + a, _, err := createPad(st, "alpha", "erp", "Alpha pad", "the retry budget is per pad", false) + if err != nil { + t.Fatal(err) + } + if _, _, err := createPad(st, "beta", "ios", "Beta pad", "the retry budget again", false); err != nil { + t.Fatal(err) + } + + // Whole store: both projects answer. + var all searchResponse + if code := getJSON(t, client, ts.URL+"/api/search?q=retry+budget", &all); code != http.StatusOK { + t.Fatalf("store-wide search gave %d", code) + } + if len(all.Hits) != 2 { + t.Fatalf("want a hit in each project, got %+v", all.Hits) + } + + // One project. + var one searchResponse + if code := getJSON(t, client, ts.URL+"/api/search?q=retry+budget&project=alpha", &one); code != http.StatusOK { + t.Fatalf("project search gave %d", code) + } + if len(one.Hits) != 1 || one.Hits[0].Ref != a.Ref() { + t.Fatalf("project scope must narrow to alpha, got %+v", one.Hits) + } + + // One pad. + var pad1 searchResponse + if code := getJSON(t, client, ts.URL+"/api/search?q=retry&ref="+a.Ref(), &pad1); code != http.StatusOK { + t.Fatalf("pad search gave %d", code) + } + if len(pad1.Hits) != 1 || pad1.Scanned != 1 { + t.Fatalf("pad scope must read exactly one pad, got %d hits over %d pads", len(pad1.Hits), pad1.Scanned) + } +} + +// The offsets the store computes must survive the wire: they are what the page +// highlights with, and recomputing them in JavaScript would be a second matcher. +func TestSearchCarriesMatchOffsets(t *testing.T) { + srv, ts, client, st := newTestServer(t) + authenticate(t, srv, ts, client) + + if _, _, err := createPad(st, "alpha", "erp", "Alpha", "phần điều tra về địa chỉ hai cấp", false); err != nil { + t.Fatal(err) + } + var res searchResponse + if code := getJSON(t, client, ts.URL+"/api/search?q="+url.QueryEscape("địa chỉ"), &res); code != http.StatusOK { + t.Fatalf("search gave %d", code) + } + if len(res.Hits) == 0 { + t.Fatal("want a hit") + } + h := res.Hits[0] + r := []rune(h.Text) + if h.MatchStart < 0 || h.MatchEnd > len(r) || string(r[h.MatchStart:h.MatchEnd]) != "địa chỉ" { + t.Fatalf("offsets %d..%d do not select the match in %q", h.MatchStart, h.MatchEnd, h.Text) + } +} + +// A protected pad is not searched by a store-wide query, and the fact that it was left +// out travels with the result. Named WITH the session's unlock, it opens. +func TestSearchProtectedPadNeedsTheSessionUnlock(t *testing.T) { + srv, ts, client, st := newTestServer(t) + authenticate(t, srv, ts, client) + + secret, pw, err := createPad(st, "alpha", "erp", "Secret", "the codeword is hyacinth", true) + if err != nil { + t.Fatal(err) + } + if pw == "" { + t.Fatal("a protected pad must return its password") + } + + var wide searchResponse + if code := getJSON(t, client, ts.URL+"/api/search?q=hyacinth", &wide); code != http.StatusOK { + t.Fatalf("store-wide search gave %d", code) + } + if len(wide.Hits) != 0 { + t.Fatalf("a protected pad's content leaked into a store-wide search: %+v", wide.Hits) + } + if len(wide.Skipped) != 1 || wide.Skipped[0] != secret.Ref() { + t.Fatalf("the skipped pad must be reported, got %+v", wide.Skipped) + } + + // Named but not unlocked in this session: refused, not silently empty. + if code := getJSON(t, client, ts.URL+"/api/search?q=hyacinth&ref="+secret.Ref(), nil); code != http.StatusForbidden { + t.Fatalf("searching a locked pad gave %d, want 403", code) + } + + // Unlock, then the same request answers. + body := strings.NewReader(`{"password":` + strconv.Quote(pw) + `}`) + req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/pads/"+secret.Ref()+"/unlock", body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", ts.URL) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("unlock gave %d", resp.StatusCode) + } + + var opened searchResponse + if code := getJSON(t, client, ts.URL+"/api/search?q=hyacinth&ref="+secret.Ref(), &opened); code != http.StatusOK { + t.Fatalf("search after unlock gave %d", code) + } + if len(opened.Hits) != 1 { + t.Fatalf("the session's unlock must open the pad to a search, got %+v", opened.Hits) + } +} + +// The ceiling is this surface's own: the CLI's "0 means no cap" would let one request +// ask for the whole store in one response. +func TestSearchLimitIsBounded(t *testing.T) { + srv, ts, client, st := newTestServer(t) + authenticate(t, srv, ts, client) + + p, _, err := createPad(st, "alpha", "erp", "Alpha", "needle", false) + if err != nil { + t.Fatal(err) + } + for n := 2; n <= 6; n++ { + author := "erp" + if n%2 == 0 { + author = "ios" + } + if _, err := st.Post(store.PostRequest{ + Ref: p.Ref(), Author: author, Title: fmt.Sprintf("s%d", n), Content: "needle", + }); err != nil { + t.Fatal(err) + } + } + + var res searchResponse + if code := getJSON(t, client, ts.URL+"/api/search?q=needle&limit=2", &res); code != http.StatusOK { + t.Fatalf("limited search gave %d", code) + } + if len(res.Hits) != 2 || !res.Truncated { + t.Fatalf("want 2 hits marked truncated, got %d truncated=%v", len(res.Hits), res.Truncated) + } + if code := getJSON(t, client, ts.URL+"/api/search?q=needle&limit=0", nil); code != http.StatusBadRequest { + t.Fatalf("limit=0 gave %d, want 400 — no unbounded response over HTTP", code) + } +} + +// An empty query is refused rather than answered with everything. +func TestSearchNeedsAQuery(t *testing.T) { + srv, ts, client, _ := newTestServer(t) + authenticate(t, srv, ts, client) + if code := getJSON(t, client, ts.URL+"/api/search?q=+", nil); code != http.StatusBadRequest { + t.Fatalf("a blank q gave %d, want 400", code) + } +} + +// A misspelled flag is refused, not read as "off": a filter that silently turns itself +// off answers a question nobody asked. +func TestSearchRejectsABadFlag(t *testing.T) { + srv, ts, client, _ := newTestServer(t) + authenticate(t, srv, ts, client) + if code := getJSON(t, client, ts.URL+"/api/search?q=x&word=yep", nil); code != http.StatusBadRequest { + t.Fatalf("word=yep gave %d, want 400", code) + } +} From 79ebbcbffabe9a18702473df81004f8d3088de5d Mon Sep 17 00:00:00 2001 From: MaDnh Date: Thu, 6 Aug 2026 00:07:14 +0700 Subject: [PATCH 3/4] =?UTF-8?q?[ui-search]=20chore(vendor):=20puredashboar?= =?UTF-8?q?d=20=E2=86=92=205d9d4f9,=20for=20the=20label-id=20fix=20a=20for?= =?UTF-8?q?m=20here=20needs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every form-associated component mirrors a wrapping