oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3// Finding a document by its title (spec §15.2's "title and metadata
4// search ships"). Full-text search is still unbuilt and still needs a
5// design compatible with §12's plaintext-grep gate; nothing here reads
6// a document's content.
7
8import (
9 "context"
10 "encoding/json"
11 "net/http"
12 "sort"
13 "strings"
14 "time"
15 "unicode"
16
17 "amadan.net/rastrillo/rastrillo"
18 "golang.org/x/text/unicode/norm"
19)
20
21// maxSearchResults caps what one query returns.
22//
23// The list this filters is one person's own documents, so there is no
24// unbounded corpus behind it — the cap is about the response and the
25// palette's own rendering, not about protecting the query. A person
26// with four hundred documents matching "notes" is not helped by four
27// hundred rows, and the ones past the first few dozen are ranked below
28// everything they were more likely to mean.
29const maxSearchResults = 50
30
31// searchRank is how well a title matches a query. Lower is better, and
32// the values are ordered rather than scored: the point is that somebody
33// can look at a result order and say why it is that order, which a
34// tuned numeric score does not give you.
35type searchRank int
36
37const (
38 rankExact searchRank = iota // the title IS the query
39 rankPrefix // the title starts with it
40 rankWordPrefix // some word in the title starts with it
41 rankContains // it appears somewhere
42 rankNone // it does not appear
43)
44
45// rankTitle scores one title against an already-normalised query.
46func rankTitle(title, query string) searchRank {
47 t := normaliseForSearch(title)
48 switch {
49 case t == query:
50 return rankExact
51 case strings.HasPrefix(t, query):
52 return rankPrefix
53 }
54 if !strings.Contains(t, query) {
55 return rankNone
56 }
57 // A word boundary is worth distinguishing from a match inside a
58 // word: someone typing "min" means "Minutes", not "Determining".
59 for _, word := range strings.Fields(t) {
60 if strings.HasPrefix(word, query) {
61 return rankWordPrefix
62 }
63 }
64 return rankContains
65}
66
67// normaliseForSearch lowercases and collapses whitespace, and strips
68// the punctuation that varies between how a title was typed and how it
69// is remembered — an em dash for a hyphen, a curly apostrophe for a
70// straight one. Deliberately NOT a full Unicode fold: "café" and "cafe"
71// stay different, because guessing at accent equivalence across twelve
72// locales (render.go's Locales) is a design and not a detail.
73//
74// It DOES normalise to NFC first, and that is a different question from
75// the folding above rather than a softening of it. Folding is about two
76// different strings a person might want treated as one. NFC is about
77// one string with two encodings — "café" as U+00E9, and "café" as "e"
78// followed by U+0301 — which nobody has ever wanted treated as two.
79// Without it a title typed on a machine that produces decomposed text
80// (macOS's filesystem, several IMEs) is unfindable from a machine that
81// produces precomposed text, and the person searching sees an empty
82// result for a title they are looking straight at.
83//
84// Applied here rather than at either call site so the title and the
85// query go through the same function — normalising only one side fixes
86// exactly one direction and looks fixed from whichever side you tested.
87// NFC and not NFD because it is the shorter form and the web's own
88// default; the choice does not matter as long as it is the same on both
89// sides, which sharing this function is what guarantees.
90func normaliseForSearch(s string) string {
91 var b strings.Builder
92 b.Grow(len(s))
93 lastSpace := true
94 for _, r := range strings.ToLower(norm.NFC.String(s)) {
95 switch {
96 case unicode.IsSpace(r) || r == '—' || r == '–' || r == '-' || r == '_':
97 if !lastSpace {
98 b.WriteRune(' ')
99 lastSpace = true
100 }
101 case r == '\'' || r == '’':
102 // Dropped rather than spaced: "don't" and "don t" should
103 // not be different words.
104 default:
105 b.WriteRune(r)
106 lastSpace = false
107 }
108 }
109 return strings.TrimSpace(b.String())
110}
111
112// Search returns owner's documents whose titles match query, best
113// first, capped at maxSearchResults. An empty or whitespace-only query
114// returns nil rather than everything — "no query" is not "match
115// nothing", and the caller decides what to show for it.
116//
117// The filtering happens here, in Go, over the rows List already loads,
118// rather than in SQL. See the plan's D1: document titles are due to be
119// sealed (spec §2), at which point a `WHERE title LIKE ?` stops
120// matching anything and does so silently, while a pass over one
121// person's decrypted titles keeps working unchanged. It also means the
122// owner scope is the one List already applies, rather than a second
123// WHERE clause somebody has to remember to write.
124func (s *Store) Search(ctx context.Context, owner int64, query string) ([]*Document, error) {
125 q := normaliseForSearch(query)
126 if q == "" {
127 return nil, nil
128 }
129 docs, err := s.List(ctx, owner)
130 if err != nil {
131 return nil, err
132 }
133 return rankAndTrim(docs, q), nil
134}
135
136// SearchFor is Search over everything the acting person may read: theirs,
137// plus what has been shared with them. It sits beside Search for the same
138// reason ListFor sits beside List.
139//
140// It exists at all because Search runs over a list rather than over SQL,
141// so widening only the list page would have shipped an app where a shared
142// document is on the list and cannot be searched for. That reads as a
143// broken search, not as a scope decision, and it is the sort of split a
144// person hits on their second day rather than their first.
145//
146// Both spellings run the same ranking, so the list page, the palette and
147// this cannot drift — the plan's D3, kept while the scope widened.
148func (s *Store) SearchFor(ctx context.Context, a Actor, query string) ([]*Document, error) {
149 q := normaliseForSearch(query)
150 if q == "" {
151 return nil, nil
152 }
153 docs, err := s.ListFor(ctx, a)
154 if err != nil {
155 return nil, err
156 }
157 return rankAndTrim(docs, q), nil
158}
159
160// rankAndTrim is the ranking both spellings share: keep the titles that
161// match, best rank first, capped at maxSearchResults.
162//
163// It takes documents rather than a scope so that the scope decision lives
164// in exactly one place per caller — the List or ListFor above it — and
165// cannot be half-applied here.
166func rankAndTrim(docs []*Document, q string) []*Document {
167 type scored struct {
168 doc *Document
169 rank searchRank
170 }
171 var hits []scored
172 for _, d := range docs {
173 if r := rankTitle(d.Title, q); r != rankNone {
174 hits = append(hits, scored{doc: d, rank: r})
175 }
176 }
177
178 // SliceStable, and the list arrives newest-first: that makes "most
179 // recently updated" the tie-break without re-reading a clock or
180 // comparing timestamps here.
181 sort.SliceStable(hits, func(i, j int) bool { return hits[i].rank < hits[j].rank })
182
183 if len(hits) > maxSearchResults {
184 hits = hits[:maxSearchResults]
185 }
186 out := make([]*Document, 0, len(hits))
187 for _, h := range hits {
188 out = append(out, h.doc)
189 }
190 return out
191}
192
193// searchResult is one row of the JSON the palette reads.
194type searchResult struct {
195 Title string `json:"title"`
196 Href string `json:"href"`
197 When string `json:"when"`
198}
199
200// searchJSON serves GET /documents/search?q=… for fixtures/jump-to.mjs.
201//
202// The palette queries this rather than filtering a downloaded list of
203// every title, so there is exactly one ranking implementation and it
204// cannot drift from the one the list page uses (the plan's D3). It also
205// means no bulk export of a person's titles to the browser.
206//
207// Inside app.go's RequireSession group and reader-scoped through
208// Search → List, so a document belonging to someone else cannot appear
209// here — the same property every other document route has, by the same
210// mechanism rather than by a check of its own.
211func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) {
212 who, err := h.actor(r)
213 if err != nil {
214 h.logger.Error("docs: resolve signed-in person", "err", err)
215 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
216 return
217 }
218
219 docs, err := h.store.SearchFor(r.Context(), who, r.URL.Query().Get("q"))
220 if err != nil {
221 h.logger.Error("docs: search documents", "err", err)
222 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
223 return
224 }
225
226 now := time.Now().UTC()
227 results := make([]searchResult, 0, len(docs))
228 for _, d := range docs {
229 results = append(results, searchResult{
230 Title: d.Title,
231 Href: "/d/" + d.Sqid,
232 When: formatWhen(now, d.UpdatedAt),
233 })
234 }
235
236 // Marshal before writing anything: a mid-encode failure must not
237 // leave a half-written 200 on the wire, the same reason
238 // renderer.page renders into a buffer first.
239 body, err := json.Marshal(results)
240 if err != nil {
241 h.logger.Error("docs: encode search results", "err", err)
242 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
243 return
244 }
245
246 if h.partialCatalog(r) != "" {
247 w.Header().Set("Docs-Catalog-Partial", "1")
248 }
249 w.Header().Set("Content-Type", "application/json; charset=utf-8")
250 // A person's own document titles, and they change as they type.
251 w.Header().Set("Cache-Control", "no-store")
252 if err := writeGuardedBody(w, r, 0, body); err != nil {
253 h.logger.Error("docs: write search results", "err", err)
254 }
255}
256
257// jumpToView is the palette's copy, resolved from the catalog per
258// request. Both the list and a document carry it, so it lives here
259// with the rest of search rather than on either page's own view data.
260type jumpToView struct {
261 Partial string
262 Label string
263 Placeholder string
264 Hint string
265 Empty string
266 Failed string
267 Close string
268}
269
270func newJumpToView(r *http.Request) jumpToView {
271 return jumpToView{
272 Partial: rastrillo.T(r, "docs.team.partial"),
273 Label: rastrillo.T(r, "docs.jumpto.label"),
274 Placeholder: rastrillo.T(r, "docs.jumpto.placeholder"),
275 Hint: rastrillo.T(r, "docs.jumpto.hint"),
276 Empty: rastrillo.T(r, "docs.jumpto.empty"),
277 Failed: rastrillo.T(r, "docs.jumpto.failed"),
278 Close: rastrillo.T(r, "docs.jumpto.close"),
279 }
280}
281