commit 6a2d30aadecbfabc6d245ef240c7d1590ecad68c equwal <truex@equwal.com> 2026-09-22 19:22:03 -0700 Search while you type on the bookmarks page The results change while you type, and Enter opens the first result, as in the sbm add-on. Ctrl+Enter opens it in a new tab. The script search.js gets the page of the search from the server and shows its results, so the search stays in one place: the server. Without the script, the form works as before. The Content-Security-Policy now allows scripts and fetch from this server only.
README.md | 12 +++++++---- bookmarks.go | 12 +++++++++++ main.go | 5 ++++- pages.html | 11 ++++++---- search.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web_test.go | 39 +++++++++++++++++++++++++++++++++--- 6 files changed, 132 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md index b879797..317e39d 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,11 @@ sync), nothing is removed: the result is the union of both files. After you sign in, the page **/bookmarks** shows your bookmarks in any browser, also on a phone. It does what bm does: -- Search as fzf does, with the search of the add-on. Pick a tag. Sort by - date, URL, description or tag. Text that no bookmark matches opens as an - address, or as a web search. +- Search as fzf does, with the search of the add-on. The results change + while you type, and Enter opens the first result (Ctrl+Enter: in a new + tab), as in the add-on. Pick a tag. Sort by date, URL, description or + tag. Text that no bookmark matches opens as an address, or as a web + search. - Add a bookmark. A URL that is a bookmark already is refused. - Edit or delete a bookmark. - Edit the whole file, as `bm -e` does. @@ -53,7 +55,9 @@ browser, also on a phone. It does what bm does: - Download the file. Each change goes through the same merge as a sync, so the changes that your -devices made since the page opened stay. The pages have no script. +devices made since the page opened stay. The only script is `search.js`, +for the search while you type: it gets the results from the server. Without +it, the search form works too. The pages obey the rules of sync: the email address must be confirmed, and with billing on, the account needs its trial or a subscription. The diff --git a/bookmarks.go b/bookmarks.go index c5834b9..7f25370 100644 --- a/bookmarks.go +++ b/bookmarks.go @@ -5,6 +5,7 @@ package main // devices made since a page opened stay. import ( + _ "embed" "errors" "fmt" "html" @@ -34,6 +35,11 @@ var ( errChanged = errors.New("the bookmark changed on another device") ) +// searchJS is the live search of the bookmark pages. +// +//go:embed search.js +var searchJS string + // sentence gives the text of an error as a sentence for a page. func sentence(err error) string { return strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "." @@ -735,3 +741,9 @@ func (s *server) download(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Disposition", `attachment; filename="bookmarks.txt"`) io.WriteString(w, text) } + +// script gives the live search of the bookmark pages. +func (s *server) script(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + io.WriteString(w, searchJS) +} diff --git a/main.go b/main.go index 890ddda..2002511 100644 --- a/main.go +++ b/main.go @@ -178,9 +178,12 @@ func (s *server) routes() http.Handler { m.HandleFunc("POST /bookmarks/file", s.saveFile) m.HandleFunc("POST /bookmarks/import", s.importFile) m.HandleFunc("GET /bookmarks.txt", s.download) + m.HandleFunc("GET /search.js", s.script) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() - h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; "+ + // Scripts only from this server: the live search of the bookmark + // pages gets its results with fetch. + h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; "+ "form-action 'self' https://checkout.stripe.com https://billing.stripe.com; "+ "frame-ancestors 'none'; base-uri 'none'") h.Set("Referrer-Policy", "same-origin") diff --git a/pages.html b/pages.html index b53f75d..3b5d4c7 100644 --- a/pages.html +++ b/pages.html @@ -133,8 +133,8 @@ their bookmark files.</p> <h1>Your bookmarks</h1> {{with .Error}}<p class="error">{{.}}</p>{{end}} {{with .Done}}<p><b>{{.}}</b></p>{{end}} -<form method="get" action="/bookmarks"> -<input type="search" name="q" value="{{.Query}}" placeholder="Search, or type an address" aria-label="Search"> +<form id="search" method="get" action="/bookmarks"> +<input type="search" name="q" value="{{.Query}}" placeholder="Search, or type an address" aria-label="Search" autocomplete="off" autofocus> {{with .Tag}}<input type="hidden" name="tag" value="{{.}}">{{end}} <label>Order <select name="sort"> <option value="recent"{{if eq .Sort "recent"}} selected{{end}}>newest first</option> @@ -159,16 +159,19 @@ works too. URLs that are bookmarks already are skipped.</p> <label>File <input type="file" name="file" required></label> <button>Import</button> </form></details> +<div id="results"> <p class="note">{{.Found}} of {{.Total}} bookmarks{{with .Tag}} with the tag <b>{{.}}</b>{{end}}.{{if gt .Found (len .Rows)}} This page shows the first {{len .Rows}}.{{end}}{{if or .Query .Tag}} <a href="/bookmarks">Show all</a>{{end}}</p> -{{with .Target}}<p>No bookmark matches. Go to <a href="{{.}}">{{.}}</a></p>{{end}} +{{with .Target}}<p>No bookmark matches. Go to <a href="{{.}}" class="open">{{.}}</a></p>{{end}} <ul class="bookmarks"> -{{range .Rows}}<li>{{if .Link}}<a href="{{.Link}}">{{.Title}}</a>{{else}}{{.Title}}{{end}}<br> +{{range .Rows}}<li>{{if .Link}}<a href="{{.Link}}" class="open">{{.Title}}</a>{{else}}{{.Title}}{{end}}<br> <small>{{.Host}}{{range .Tags}} · <a href="/bookmarks?tag={{.}}">{{.}}</a>{{end}} · <a href="/bookmarks/edit?v={{$.Version}}&n={{.N}}">edit</a></small></li> {{end}}</ul> +</div> {{with .Tags}}<h2>Tags</h2> <p>{{range .}}<a href="/bookmarks?tag={{.Name}}">{{.Name}}</a> ({{.N}}) {{end}}</p>{{end}} +<script src="/search.js" defer></script> {{template "foot" .}}{{end}} {{define "edit"}}{{template "head" .}} diff --git a/search.js b/search.js new file mode 100644 index 0000000..d14f084 --- /dev/null +++ b/search.js @@ -0,0 +1,65 @@ +// Live search for the bookmark pages of sbm Sync: the results change while +// you type, and Enter opens the first result, as in the sbm add-on. +// Ctrl+Enter opens it in a new tab. The server does the search: this script +// gets the page of the search and shows its results. Without this script the +// form still works. +"use strict"; + +(() => { + const form = document.getElementById("search"); + const results = document.getElementById("results"); + if (!form || !results) return; + const box = form.elements.q; + const clears = results.dataset.empty === "clear"; // no results for no query + let timer = null; // the update that waits for a pause in the typing + let running = Promise.resolve(); // the newest update + let newest = 0; // the number of the newest update + + // update shows the results that the server gives for the form. + async function update() { + const n = ++newest; + const query = new URLSearchParams(new FormData(form)).toString(); + let fresh = null; + if (!clears || box.value.trim() !== "") { + try { + const resp = await fetch("/bookmarks?" + query); + if (!resp.ok) return; + const page = new DOMParser().parseFromString(await resp.text(), "text/html"); + fresh = page.getElementById("results"); + } catch { + return; + } + if (fresh === null) return; // not signed in any more + } + if (n !== newest) return; // a newer update is on its way + results.replaceChildren(...(fresh === null ? [] : fresh.childNodes)); + if (!clears) history.replaceState(null, "", "?" + query); + } + + function later() { + clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + running = update(); + }, 150); + } + + box.addEventListener("input", later); + form.addEventListener("change", (e) => { + if (e.target !== box) later(); + }); + box.addEventListener("keydown", async (e) => { + if (e.key !== "Enter" || e.isComposing) return; + e.preventDefault(); + if (timer !== null) { + clearTimeout(timer); + timer = null; + running = update(); + } + await running; + const first = results.querySelector("a.open"); + if (first === null) form.submit(); + else if (e.ctrlKey || e.metaKey) window.open(first.href, "_blank", "noopener"); + else location.assign(first.href); + }); +})(); diff --git a/web_test.go b/web_test.go index c8b588f..51054fe 100644 --- a/web_test.go +++ b/web_test.go @@ -86,7 +86,7 @@ func editForm(t *testing.T, page, title string) url.Values { return nil } -var rowTitle = regexp.MustCompile(`<li>(?:<a href="[^"]*">)?([^<]*)`) +var rowTitle = regexp.MustCompile(`<li>(?:<a href="[^"]*"[^>]*>)?([^<]*)`) // titles gives the titles of the bookmarks on the page, in their order. func titles(page string) []string { @@ -271,7 +271,7 @@ func TestSearchTagsAndOrders(t *testing.T) { if page := e.body(c, "/bookmarks?q=posix+sh"); !strings.Contains(page, "No bookmark matches.") || !strings.Contains(page, "https://duckduckgo.com/?q=posix") { t.Errorf("no web search for text that no bookmark matches:\n%s", page) } - if page := e.body(c, "/bookmarks?q=example.org/x"); !strings.Contains(page, `Go to <a href="https://example.org/x">`) { + if page := e.body(c, "/bookmarks?q=example.org/x"); !strings.Contains(page, `Go to <a href="https://example.org/x" class="open">`) { t.Errorf("no link for an address that no bookmark matches:\n%s", page) } } @@ -288,7 +288,7 @@ func TestBookmarkPagesEscapeTheFile(t *testing.T) { if !strings.Contains(page, "<script>alert(2)</script>") { t.Error("the page does not show the description as text") } - if !strings.Contains(page, `<a href="https://www.example.org">No scheme</a>`) { + if !strings.Contains(page, `<a href="https://www.example.org" class="open">No scheme</a>`) { t.Error("an address without a scheme does not get https") } } @@ -432,3 +432,36 @@ func TestOtherSiteCannotChangeBookmarks(t *testing.T) { t.Errorf("another site changed the file: %q", got) } } + +func TestLiveSearch(t *testing.T) { + e := newEnv(t, false) + c, _, _ := e.account("https://a.org\tA\t\ngemini://b.org\tB\t\n") + resp, err := c.Get(e.web.URL + "/bookmarks") + if err != nil { + t.Fatal(err) + } + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + // Scripts come only from this server, and fetch only from it. + csp := resp.Header.Get("Content-Security-Policy") + if !strings.Contains(csp, "script-src 'self';") || !strings.Contains(csp, "connect-src 'self';") || strings.Contains(csp, "unsafe-eval") { + t.Errorf("Content-Security-Policy: %q", csp) + } + // The script finds the form, the results, and the links that Enter opens. + // A bookmark without a web address has no such link. + for _, want := range []string{`<form id="search" `, `<div id="results">`, `<a href="https://a.org" class="open">A</a>`, + `<li>B<br>`, `<script src="/search.js" defer></script>`} { + if !strings.Contains(string(b), want) { + t.Errorf("the page has no %q", want) + } + } + resp, err = e.browser().Get(e.web.URL + "/search.js") + if err != nil { + t.Fatal(err) + } + b, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "text/javascript; charset=utf-8" || string(b) != searchJS { + t.Errorf("GET /search.js: %s %q, %d bytes", resp.Status, resp.Header.Get("Content-Type"), len(b)) + } +}