Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


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}}&amp;n={{.N}}">edit</a></small></li>
 {{end}}</ul>
+</div>
 {{with .Tags}}<h2>Tags</h2>
 <p>{{range .}}<a href="/bookmarks?tag={{.Name}}">{{.Name}}</a>&nbsp;({{.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, "&lt;script&gt;alert(2)&lt;/script&gt;") {
 		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))
+	}
+}