Recently Written · git

sbm

dmenu bookmarks. Instantly fuzzy search thousands of bookmarks and plumb them. LOOKING FOR SUCKLESS SOFTWARE EDITION? GO TO sbm-suckless INSTEAD

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

Log | Files | Refs


commit 65906dd853842c8b739b45c0f54f3f07783a4f95
map588 <map588@pitt.edu>
2026-09-20 23:25:44 -0400

Split bm into small POSIX tools; browse, preview, web search

bm keeps the bookmark file and the menu: open, copy, add, edit, delete,
list, merge. The rest are filters over bookmark lines, installed à la
carte (TOOLS in config.mk):

  bm-import bookmarks.html | bm --merge     was --import
  bm --list | bm-check -                    was --check
  bm-html > bookmarks.html                  new: a searchable web page
  bm-migrate                                was --migrate; never
                                            overwrites an earlier .bak
  bm-title, bm-commit                       used by bm when installed

Plain "bm" browses. dmenu cannot bind keys or preview, so a second menu
shows the chosen bookmark in full and asks what to do; fzf gets a live
preview. Text that is no bookmark is opened as an address or searched on
the web, with engine keywords from an engines file.

POSIX sh and POSIX utilities only: noclobber for mktemp, a FIFO of
tokens for xargs -P, dd for head -c, awk srand() for date +%s.
make check: shellcheck -s sh and 159 scripted tests.

 Makefile      |  17 +-
 README        | 204 +++++++++++------
 TODO          |  16 +-
 bm            | 700 ++++++++++++++++++++++++----------------------------------
 bm-check      |  70 ++++++
 bm-commit     |  36 +++
 bm-html       | 105 +++++++++
 bm-import     | 109 +++++++++
 bm-migrate    | 120 ++++++++++
 bm-title      |  33 +++
 config.mk     |  11 +-
 engines       |   5 +
 test/fakefzf  |  27 ++-
 test/fakemenu |   2 +
 test/run.sh   | 461 +++++++++++++++++++++++++++++++-------
 15 files changed, 1327 insertions(+), 589 deletions(-)
diff --git a/Makefile b/Makefile
index bb5bfff..42449fb 100644
--- a/Makefile
+++ b/Makefile
@@ -1,11 +1,14 @@
 include config.mk
 
+SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit
+TESTS = test/run.sh test/fakemenu test/fakefzf
+
 all:
 	@echo Nothing to build: use make install, make uninstall, make check, or make dist.
 
 check:
 	@if command -v shellcheck >/dev/null 2>&1; then \
-		shellcheck -s sh bm test/run.sh test/fakemenu test/fakefzf; \
+		shellcheck -s sh ${SCRIPTS} ${TESTS}; \
 	else \
 		echo shellcheck not found: skipping lint; \
 	fi
@@ -14,20 +17,22 @@ check:
 dist:
 	@echo creating dist tarball
 	@mkdir -p sbm-${VERSION}-temp
-	@cp -R Makefile config.mk README TODO bm usertags test sbm-${VERSION}-temp
+	@cp -R Makefile config.mk README TODO ${SCRIPTS} usertags engines test sbm-${VERSION}-temp
 	@mv sbm-${VERSION}-temp sbm-${VERSION}
 	@tar -cf sbm-${VERSION}.tar sbm-${VERSION}
 	@gzip sbm-${VERSION}.tar
 	@rm -rf sbm-${VERSION}
 
 install:
-	@echo installing scripts to ${DESTDIR}${PREFIX}/bin
+	@echo installing ${TOOLS} to ${DESTDIR}${PREFIX}/bin
 	@mkdir -p ${DESTDIR}${PREFIX}/bin
-	@cp bm ${DESTDIR}${PREFIX}/bin
-	@chmod 755 ${DESTDIR}${PREFIX}/bin/bm
+	@for tool in ${TOOLS}; do \
+		cp $$tool ${DESTDIR}${PREFIX}/bin/ && \
+		chmod 755 ${DESTDIR}${PREFIX}/bin/$$tool || exit 1; \
+	done
 
 uninstall:
 	@echo removing scripts
-	rm -f ${DESTDIR}${PREFIX}/bin/bm
+	@for tool in ${SCRIPTS}; do rm -f ${DESTDIR}${PREFIX}/bin/$$tool; done
 
 .PHONY: all check dist install uninstall
diff --git a/README b/README
index cfe1197..30f3842 100644
--- a/README
+++ b/README
@@ -1,4 +1,4 @@
-Manage bookmarks with a menu program and a basic file.
+Manage bookmarks with dmenu and a basic file.
 
 The file is Tab Separated Values, one bookmark per line:
 
@@ -8,25 +8,48 @@ For example:
 
     https://equwal.com	Spenser Truex's website.	users
 
-Lines starting with # are ignored. Interact with the file using the bm
-script, or any other tool: cut, grep, awk and your editor all work.
+Lines starting with # are ignored. bm is one way to work with the file;
+cut, grep, awk and your editor are others.
+
+== Design ==
+bm owns the bookmark file and talks to the menu. Everything else is a small
+filter that reads or writes bookmark lines, so the pieces combine like any
+other Unix tools, and you install only the ones you use:
+
+    bm          pick, open, copy, add, edit, delete, list, merge
+    bm-import   browser bookmarks        -> bookmark lines
+    bm-check    bookmark lines           -> the dead links among them
+    bm-html     bookmark lines           -> a searchable web page
+    bm-migrate  the old sbm file format  -> bookmark lines
+    bm-title    a URL                    -> the page's title
+    bm-commit   commit the bookmark file to git
+
+    bm-import bookmarks.html | bm --merge
+    bm --tag code --list | bm-check -
+    bm --list | grep -i wiki | bm-html -t Wikis - > wikis.html
+
+Everything is POSIX sh and POSIX utilities: no bash, no mktemp, no GNU
+options. make check runs shellcheck -s sh over all of it. Where the usual
+tool is not in POSIX, the script says what stands in for it: noclobber for
+mktemp, a FIFO of tokens for xargs -P, dd for head -c, awk's srand() for
+date +%s.
 
 == Requirements ==
-dmenu (or fzf), one of xclip, xsel or wl-clipboard, a POSIX sh, awk, make.
-Optional: curl (page titles, --check), jq (--import of Chromium files), git.
+dmenu (or fzf), one of xclip, xsel or wl-clipboard, sh, awk, make.
+curl for bm-title and bm-check, jq for bm-import of Chromium files, git for
+bm-commit.
 
 == Install ==
-    make install
-
-installs bm to /usr/local/bin. To install somewhere you own instead:
+Edit TOOLS in config.mk to choose what gets installed, then
 
+    make install                        # to /usr/local/bin
     make PREFIX="$HOME/.local" install
 
 == Setup ==
 Nothing is required. Bookmarks are kept in ~/.local/share/sbm/bookmarks
 ($XDG_DATA_HOME is honoured) and the file is created on first use.
 
-Tag choices are read from ~/.local/share/sbm/usertags. The file syntax is:
+Tag choices are read from ~/.local/share/sbm/usertags:
 
     <tag><space>|<space><description><newline>
 
@@ -34,59 +57,75 @@ Start from the included example, or from nothing: a tag typed into the tag
 menu that is not in the file yet is added to it.
 
     mkdir -p ~/.local/share/sbm
-    cp usertags ~/.local/share/sbm/
-
-To keep the files somewhere else, export their locations from your shell's
-startup file. Use $HOME here: a ~ inside quotes is not expanded.
-
-    export BOOKMARKS="$HOME/notes/bookmarks"
-    export USERTAGS="$HOME/notes/usertags"
+    cp usertags engines ~/.local/share/sbm/
 
 == Usage ==
-    bm                   dmenu: prompt for an action. fzf: browse (see below)
-    bm -c, --copy        copy a bookmark's URL to the clipboard
-    bm -o, --open        open a bookmark in the default browser
+    bm                   browse (see below)
+    bm -o, --open        open a bookmark, an address, or a web search
                          (-p, --plumb is the same)
+    bm -c, --copy        copy a bookmark's URL to the clipboard
+    bm --print           print a bookmark's URL
     bm -a, --add [url]   add a bookmark
-    bm -e, --edit        open the bookmark file in $VISUAL/$EDITOR at a bookmark
+    bm -e, --edit        open the file in $VISUAL/$EDITOR at a bookmark
     bm -d, --delete      delete a bookmark
     bm -l, --list        print the bookmarks
-    bm -s, --search ERE  print the bookmarks matching an extended regex
-    bm --import SRC      import bookmarks from a browser (see below)
-    bm --check           report dead and permanently moved links
-    bm --migrate         convert a file from the old format (see below)
-    bm -h, --help
-
+    bm -m, --merge       add the bookmark lines on stdin, skipping known ones
     -t, --tag TAG        restrict any of the above to one tag
-    -S, --sort MODE      used (default), recent, url, desc or tag
-    --print              with --copy or --open, print the URL instead
+    -S, --sort ORDER     used (default), recent, url, desc or tag
+
+Bind "bm" and "bm -a" to keys in your window manager.
+
+== Browsing ==
+Plain "bm" lists the bookmarks. Pick one and a second menu shows it in full
+(dmenu cannot preview while you scroll, so this is its preview) and asks what
+to do:
 
-== The menu ==
-With a display bm uses dmenu, as it always has; fzf is only used when dmenu
-is missing or there is no X11 or Wayland display. SBM_MENU=dmenu or
-SBM_MENU=fzf settles it either way.
+    open
+    copy
+    edit
+    delete
+      https://suckless.org/
+      software that sucks less
+      tags: code org
+      used: 12
 
-With dmenu, tags are chosen one at a time until "done" is picked or the menu
-is closed. With fzf they are chosen all at once: TAB marks a tag, ENTER
-confirms. In both, typing a tag that does not exist creates it.
+Closing the second menu goes back to the list, closing the list leaves.
+[add] at the top of the list adds a bookmark.
 
-With fzf, plain "bm" is one screen for everything:
+With fzf (no display, or SBM_MENU=fzf) it is one screen: the bookmark under
+the cursor is previewed live, and keys do the rest.
 
     ENTER   open            ctrl-a  add            ctrl-t  filter by tag
     ctrl-y  copy            ctrl-d  delete (asks)  ctrl-s  next sort order
     ESC     leave           ctrl-e  edit
 
+== Web search ==
+In the list, and in "bm -o", text that is not a bookmark is not wasted:
+
+    suckless.org/dwm          one word with a dot or "://": opened as an address
+    posix sh printf           anything else: searched for on the web
+    w dynamic menu            first word is a keyword: that engine is used
+
+So one key gives you bookmarks first and the web behind them. The default
+engine is DuckDuckGo; set another with SBM_SEARCH='https://.../?q=%s'.
+Keywords are read from ~/.local/share/sbm/engines:
+
+    <keyword><space>|<space><url with %s><newline>
+
+See the included engines file.
+
 == Adding ==
-Without a url, bm offers one: the X11 primary selection, or the clipboard,
-when it holds a URL.
+Without a url, bm offers the X11 primary selection, or the clipboard, when
+it holds a URL. With bm-title installed the description defaults to the
+page's title (curl, 3 second limit; SBM_FETCH=0 turns it off). In dmenu the
+default is the one entry in the menu: Return accepts it, Shift-Return sends
+exactly what you typed.
 
-The description defaults to the page's <title>, fetched with curl (3 second limit; SBM_FETCH=0 turns this off). With fzf,
-ENTER accepts the default. With dmenu the default is the one entry in the
-menu: Return accepts it, and Shift-Return sends exactly what you typed.
+Tags are chosen one at a time until "done" is picked or the menu is closed
+(fzf: TAB marks, ENTER confirms). Typing a tag that does not exist creates it.
 
-Adding refuses a URL that is already bookmarked. URLs are compared without
-their scheme, a leading www. and trailing slashes, so http://www.example.com/
-and https://example.com are the same bookmark.
+A URL that is already bookmarked is refused (exit status 3). URLs are
+compared without their scheme, a leading www. and trailing slashes.
 
 == Sorting ==
     used     most opened or copied first, then by url (the default)
@@ -96,24 +135,53 @@ and https://example.com are the same bookmark.
 Set a default with SBM_SORT. Use counts live in "$BOOKMARKS.usage", so the
 bookmark file itself stays a plain list.
 
+== A web page of your bookmarks ==
+    bm-html > ~/public_html/bookmarks.html
+
+One self-contained file: no server side, nothing loaded from elsewhere.
+Bookmarks are listed under each of their tags, with the tags linked at the
+top. A dozen lines of inline script add a filter box; without script the page
+is still the complete list, and Ctrl-F works. page.html?q=words starts out
+filtered, so the page can be a search keyword in your browser, or in bm:
+
+    b | https://example.org/bookmarks.html?q=%s
+
+Links that would run script (javascript:, data:) are never written.
+
 == Importing ==
-    bm --import bookmarks.html      # the HTML export of any browser
-    bm --import vivaldi             # or chrome, brave, edge, chromium
-    bm --import path/to/Bookmarks   # a Chromium JSON file (needs jq)
+    bm-import bookmarks.html | bm --merge     # the HTML export of any browser
+    bm-import vivaldi | bm --merge            # or chrome, brave, edge, chromium
+    bm-import path/to/Bookmarks | bm --merge  # a Chromium JSON file (needs jq)
+
+Folder names become tags ("Dev Tools" becomes dev-tools). bm --merge skips
+what you already have, so importing twice adds nothing. Look before you
+merge: bm-import prints plain bookmark lines.
 
-Folder names become tags ("Dev Tools" becomes dev-tools), bookmarks you
-already have are skipped, and anything that is not a web, ftp or file URL is
-dropped. Importing twice adds nothing.
+== Dead links ==
+    bm-check
+
+prints status<tab>url for links that fail, and status<tab>url<tab>new-url for
+ones that moved for good. It exits 1 if it printed anything.
 
 == History and sync with git ==
-If the directory holding the bookmark file is itself a git repository, every
-add, delete, edit, import and migrate is committed. bm never pushes or pulls.
+With bm-commit installed, every change bm makes is committed, provided the
+directory holding the bookmark file is a git repository of its own. It never
+pushes or pulls.
 
     cd ~/.local/share/sbm && git init
-    printf '%s\n' '*.usage' '*.bak' > .gitignore
+    printf '%s\n' '*.usage' '*.bak*' > .gitignore
+
+A file that merely sits inside a bigger repository (your dotfiles) is left
+alone unless SBM_GIT=1. SBM_GIT=0 turns commits off.
 
-A bookmark file that merely sits somewhere inside a bigger repository (your
-dotfiles, say) is left alone unless SBM_GIT=1. SBM_GIT=0 turns commits off.
+== Migrating from the old format ==
+Earlier versions wrote "URL description | tag tag".
+
+    bm-migrate                  # $BOOKMARKS, or the default file
+    bm-migrate -n file          # only show what it would become
+    bm-migrate - < old > new    # as a filter
+
+The original is kept as .bak, and an existing .bak is never overwritten.
 
 == Configuration ==
 All through the environment, all optional.
@@ -124,36 +192,28 @@ All through the environment, all optional.
     SBM_MENU      dmenu, fzf, or a custom command
     SBM_FZF_OPTS  extra options for every fzf call
     SBM_SORT      default sort order
+    SBM_SEARCH    default search engine, with %s for the query
+    SBM_ENGINES   search keywords file
     SBM_COPY      command that reads the new clipboard contents from stdin
     SBM_PASTE     command that writes the clipboard contents to stdout
     SBM_OPEN      command that opens the URL given as its argument
-    SBM_FETCH     0 to never download a page for its title
-    SBM_GIT       0 never commit, 1 commit even inside a bigger repository
-
-The clipboard is the first of wl-copy, xclip and xsel that exists, and URLs
-are opened with xdg-open.
 
-A custom SBM_MENU is called with the choices on stdin as one of:
+With a display bm uses dmenu; fzf is only used when dmenu is missing or
+there is no X11 or Wayland display. A custom SBM_MENU is called with the
+choices on stdin as one of:
 
-    <command> pick  <prompt>     print the chosen line
+    <command> pick  <prompt>     print the chosen line, or the typed text
     <command> multi <prompt>     print every chosen line
     <command> ask   <prompt>     print free text; stdin holds a default, if any
 
 Printing nothing means the user cancelled. test/fakemenu is a small example.
 
-== Migrating from the old format ==
-Earlier versions wrote "URL description | tag tag". Convert such a file with
-
-    bm --migrate
-
-The original is kept next to it with a .bak suffix, and lines that are
-already tab separated are left alone.
-
 == Tests ==
     make check
 
-runs shellcheck, when installed, and test/run.sh. The tests script dmenu,
-fzf and curl, so they need no display and no network.
+runs shellcheck, when installed, and test/run.sh, under sh. Run the tests
+under another shell with SBM_SH=dash sh test/run.sh. They script dmenu, fzf
+and curl, so they need no display and no network.
 
 == Credits ==
 Some ideas taken from Karl Voit:
diff --git a/TODO b/TODO
index bfcc436..cc69375 100644
--- a/TODO
+++ b/TODO
@@ -1,17 +1,19 @@
 - [X] Don't copy comments, just the bookmark.
 - [X] Be able to add bookmarks with a prompter that shows the other bookmarks.
-      (fzf: ctrl-a from the list; dmenu still asks for the action first)
 
 PREVENT DUPLICATES
 - [X] prevent duplicates via http(s) difference
 - [X] prevent duplicates via www subdomain
 - [X] Allow any whitespace as a separator (except newline)
-      (the file is now tab separated; bm --migrate converts old files)
+      (the file is now tab separated; bm-migrate converts old files)
 
 - [X] User-defined tags, and new ones typed into the tag menu
 - [X] make the use of xclip configurable (other programs exist)
-- [X] fzf as a second menu, wl-clipboard and xsel as clipboards
-- [X] open, edit, delete, list, search, filter by tag, sort
-- [X] import from browsers, dead link check, git history
-- figure out a dmenu websearch that utilizes bookmarks too
-- one-screen browsing for dmenu (it cannot bind keys; maybe a second menu)
+- [X] a dmenu websearch that utilizes bookmarks too
+      (text that is no bookmark is an address or a web search; engines file)
+- [X] one-screen browsing for dmenu (a second menu: details and actions)
+- [X] bookmark preview (fzf: live; dmenu: the second menu)
+- [X] a personal web page of the bookmarks, with search (bm-html)
+- [X] split into small tools; POSIX sh and POSIX utilities only
+- tag filter and sort order from inside the dmenu list
+- man pages
diff --git a/bm b/bm
index 33e248d..52c7718 100755
--- a/bm
+++ b/bm
@@ -1,29 +1,23 @@
 #!/bin/sh
 
-# sbm: manage bookmarks with a menu program and a basic TSV file.
-# Bookmark lines are: URL<tab>description<tab>tag tag tag
+# bm: bookmarks in a plain file, picked with dmenu (or fzf).
 #
-# Everything below is optional configuration through the environment:
-#   BOOKMARKS    bookmark file    (default: $XDG_DATA_HOME/sbm/bookmarks)
-#   USERTAGS     tag choices file (default: $XDG_DATA_HOME/sbm/usertags)
-#   SBM_MENU     fzf, dmenu, or a custom command (see README)
-#   SBM_FZF_OPTS extra options for every fzf call
-#   SBM_SORT     default sort: used, recent, url, desc or tag
-#   SBM_COPY     command reading the clipboard contents from stdin
-#   SBM_PASTE    command writing the clipboard contents to stdout
-#   SBM_OPEN     command opening a URL given as its argument
-#   SBM_FETCH    0 to never download a page for its title
-#   SBM_GIT      0 to never commit, 1 to commit even in a shared repository
+#     URL<tab>description<tab>tag tag tag
+#
+# bm owns that file and nothing else. Importing, link checking, the web page
+# and so on are separate bm-* filters; see README.
 
 DMENULINES=${DMENULINES:-15}
 DATADIR=${XDG_DATA_HOME:-$HOME/.local/share}/sbm
 BOOKMARKS=${BOOKMARKS:-$DATADIR/bookmarks}
 USERTAGS=${USERTAGS:-$DATADIR/usertags}
+ENGINES=${SBM_ENGINES:-$DATADIR/engines}
+SEARCH=${SBM_SEARCH:-https://duckduckgo.com/?q=%s}
 USAGE=$BOOKMARKS.usage
 TAB=$(printf '\t')
 
-# awk function shared by everything that compares URLs: drops the scheme, a
-# leading www. and trailing slashes, and lowercases the host.
+# Two URLs are the same bookmark when they differ only in scheme, a leading
+# www., trailing slashes or the case of the host.
 AWK_NORM='
 function norm(u,    host, rest, i) {
     sub(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "", u)
@@ -36,21 +30,6 @@ function norm(u,    host, rest, i) {
     return host rest
 }'
 
-# awk functions for text taken out of HTML.
-AWK_HTML='
-function decode(s) {
-    gsub(/&lt;/, "<", s); gsub(/&gt;/, ">", s); gsub(/&quot;/, "\"", s)
-    gsub(/&#39;|&#x27;|&apos;/, "\047", s); gsub(/&nbsp;/, " ", s)
-    gsub(/&amp;/, "\\&", s)
-    gsub(/[ \t\r]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s)
-    return s
-}
-function slug(s) {
-    s = tolower(s)
-    gsub(/[^a-z0-9]+/, "-", s); sub(/^-/, "", s); sub(/-$/, "", s)
-    return s
-}'
-
 die () {
     printf 'bm: %s\n' "$*" >&2
     exit 1
@@ -62,33 +41,28 @@ have () {
 
 usage () {
     cat <<'EOF'
-usage: bm [options] [action]
-Without an action: browse the bookmarks (fzf), or be prompted for an action.
-Actions:
-  -c, --copy          copy a bookmark's URL to the clipboard
-  -o, --open          open a bookmark (-p, --plumb is an alias)
-  -a, --add [url]     add a bookmark; the url defaults to the selection or
-                      the clipboard
-  -e, --edit          open $BOOKMARKS in $EDITOR at a bookmark
-  -d, --delete        delete a bookmark
-  -l, --list          print bookmarks
-  -s, --search <ere>  print bookmarks matching an extended regex
-      --import <src>  import a browser export: a Netscape HTML or Chromium
-                      JSON file, or one of vivaldi chrome brave edge chromium
-      --check         report dead and permanently moved links
-      --migrate       convert an old "url desc | tags" file to TSV
-  -h, --help          show this help
-Options:
-  -t, --tag <tag>     only consider bookmarks with this tag
-  -S, --sort <mode>   used (default), recent, url, desc or tag
-      --print         with --copy or --open: print the URL instead
+usage: bm [-t tag] [-S order] [action]
+Without an action: browse. Pick a bookmark, then what to do with it. Text
+that is no bookmark is opened as an address, or searched for on the web.
+  -o, --open        open a bookmark, an address or a web search
+                    (-p, --plumb is the same)
+  -c, --copy        copy a bookmark's URL to the clipboard
+      --print       print a bookmark's URL
+  -a, --add [url]   add a bookmark; the url defaults to the selection
+  -e, --edit        open the bookmark file in $EDITOR at a bookmark
+  -d, --delete      delete a bookmark
+  -l, --list        print the bookmarks
+  -m, --merge       add the bookmarks on stdin, skipping the known ones
+  -t, --tag <tag>   only consider bookmarks with this tag
+  -S, --sort <o>    used (default), recent, url, desc or tag
+  -h, --help
 EOF
 }
 
 # ---- external programs: menu, clipboard, opener ----
 
-# dmenu whenever there is a display to draw it on, as always. fzf stands in
-# on a bare terminal, or when dmenu is not installed.
+# dmenu whenever there is a display to draw it on. fzf stands in on a bare
+# terminal, or when dmenu is not installed.
 pickmenu () {
     [ -n "$SBM_MENU" ] && return
     if [ -n "$DISPLAY$WAYLAND_DISPLAY" ]; then
@@ -117,36 +91,42 @@ fzfcmd () {
     fzf -i --reverse --height=40% $SBM_FZF_OPTS "$@"
 }
 
-# pick <prompt>: choices on stdin, the chosen line on stdout.
+# fzfpick <fzf options>: the chosen line, or else what was typed, which is
+# what dmenu gives by itself.
+fzfpick () {
+    out=$(fzfcmd --print-query "$@")
+    chosen=$(printf '%s\n' "$out" | sed -n 2p)
+    printf '%s\n' "${chosen:-$(printf '%s\n' "$out" | sed -n 1p)}"
+}
+
+# pick <prompt>: choices on stdin; the chosen line, or the typed text.
 pick () {
     case $SBM_MENU in
-        fzf)   fzfcmd --prompt="$1 " ;;
+        fzf)   fzfpick --prompt="$1 " ;;
         dmenu) dmenu -i -l "$DMENULINES" -p "$1" ;;
         *)     custommenu pick "$1" ;;
     esac
 }
 
-# pickmulti <prompt>: like pick, but any number of lines can be chosen. Text
-# that is not among the choices may come back too: that is a new tag.
+# pickmulti <prompt>: like pick, for any number of lines.
 pickmulti () {
+    choices=$(cat)
     case $SBM_MENU in
         fzf)
-            choices=$(cat)
             while :; do
                 out=$(printf '%s\n' "$choices" | fzfcmd --multi --print-query \
                       --prompt="$1 " --header='TAB marks, ENTER confirms. No match: ENTER creates the typed tag, ESC finishes.')
-                query=$(printf '%s\n' "$out" | sed -n 1p)
                 chosen=$(printf '%s\n' "$out" | sed 1d)
                 if [ -n "$chosen" ]; then
                     printf '%s\n' "$chosen"
                     break
                 fi
-                [ -n "$query" ] || break
-                printf '%s\n' "$query"
+                typed=$(printf '%s\n' "$out" | sed -n 1p)
+                [ -n "$typed" ] || break
+                printf '%s\n' "$typed"
             done
             ;;
         dmenu)
-            choices=$(cat)
             while :; do
                 one=$(printf 'done | (choose when done)\n%s\n' "$choices" \
                       | dmenu -i -l "$DMENULINES" -p "$1")
@@ -155,7 +135,7 @@ pickmulti () {
                 printf '%s\n' "$one"
             done
             ;;
-        *)  custommenu multi "$1" ;;
+        *)  printf '%s\n' "$choices" | custommenu multi "$1" ;;
     esac
 }
 
@@ -163,11 +143,7 @@ pickmulti () {
 ask () {
     case $SBM_MENU in
         fzf)
-            if [ -n "$2" ]; then
-                printf '%s [%s]: ' "$1" "$2" >&2
-            else
-                printf '%s: ' "$1" >&2
-            fi
+            printf '%s%s: ' "$1" "${2:+ [$2]}" >&2
             IFS= read -r reply || reply=
             printf '%s\n' "${reply:-$2}"
             ;;
@@ -190,9 +166,8 @@ clip_out () {
     fi
 }
 
-# Never fatal: an unreadable clipboard only means there is no default URL.
-# Under X11 the primary selection comes first, as it always has, and the
-# clipboard second; add takes the first line that looks like a URL.
+# The primary selection first, then the clipboard. Never fatal: without
+# either there is just no URL to suggest.
 clip_in () {
     # shellcheck disable=SC2086
     if   [ -n "$SBM_PASTE" ]; then $SBM_PASTE
@@ -213,34 +188,18 @@ opener () {
     fi
 }
 
-# fetch_title <url>: print the page's <title>, or nothing.
-fetch_title () {
-    [ "${SBM_FETCH:-1}" != 0 ] && have curl || return 0
-    curl -sL --max-time 3 -A 'Mozilla/5.0' -- "$1" 2>/dev/null \
-        | head -c 65536 | LC_ALL=C tr '\r\n\t' '   ' | LC_ALL=C awk "$AWK_HTML"'
-        {
-            start = index(tolower($0), "<title")
-            if (!start) exit
-            rest = substr($0, start)
-            rest = substr(rest, index(rest, ">") + 1)
-            end = index(tolower(rest), "</title")
-            if (end) rest = substr(rest, 1, end - 1)
-            print decode(rest)
-            exit
-        }'
-}
-
-# ---- bookmark file helpers ----
+# ---- the bookmark file ----
 
-# Bookmarks restricted to $tagfilter and ordered by $sortmode.
+# Bookmarks restricted to $tagfilter and ordered by $sortorder, each followed
+# by a fourth field: how often it was used.
 rows () {
-    SBM_TAG=$tagfilter SBM_MODE=$sortmode SBM_USAGE=$USAGE awk -F'\t' '
+    SBM_TAG=$tagfilter SBM_ORDER=$sortorder SBM_USAGE=$USAGE awk -F'\t' '
         BEGIN {
             while ((getline line < ENVIRON["SBM_USAGE"]) > 0) {
                 split(line, f, "\t")
                 used[f[1]] = f[2]
             }
-            mode = ENVIRON["SBM_MODE"]
+            order = ENVIRON["SBM_ORDER"]
         }
         NF == 0 || /^#/ { next }
         ENVIRON["SBM_TAG"] != "" {
@@ -251,37 +210,64 @@ rows () {
             if (!hit) next
         }
         {
-            if (mode == "used")        key = sprintf("%09d", 999999999 - used[$1])
-            else if (mode == "recent") key = sprintf("%09d", 999999999 - NR)
-            else if (mode == "desc")   key = tolower($2)
-            else if (mode == "tag")    key = tolower($3)
-            else                       key = ""
-            printf "%s\t%s\t%s\n", key, tolower($1), $0
+            if (order == "used")        key = sprintf("%09d", 999999999 - used[$1])
+            else if (order == "recent") key = sprintf("%09d", 999999999 - NR)
+            else if (order == "desc")   key = tolower($2)
+            else if (order == "tag")    key = tolower($3)
+            else                        key = ""
+            printf "%s\t%s\t%s\t%s\t%s\t%d\n", key, tolower($1), $1, $2, $3, used[$1]
         }' "$BOOKMARKS" | LC_ALL=C sort -t"$TAB" -k1,1 -k2,2 | cut -f3-
 }
 
-# display: turn rows into url<tab>aligned text, for fzf --with-nth=2..
-display () {
-    awk -F'\t' '{
+# details <url>: a bookmark in full, one field per line.
+details () {
+    rows | SBM_URL=$1 awk -F'\t' '$1 == ENVIRON["SBM_URL"] {
+        printf "%s\n%s\ntags: %s\nused: %d\n", $1, $2, $3, $4
+        exit
+    }'
+}
+
+is_bookmark () {
+    SBM_URL=$1 awk -F'\t' '$1 == ENVIRON["SBM_URL"] { found = 1; exit }
+        END { exit !found }' "$BOOKMARKS"
+}
+
+# For fzf a fifth field is added, and it is all that is listed. The first
+# four feed the preview, which follows the cursor.
+PREVIEW="printf '%s\n%s\ntags: %s\nused: %s\n' {1} {2} {3} {4}"
+fzfrows () {
+    awk -F'\t' 'NF {
         desc = $2
         if (length(desc) > 44) desc = substr(desc, 1, 42) ".."
-        printf "%s\t%-44s  %-18s  %s\n", $1, desc, $3, $1
+        printf "%s\t%-44s  %-18s  %s\n", $0, desc, $3, $1
     }'
 }
 
-# choose_url <prompt>: let the user pick a bookmark, print its URL.
+# withpreview <fzf command and options>: for the output of fzfrows.
+withpreview () {
+    "$@" --delimiter="$TAB" --with-nth=5.. \
+         --preview-window=down:5:wrap --preview="$PREVIEW"
+}
+
+# choose <prompt>: a bookmark's URL, or whatever else the user typed.
+choose () {
+    if [ "$SBM_MENU" = fzf ]; then
+        rows | fzfrows | withpreview fzfpick --prompt="$1 "
+    else
+        rows | cut -f1-3 | pick "$1"
+    fi | cut -f1
+}
+
+# choose_url <prompt>: like choose, but only a bookmark will do.
 choose_url () {
-    list=$(rows)
-    if [ -z "$list" ]; then
+    if [ -z "$(rows)" ]; then
         [ -n "$tagfilter" ] && die "no bookmarks tagged '$tagfilter'"
         die 'no bookmarks yet; add one with: bm --add'
     fi
-    if [ "$SBM_MENU" = fzf ]; then
-        printf '%s\n' "$list" | display \
-            | fzfcmd --delimiter="$TAB" --with-nth=2.. --prompt="$1 "
-    else
-        printf '%s\n' "$list" | pick "$1"
-    fi | cut -f1
+    url=$(choose "$1")
+    [ -n "$url" ] || exit 1
+    is_bookmark "$url" || die "not a bookmark: $url"
+    printf '%s\n' "$url"
 }
 
 # find_dup <url>: print the bookmark that is the same page as <url>, if any.
@@ -291,29 +277,47 @@ find_dup () {
         NF && !/^#/ && norm($1) == want { print; exit }' "$BOOKMARKS"
 }
 
-# rewrite <file>: replace <file> with stdin, keeping its permissions and any
-# symlink pointing at it.
+# tmpfile <file>: create an empty temporary file and print its name. Beside
+# <file> if that directory can be written to, else in $TMPDIR. noclobber makes
+# the shell create it with O_EXCL, so a file or symlink planted under that
+# name in a shared /tmp is refused rather than followed: mktemp, in POSIX.
+tmpfile () {
+    for candidate in "$1.tmp.$$" "${TMPDIR:-/tmp}/sbm.$$.${1##*/}"; do
+        if (umask 077; set -C; : > "$candidate") 2>/dev/null; then
+            printf '%s\n' "$candidate"
+            return 0
+        fi
+    done
+    return 1
+}
+
+# rewrite <file>: replace <file> with stdin. Through cat, not mv, so that a
+# symlinked file stays a symlink and keeps its mode.
 rewrite () {
-    tmp=$(mktemp "${TMPDIR:-/tmp}/sbm.XXXXXX") || die 'mktemp failed'
+    tmp=$(tmpfile "$1") || die "cannot create a temporary file for $1"
     cat > "$tmp" && cat "$tmp" > "$1"
     rc=$?
     rm -f "$tmp"
     return $rc
 }
 
-# bump <url>: count a use, for the "used" sort order.
+# bump <url>: count a use, for the "used" order. The counts live beside the
+# bookmark file, which stays a plain list: url, count, time of last use.
+# srand() returns the previous seed, and the first seed is the time of day:
+# that is "date +%s" for a POSIX that has no %s.
 bump () {
     [ -e "$USAGE" ] || : > "$USAGE" || return 0
-    SBM_URL=$1 SBM_NOW=$(date +%s) awk -F'\t' '
-        BEGIN { OFS = "\t" }
-        $1 == ENVIRON["SBM_URL"] { $2 = $2 + 1; $3 = ENVIRON["SBM_NOW"]; seen = 1 }
+    SBM_URL=$1 awk -F'\t' '
+        BEGIN { OFS = "\t"; srand(); now = srand() }
+        $1 == ENVIRON["SBM_URL"] { $2 = $2 + 1; $3 = now; seen = 1 }
         { print }
-        END { if (!seen) print ENVIRON["SBM_URL"], 1, ENVIRON["SBM_NOW"] }
+        END { if (!seen) print ENVIRON["SBM_URL"], 1, now }
     ' "$USAGE" | rewrite "$USAGE"
 }
 
 # learn_tags: tag names on stdin; the ones $USERTAGS lacks are appended to it.
 learn_tags () {
+    [ -e "$USERTAGS" ] || : > "$USERTAGS"
     while IFS= read -r name; do
         [ -n "$name" ] || continue
         sed 's/[ |].*//' "$USERTAGS" | grep -Fxq -- "$name" \
@@ -321,88 +325,85 @@ learn_tags () {
     done
 }
 
-# autocommit <message>: record a change when the bookmarks live in their own
-# git repository. Never pushes.
-autocommit () {
-    [ "${SBM_GIT:-auto}" != 0 ] && have git || return 0
-    dir=$(cd "$(dirname "$BOOKMARKS")" && pwd -P) || return 0
-    top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || return 0
-    if [ "${SBM_GIT:-auto}" != 1 ] && [ "$top" != "$dir" ]; then
-        return 0
-    fi
-    set -- "$1" "$BOOKMARKS"
-    [ "$(cd "$(dirname "$USERTAGS")" 2>/dev/null && pwd -P)" = "$dir" ] \
-        && [ -e "$USERTAGS" ] && set -- "$@" "$USERTAGS"
-    message=$1
-    shift
-    git -C "$dir" add -- "$@" 2>/dev/null || return 0
-    git -C "$dir" diff --cached --quiet -- "$@" && return 0
-    git -C "$dir" commit -q -m "$message" -- "$@" >/dev/null 2>&1 \
-        || printf 'bm: git commit failed in %s\n' "$dir" >&2
+# changed <message>: the file was written. bm-commit, where installed, keeps
+# its history in git.
+changed () {
+    if have bm-commit; then bm-commit "$1"; fi
 }
 
 # ---- actions ----
 
 copy_url () {
     bump "$1"
-    if [ -n "$printurl" ]; then
-        printf '%s\n' "$1"
-    else
-        printf '%s' "$1" | clip_out
-    fi
+    printf '%s' "$1" | clip_out
 }
 
 open_url () {
     bump "$1"
-    if [ -n "$printurl" ]; then
-        printf '%s\n' "$1"
-    else
-        opener "$1"
-    fi
+    opener "$1"
 }
 
 edit_url () {
     line=$(SBM_URL=$1 awk -F'\t' \
            '$1 == ENVIRON["SBM_URL"] { print NR; exit }' "$BOOKMARKS")
     ${VISUAL:-${EDITOR:-vi}} "+${line:-1}" "$BOOKMARKS"
-    autocommit 'bm: edit'
+    changed 'bm: edit'
 }
 
 delete_url () {
-    SBM_URL=$1 awk -F'\t' '$1 != ENVIRON["SBM_URL"]' "$BOOKMARKS" \
-        | rewrite "$BOOKMARKS" || die "could not rewrite $BOOKMARKS"
-    if [ -e "$USAGE" ]; then
-        SBM_URL=$1 awk -F'\t' '$1 != ENVIRON["SBM_URL"]' "$USAGE" \
-            | rewrite "$USAGE"
-    fi
-    autocommit "bm: delete $1"
-}
-
-copy () {
-    url=$(choose_url 'copy:')
-    [ -n "$url" ] || exit 1
-    copy_url "$url"
+    for file in "$BOOKMARKS" "$USAGE"; do
+        [ -e "$file" ] || continue
+        SBM_URL=$1 awk -F'\t' '$1 != ENVIRON["SBM_URL"]' "$file" \
+            | rewrite "$file" || die "could not rewrite $file"
+    done
+    changed "bm: delete $1"
 }
 
-plumb () {
-    url=$(choose_url 'open:')
-    [ -n "$url" ] || exit 1
-    open_url "$url"
+# urlencode <text>: for a query string. awk has no ord(), hence the table.
+urlencode () {
+    printf '%s' "$1" | LC_ALL=C awk '
+        BEGIN { for (i = 1; i < 256; i++) ord[sprintf("%c", i)] = i }
+        {
+            for (i = 1; i <= length($0); i++) {
+                c = substr($0, i, 1)
+                if (c ~ /[A-Za-z0-9._~-]/) out = out c
+                else if (c == " ")         out = out "+"
+                else                       out = out sprintf("%%%02X", ord[c])
+            }
+        }
+        END { printf "%s", out }'
 }
 
-edit () {
-    url=$(choose_url 'edit:')
-    [ -n "$url" ] || exit 1
-    edit_url "$url"
+# go <text>: open text that is not a bookmark. One word with "://" or a dot
+# in it is an address. Anything else is a web search: by the engine whose
+# keyword is the first word ($ENGINES holds "keyword | url with %s" lines),
+# or else by $SEARCH.
+go () {
+    case $1 in
+        *' '*) ;;
+        *://*) opener "$1"; return ;;
+        *.*)   opener "https://$1"; return ;;
+    esac
+    engine=$SEARCH
+    query=$1
+    if [ "${1%% *}" != "$1" ] && [ -r "$ENGINES" ]; then
+        found=$(SBM_KEY=${1%% *} awk -F' *[|] *' \
+                '$1 == ENVIRON["SBM_KEY"] { print $2; exit }' "$ENGINES")
+        if [ -n "$found" ]; then
+            engine=$found
+            query=${1#* }
+        fi
+    fi
+    opener "$(printf '%s\n' "$engine" | sed "s|%s|$(urlencode "$query")|")"
 }
 
-delete () {
-    url=$(choose_url 'delete:')
+plumb () {
+    url=$(choose 'open:')
     [ -n "$url" ] || exit 1
-    delete_url "$url"
-    printf 'deleted %s\n' "$url"
+    if is_bookmark "$url"; then open_url "$url"; else go "$url"; fi
 }
 
+# Exit status 3 means "already bookmarked".
 add () {
     url=$1
     if [ -z "$url" ]; then
@@ -414,10 +415,13 @@ add () {
     dup=$(find_dup "$url")
     if [ -n "$dup" ]; then
         printf 'bm: already bookmarked:\n%s\n' "$dup" >&2
-        exit 1
+        exit 3
     fi
 
-    desc=$(ask 'description' "$(fetch_title "$url")" | tr '\t' ' ')
+    # bm-title, where installed, fetches the page's title.
+    title=
+    if have bm-title; then title=$(bm-title "$url"); fi
+    desc=$(ask 'description' "$title" | tr '\t' ' ')
 
     [ -e "$USERTAGS" ] || : > "$USERTAGS"
     names=$(pickmulti 'tags:' < "$USERTAGS" | sed 's/[ |].*//' | sed '/^$/d' \
@@ -426,222 +430,83 @@ add () {
     tags=$(printf '%s\n' "$names" | paste -sd ' ' -)
 
     printf '%s\t%s\t%s\n' "$url" "$desc" "$tags" >> "$BOOKMARKS"
-    autocommit "bm: add $url"
-}
-
-list () {
-    rows
+    changed "bm: add $url"
 }
 
-search () {
-    rows | grep -Ei -- "$1"
-}
-
-# browser_file <name>: where a Chromium style browser keeps its bookmarks.
-browser_file () {
-    case $1 in
-        vivaldi)  dir='vivaldi' ;;
-        chrome)   dir='google-chrome' ;;
-        brave)    dir='BraveSoftware/Brave-Browser' ;;
-        edge)     dir='microsoft-edge' ;;
-        chromium) dir='chromium' ;;
-    esac
-    printf '%s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/$dir/Default/Bookmarks"
-}
-
-# Chromium JSON to rows. Folder names become tags; the trash is left out.
-import_json () {
-    have jq || die 'importing a Chromium bookmark file needs jq'
-    jq -r '
-        def slug: ascii_downcase | gsub("[^a-z0-9]+"; "-")
-                  | sub("^-"; "") | sub("-$"; "");
-        def emit(path):
-            if .type == "url" then
-                [.url, (.name | gsub("[\t\r\n]+"; " ")),
-                 (path | unique | join(" "))] | join("\t")
-            elif (.children? | type) == "array" then
-                (path + ([.name | slug] | map(select(. != "")))) as $sub
-                | .children[] | emit($sub)
-            else empty end;
-        .roots | to_entries[]
-        | select(.key != "trash" and (.value | type) == "object")
-        | .value.children[]? | emit([])' "$1"
-}
-
-# Netscape bookmark HTML to rows. <H3> opens a folder, </DL> closes it.
-import_html () {
-    awk "$AWK_HTML"'
-    {
-        upper = toupper($0)
-        if ((at = index(upper, "<H3")) > 0) {
-            name = substr($0, at)
-            name = substr(name, index(name, ">") + 1)
-            end = index(toupper(name), "</H3")
-            if (end) name = substr(name, 1, end - 1)
-            folder[++depth] = slug(decode(name))
-        }
-        if ((at = index(upper, "HREF=\"")) > 0) {
-            url = substr($0, at + 6)
-            rest = substr(url, index(url, "\"") + 1)
-            url = decode(substr(url, 1, index(url, "\"") - 1))
-            name = substr(rest, index(rest, ">") + 1)
-            end = index(toupper(name), "</A")
-            if (end) name = substr(name, 1, end - 1)
-            tags = ""
-            for (i = 1; i <= depth; i++)
-                if (folder[i] != "" && index(" " tags " ", " " folder[i] " ") == 0)
-                    tags = tags (tags == "" ? "" : " ") folder[i]
-            printf "%s\t%s\t%s\n", url, decode(name), tags
-        }
-        if (index(upper, "</DL>") > 0 && depth > 0) depth--
-    }' "$1"
-}
-
-import () {
-    src=$1
-    case $src in
-        vivaldi|chrome|brave|edge|chromium) src=$(browser_file "$src") ;;
-    esac
-    [ -r "$src" ] || die "cannot read $src"
-
-    new=$(mktemp "${TMPDIR:-/tmp}/sbm.XXXXXX") || die 'mktemp failed'
-    case $(LC_ALL=C tr -d ' \t\r\n' < "$src" | head -c 1) in
-        '{') import_json "$src" ;;
-        *)   import_html "$src" ;;
-    esac | SBM_FILE=$BOOKMARKS SBM_NEW=$new awk -F'\t' "$AWK_NORM"'
+# merge: bookmark lines on stdin (a bare URL per line will do); the ones not
+# known yet are appended. This is how the output of bm-import gets in.
+merge () {
+    new=$(tmpfile "$BOOKMARKS") || die 'cannot create a temporary file'
+    SBM_FILE=$BOOKMARKS SBM_NEW=$new awk -F'\t' "$AWK_NORM"'
         BEGIN {
             while ((getline line < ENVIRON["SBM_FILE"]) > 0) {
                 split(line, f, "\t")
                 seen[norm(f[1])] = 1
             }
         }
-        $1 !~ /^(https?|ftp|file):\/\// { next }
+        NF == 0 || /^#/ { next }
         seen[norm($1)]++ { skipped++; next }
-        { print > ENVIRON["SBM_NEW"]; added++ }
-        END { printf "imported %d, skipped %d duplicates\n", added, skipped }'
-
-    [ -e "$USERTAGS" ] || : > "$USERTAGS"
+        { printf "%s\t%s\t%s\n", $1, $2, $3 > ENVIRON["SBM_NEW"]; added++ }
+        END { printf "added %d, skipped %d duplicates\n", added, skipped }'
     cut -f3 "$new" | tr ' ' '\n' | sort -u | learn_tags
     cat "$new" >> "$BOOKMARKS"
     rm -f "$new"
-    autocommit "bm: import $(basename "$src")"
-}
-
-# check_one <url>: print a line when the link is dead or has moved for good.
-check_one () {
-    probe='%{http_code} %{redirect_url}'
-    res=$(curl -sI --max-time 10 -A 'Mozilla/5.0' -o /dev/null -w "$probe" \
-          -- "$1" 2>/dev/null)
-    case ${res%% *} in
-        403|405|000|'')
-            # Some servers refuse HEAD; ask for the first byte instead.
-            res=$(curl -s -r 0-0 --max-time 10 -A 'Mozilla/5.0' -o /dev/null \
-                  -w "$probe" -- "$1" 2>/dev/null)
-            ;;
-    esac
-    code=${res%% *}
-    case $code in
-        301|308) printf '%s\t%s\t%s\n' "$code" "$1" "${res#* }" ;;
-        2*|3*)   ;;
-        *)       printf '%s\t%s\n' "${code:-000}" "$1" ;;
-    esac
+    changed 'bm: merge'
 }
 
-check () {
-    have curl || die 'checking links needs curl'
-    self=$(command -v -- "$0") || self=$0
-    bad=$(rows | cut -f1 | tr '\n' '\0' \
-          | xargs -0 -P 8 -n 1 sh "$self" --check-one)
-    if [ -z "$bad" ]; then
-        printf 'all links ok\n'
-        return 0
-    fi
-    printf '%s\n' "$bad"
-    return 1
-}
-
-# Old lines look like "url description | tag tag". Lines that already have a
-# tab are left alone, so running this twice is harmless.
-migrate () {
-    cp "$BOOKMARKS" "$BOOKMARKS.bak" || die "could not back up $BOOKMARKS"
-    awk '
-        /\t/ || /^#/ || NF == 0 { print; next }
-        {
-            url = $1
-            desc = $0
-            sub(/^[ ]*[^ ]+[ ]*/, "", desc)
-            tags = ""
-            if (match(desc, / \|[^|]*$/)) {
-                tags = substr(desc, RSTART + 2)
-                desc = substr(desc, 1, RSTART - 1)
-            }
-            gsub(/^ +| +$/, "", desc)
-            gsub(/^ +| +$/, "", tags)
-            printf "%s\t%s\t%s\n", url, desc, tags
-        }' "$BOOKMARKS.bak" | rewrite "$BOOKMARKS" \
-        || die "could not rewrite $BOOKMARKS"
-    printf 'migrated %s (backup in %s.bak)\n' "$BOOKMARKS" "$BOOKMARKS"
-    autocommit 'bm: migrate to TSV'
-}
-
-needs_migration () {
-    awk '!/\t/ && !/^#/ && NF { found = 1; exit } END { exit !found }' \
-        "$BOOKMARKS"
+nextorder () {
+    case $sortorder in
+        used)   sortorder=recent ;;
+        recent) sortorder=url ;;
+        url)    sortorder=desc ;;
+        desc)   sortorder=tag ;;
+        *)      sortorder=used ;;
+    esac
 }
 
-promptaction () {
-    action=$(printf 'copy\nopen\nadd\nedit\ndelete\n' | pick 'action:')
-    [ -n "$action" ] || exit 1
+picktag () {
+    tag=$( { printf '(all)\n'; cut -f3 "$BOOKMARKS" | tr ' ' '\n' \
+             | sed '/^$/d' | sort -u; } | pick 'tag:')
+    case $tag in
+        '')      ;;
+        '(all)') tagfilter= ;;
+        *)       tagfilter=$tag ;;
+    esac
 }
 
-nextsort () {
-    case $sortmode in
-        used)   sortmode=recent ;;
-        recent) sortmode=url ;;
-        url)    sortmode=desc ;;
-        desc)   sortmode=tag ;;
-        *)      sortmode=used ;;
-    esac
+# add exits, which must not end a browsing session; and when it refuses a
+# duplicate the user has to get to see that before the list comes back.
+add_inside () {
+    ( add '' )
+    if [ $? -eq 3 ]; then
+        ask 'already bookmarked; press ENTER' >/dev/null
+    fi
 }
 
-# browse: everything from one fzf screen. Opening and copying leave it, the
-# other keys come back to it.
-browse () {
+# browse_fzf: fzf can bind keys, so one list does everything. Opening and
+# copying leave it, the other keys come back to it.
+browse_fzf () {
     while :; do
-        list=$(rows)
-        header="ENTER open  ^Y copy  ^A add  ^D delete  ^E edit  ^T tag [${tagfilter:-all}]  ^S sort [$sortmode]"
-        out=$(if [ -n "$list" ]; then printf '%s\n' "$list" | display; fi \
-              | fzfcmd --expect=ctrl-y,ctrl-a,ctrl-d,ctrl-e,ctrl-t,ctrl-s \
-                       --delimiter="$TAB" --with-nth=2.. \
-                       --header="$header" --prompt='bookmark: ')
+        header="ENTER open  ^Y copy  ^A add  ^D delete  ^E edit  ^T tag [${tagfilter:-all}]  ^S sort [$sortorder]"
+        out=$(rows | fzfrows | withpreview fzfcmd --print-query \
+              --expect=ctrl-y,ctrl-a,ctrl-d,ctrl-e,ctrl-t,ctrl-s \
+              --header="$header" --prompt='bookmark: ')
         [ -n "$out" ] || exit 1
-        key=$(printf '%s\n' "$out" | sed -n 1p)
-        url=$(printf '%s\n' "$out" | sed -n 2p | cut -f1)
+        typed=$(printf '%s\n' "$out" | sed -n 1p)
+        key=$(printf '%s\n' "$out" | sed -n 2p)
+        url=$(printf '%s\n' "$out" | sed -n 3p | cut -f1)
 
         case $key in
-            ctrl-a)
-                # add exits on a cancelled prompt or a duplicate; keep that
-                # from ending the browser, and leave its message readable.
-                ( add '' ) || ask 'press ENTER to continue' >/dev/null
-                continue
-                ;;
-            ctrl-t)
-                tag=$( { printf '(all)\n'; cut -f3 "$BOOKMARKS" | tr ' ' '\n' \
-                         | sed '/^$/d' | sort -u; } | pick 'tag:')
-                case $tag in
-                    '')      ;;
-                    '(all)') tagfilter= ;;
-                    *)       tagfilter=$tag ;;
-                esac
-                continue
-                ;;
-            ctrl-s)
-                nextsort
-                continue
-                ;;
+            ctrl-a) add_inside; continue ;;
+            ctrl-t) picktag; continue ;;
+            ctrl-s) nextorder; continue ;;
         esac
-
-        [ -n "$url" ] || { [ -n "$key" ] && continue; exit 1; }
+        if [ -z "$url" ]; then
+            [ -n "$key" ] && continue
+            [ -n "$typed" ] || exit 1
+            go "$typed"
+            exit 0
+        fi
         case $key in
             ctrl-y) copy_url "$url"; exit 0 ;;
             ctrl-e) edit_url "$url" ;;
@@ -655,6 +520,33 @@ browse () {
     done
 }
 
+# browse_menu: dmenu can neither bind keys nor preview, so a second menu does
+# both: it shows the bookmark in full and asks what to do with it. Closing it
+# goes back to the list; closing the list leaves.
+browse_menu () {
+    while :; do
+        chosen=$( { printf '[add]\n'; rows | cut -f1-3; } | pick 'bookmark:')
+        [ -n "$chosen" ] || exit 1
+        if [ "$chosen" = '[add]' ]; then
+            add_inside
+            continue
+        fi
+        url=$(printf '%s\n' "$chosen" | cut -f1)
+        if ! is_bookmark "$url"; then
+            go "$chosen"
+            exit 0
+        fi
+        action=$( { printf 'open\ncopy\nedit\ndelete\n'
+                    details "$url" | sed 's/^/  /'; } | pick 'do:')
+        case $action in
+            open)   open_url "$url"; exit 0 ;;
+            copy)   copy_url "$url"; exit 0 ;;
+            edit)   edit_url "$url" ;;
+            delete) delete_url "$url" ;;
+        esac
+    done
+}
+
 # ---- main ----
 
 setaction () {
@@ -674,38 +566,26 @@ needarg () {
 }
 
 action=
-actionarg=
+addurl=
 tagfilter=
-printurl=
-sortmode=${SBM_SORT:-used}
+sortorder=${SBM_SORT:-used}
 while [ $# -gt 0 ]; do
     case $1 in
         -h|--help)   setaction help ;;
         -c|--copy)   setaction copy ;;
+        --print)     setaction print ;;
         -o|--open|-p|--plumb) setaction open ;;
         -e|--edit)   setaction edit ;;
         -d|--delete) setaction delete ;;
         -l|--list)   setaction list ;;
-        --migrate)   setaction migrate ;;
-        --check)     setaction check ;;
-        --print)     printurl=1 ;;
+        -m|--merge)  setaction merge ;;
         -a|--add)
             setaction add
             case ${2-} in
                 ''|-*) ;;
-                *) actionarg=$2; shift ;;
+                *) addurl=$2; shift ;;
             esac
             ;;
-        -s|--search|--import|--check-one)
-            needarg "$1" $# "${2-}"
-            case $1 in
-                --import)    setaction import ;;
-                --check-one) setaction check-one ;;
-                *)           setaction search ;;
-            esac
-            actionarg=$2
-            shift
-            ;;
         -t|--tag)
             needarg "$1" $# "${2-}"
             tagfilter=$2
@@ -713,7 +593,7 @@ while [ $# -gt 0 ]; do
             ;;
         -S|--sort)
             needarg "$1" $# "${2-}"
-            sortmode=$2
+            sortorder=$2
             shift
             ;;
         *)
@@ -725,53 +605,43 @@ while [ $# -gt 0 ]; do
     shift
 done
 
-case $sortmode in
+case $sortorder in
     used|recent|url|desc|tag) ;;
     *)
-        printf 'bm: unknown sort order: %s\n' "$sortmode" >&2
+        printf 'bm: unknown sort order: %s\n' "$sortorder" >&2
         exit 2
         ;;
 esac
 
-case $action in
-    help)      usage; exit 0 ;;
-    check-one) check_one "$actionarg"; exit 0 ;;
-esac
+if [ "$action" = help ]; then
+    usage
+    exit 0
+fi
 
 mkdir -p "$(dirname "$BOOKMARKS")" || die "cannot create directory for $BOOKMARKS"
 [ -e "$BOOKMARKS" ] || : > "$BOOKMARKS" || die "cannot create $BOOKMARKS"
 
-if [ "$action" != migrate ] && needs_migration; then
-    printf 'bm: %s has lines in the old format; run: bm --migrate\n' \
-        "$BOOKMARKS" >&2
+if awk '!/\t/ && !/^#/ && NF { found = 1; exit } END { exit !found }' "$BOOKMARKS"
+then
+    printf 'bm: %s has lines in the old format; run: bm-migrate\n' "$BOOKMARKS" >&2
 fi
 
 case $action in
-    list|search|import|check|migrate) ;;
+    list|merge) ;;
     *) pickmenu ;;
 esac
 
-if [ -z "$action" ]; then
-    if [ "$SBM_MENU" = fzf ]; then
-        action=browse
-    elif [ -n "$tagfilter" ]; then
-        action=copy
-    else
-        promptaction
-    fi
-fi
-
 case $action in
-    browse)  browse ;;
-    copy)    copy ;;
-    open)    plumb ;;
-    add)     add "$actionarg" ;;
-    edit)    edit ;;
-    delete)  delete ;;
-    list)    list ;;
-    search)  search "$actionarg" ;;
-    import)  import "$actionarg" ;;
-    check)   check ;;
-    migrate) migrate ;;
-    *)       die "unknown action: $action" ;;
+    '')
+        if [ "$SBM_MENU" = fzf ]; then browse_fzf; else browse_menu; fi
+        ;;
+    open)   plumb ;;
+    copy)   url=$(choose_url 'copy:')   && copy_url "$url" ;;
+    print)  url=$(choose_url 'print:')  && bump "$url" && printf '%s\n' "$url" ;;
+    edit)   url=$(choose_url 'edit:')   && edit_url "$url" ;;
+    delete) url=$(choose_url 'delete:') && delete_url "$url" \
+                && printf 'deleted %s\n' "$url" ;;
+    add)    add "$addurl" ;;
+    list)   rows | cut -f1-3 ;;
+    merge)  merge ;;
 esac
diff --git a/bm-check b/bm-check
new file mode 100755
index 0000000..c54781b
--- /dev/null
+++ b/bm-check
@@ -0,0 +1,70 @@
+#!/bin/sh
+
+# bm-check: report dead and permanently moved links.
+#
+#     bm-check                    the default bookmark file
+#     bm-check file ...           these files ("-" is stdin)
+#     bm --tag code --list | bm-check -
+#
+# Reads bookmark lines (only the first field matters, so a plain list of URLs
+# works too) and prints, for each link that is in trouble,
+#
+#     status<tab>url[<tab>new location]
+#
+# where status is the HTTP status, or 000 when there was no answer. 301 and
+# 308 are reported because the bookmark should be updated. Exits 1 when it
+# printed anything.
+
+probe='%{http_code} %{redirect_url}'
+
+check () {
+    res=$(curl -sI --max-time 10 -A 'Mozilla/5.0' -o /dev/null -w "$probe" \
+          -- "$1" 2>/dev/null)
+    case ${res%% *} in
+        403|405|000|'')
+            # Some servers refuse HEAD; ask for the first byte instead.
+            res=$(curl -s -r 0-0 --max-time 10 -A 'Mozilla/5.0' -o /dev/null \
+                  -w "$probe" -- "$1" 2>/dev/null)
+            ;;
+    esac
+    code=${res%% *}
+    case $code in
+        301|308) printf '%s\t%s\t%s\n' "$code" "$1" "${res#* }" ;;
+        2*|3*)   ;;
+        *)       printf '%s\t%s\n' "${code:-000}" "$1" ;;
+    esac
+}
+
+case ${1-} in
+    -h|--help) sed -n '3,16s/^# \{0,1\}//p' "$0"; exit 0 ;;
+esac
+
+command -v curl >/dev/null 2>&1 || { printf 'bm-check: needs curl\n' >&2; exit 1; }
+[ $# -gt 0 ] || set -- "${BOOKMARKS:-${XDG_DATA_HOME:-$HOME/.local/share}/sbm/bookmarks}"
+
+# Eight probes in flight until the list runs out, like xargs -P 8, with a
+# FIFO as the semaphore: it starts out holding eight tokens, a probe takes one
+# before it starts and puts it back when it is done. Opening a FIFO blocks
+# until the other end is opened too, hence the helper that opens it for
+# writing while we open it for reading. Every report is one short line from
+# one printf, so concurrent probes do not garble each other.
+fifo=${TMPDIR:-/tmp}/bm-check.$$
+mkfifo -m 600 "$fifo" || { printf 'bm-check: cannot create %s\n' "$fifo" >&2; exit 1; }
+trap 'rm -f "$fifo"' EXIT INT TERM
+: > "$fifo" &
+# shellcheck disable=SC2094  # both ends of the FIFO, on purpose
+exec 3< "$fifo" 4> "$fifo"
+rm -f "$fifo"
+printf '\n\n\n\n\n\n\n\n' >&4
+
+bad=$(cat -- "$@" | awk -F'\t' 'NF && !/^#/ { print $1 }' | {
+    while IFS= read -r url; do
+        # shellcheck disable=SC2034  # the token is only waited for
+        read -r token <&3
+        { check "$url"; echo >&4; } &
+    done
+    wait
+})
+[ -z "$bad" ] && exit 0
+printf '%s\n' "$bad"
+exit 1
diff --git a/bm-commit b/bm-commit
new file mode 100755
index 0000000..fbefcca
--- /dev/null
+++ b/bm-commit
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+# bm-commit [message]: commit the bookmark file, and the tag file beside it,
+# when their directory is a git repository of its own. bm runs it after every
+# change when it is installed. It never pushes or pulls.
+#
+#     cd ~/.local/share/sbm && git init
+#     printf '%s\n' '*.usage' '*.bak*' > .gitignore
+#
+# A file that merely sits somewhere inside a bigger repository (your dotfiles,
+# say) is left alone, unless SBM_GIT=1. SBM_GIT=0 turns commits off.
+
+DATADIR=${XDG_DATA_HOME:-$HOME/.local/share}/sbm
+BOOKMARKS=${BOOKMARKS:-$DATADIR/bookmarks}
+USERTAGS=${USERTAGS:-$DATADIR/usertags}
+
+[ "${SBM_GIT:-auto}" != 0 ] || exit 0
+dir=$(cd "$(dirname "$BOOKMARKS")" 2>/dev/null && pwd -P) || exit 0
+top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
+[ "${SBM_GIT:-auto}" = 1 ] || [ "$top" = "$dir" ] || exit 0
+
+set -- "${1:-bm: update}" "$BOOKMARKS"
+if [ -e "$USERTAGS" ] \
+    && [ "$(cd "$(dirname "$USERTAGS")" 2>/dev/null && pwd -P)" = "$dir" ]
+then
+    set -- "$@" "$USERTAGS"
+fi
+message=$1
+shift
+
+git -C "$dir" add -- "$@" 2>/dev/null || exit 0
+git -C "$dir" diff --cached --quiet -- "$@" && exit 0
+if ! git -C "$dir" commit -q -m "$message" -- "$@" >/dev/null 2>&1; then
+    printf 'bm-commit: git commit failed in %s\n' "$dir" >&2
+    exit 1
+fi
diff --git a/bm-html b/bm-html
new file mode 100755
index 0000000..4031c5f
--- /dev/null
+++ b/bm-html
@@ -0,0 +1,105 @@
+#!/bin/sh
+
+# bm-html: print the bookmarks as one self-contained web page.
+#
+#     bm-html > ~/public_html/bookmarks.html
+#     bm --tag code --list | bm-html -t 'Code' - > code.html
+#
+# Without a file the default bookmark file is read; "-" is stdin.
+#
+# The page lists the bookmarks under each of their tags, with links to the
+# tags at the top. A few lines of script add a filter box; without script the
+# page is still the complete list. page.html?q=words starts out filtered, so
+# the page can serve as a search engine keyword in a browser, or in bm's own
+# engines file:    b | https://example.org/bookmarks.html?q=%s
+
+usage () {
+    printf 'usage: bm-html [-t title] [file | -] ...\n'
+}
+
+title=Bookmarks
+while [ $# -gt 0 ]; do
+    case $1 in
+        -h|--help) usage; exit 0 ;;
+        -t)
+            [ $# -ge 2 ] || { usage >&2; exit 2; }
+            title=$2
+            shift
+            ;;
+        --) shift; break ;;
+        -) break ;;
+        -?*) usage >&2; exit 2 ;;
+        *) break ;;
+    esac
+    shift
+done
+[ $# -gt 0 ] || set -- "${BOOKMARKS:-${XDG_DATA_HOME:-$HOME/.local/share}/sbm/bookmarks}"
+
+cat -- "$@" | LC_ALL=C sort -f -t'	' -k2,2 -k1,1 | SBM_TITLE=$title awk -F'\t' '
+function esc(s) {
+    gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
+    gsub(/"/, "\\&quot;", s)
+    return s
+}
+function host(u) {
+    sub(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "", u)
+    sub(/[\/?#].*/, "", u)
+    return u
+}
+NF == 0 || /^#/ { next }
+# Never publish a link that would run script.
+tolower($1) ~ /^[ \t]*(javascript|data|vbscript):/ { next }
+{
+    total++
+    item = sprintf("<li><a href=\"%s\">%s</a> <small>%s</small>", esc($1), \
+                   esc($2 == "" ? $1 : $2), esc(host($1)))
+    n = split($3, tag, " ")
+    for (i = 1; i <= n; i++)
+        item = item sprintf(" <a class=t href=\"#%s\">%s</a>", esc(tag[i]), esc(tag[i]))
+    if (n == 0) { n = 1; tag[1] = "" }
+    for (i = 1; i <= n; i++) {
+        if (!(tag[i] in items)) names[++count] = tag[i]
+        items[tag[i]] = items[tag[i]] item "\n"
+    }
+}
+END {
+    # Insertion sort: tags by name, the untagged last.
+    for (i = 2; i <= count; i++) {
+        name = names[i]
+        for (j = i - 1; j > 0 && (names[j] == "" || (name != "" && names[j] > name)); j--)
+            names[j + 1] = names[j]
+        names[j + 1] = name
+    }
+    t = esc(ENVIRON["SBM_TITLE"])
+    print "<!doctype html>"
+    print "<meta charset=utf-8>"
+    print "<meta name=viewport content=\"width=device-width, initial-scale=1\">"
+    print "<title>" t "</title>"
+    print "<style>"
+    print "body{max-width:50em;margin:1em auto;padding:0 1em;font:1em/1.5 sans-serif}"
+    print "#q{width:100%;box-sizing:border-box;font:inherit;padding:.3em}"
+    print "ul{padding-left:1.2em}small,.t{color:#777;font-size:.85em}.t{margin-left:.3em}"
+    print "</style>"
+    print "<h1>" t "</h1>"
+    print "<input id=q type=search placeholder=\"filter " total " bookmarks\" hidden>"
+    printf "<p>"
+    for (i = 1; i <= count; i++)
+        if (names[i] != "")
+            printf "<a href=\"#%s\">%s</a> ", esc(names[i]), esc(names[i])
+    print "</p>"
+    for (i = 1; i <= count; i++) {
+        name = names[i] == "" ? "untagged" : names[i]
+        printf "<section><h2 id=\"%s\">%s</h2>\n<ul>\n%s</ul></section>\n", \
+               esc(name), esc(name), items[names[i]]
+    }
+    print "<script>"
+    print "var q=document.getElementById(\"q\");q.hidden=false;"
+    print "function f(){var w=q.value.toLowerCase().split(/\\s+/);"
+    print "document.querySelectorAll(\"section\").forEach(function(s){var n=0;"
+    print "s.querySelectorAll(\"li\").forEach(function(l){"
+    print "var t=(l.textContent+\" \"+l.firstChild.href).toLowerCase();"
+    print "var ok=w.every(function(x){return t.indexOf(x)>=0});l.hidden=!ok;if(ok)n++});"
+    print "s.hidden=!n})}"
+    print "q.oninput=f;q.value=new URLSearchParams(location.search).get(\"q\")||\"\";f();"
+    print "</script>"
+}'
diff --git a/bm-import b/bm-import
new file mode 100755
index 0000000..bea348d
--- /dev/null
+++ b/bm-import
@@ -0,0 +1,109 @@
+#!/bin/sh
+
+# bm-import: turn a browser's bookmarks into bm's format, on stdout.
+#
+#     bm-import bookmarks.html | bm --merge
+#
+# Reads the Netscape HTML that every browser exports, or the JSON file that
+# Chromium style browsers keep (this needs jq). Folder names become tags:
+# "Dev Tools" is dev-tools. Only http, https, ftp and file URLs are kept.
+
+usage () {
+    cat <<'EOF2'
+usage: bm-import <file> | vivaldi | chrome | brave | edge | chromium
+EOF2
+}
+
+die () {
+    printf 'bm-import: %s\n' "$*" >&2
+    exit 1
+}
+
+if [ $# -ne 1 ]; then
+    usage >&2
+    exit 2
+fi
+case $1 in
+    -h|--help) usage; exit 0 ;;
+esac
+
+src=$1
+case $src in
+    vivaldi)  src=vivaldi ;;
+    chrome)   src=google-chrome ;;
+    brave)    src=BraveSoftware/Brave-Browser ;;
+    edge)     src=microsoft-edge ;;
+    chromium) src=chromium ;;
+    *)        src= ;;
+esac
+if [ -n "$src" ]; then
+    src=${XDG_CONFIG_HOME:-$HOME/.config}/$src/Default/Bookmarks
+else
+    src=$1
+fi
+[ -r "$src" ] || die "cannot read $src"
+
+# Chromium JSON. The trash (Vivaldi has one) is left out.
+from_json () {
+    command -v jq >/dev/null 2>&1 || die 'a Chromium bookmark file needs jq'
+    jq -r '
+        def slug: ascii_downcase | gsub("[^a-z0-9]+"; "-")
+                  | sub("^-"; "") | sub("-$"; "");
+        def emit(path):
+            if .type == "url" then
+                [.url, (.name | gsub("[\t\r\n]+"; " ")),
+                 (path | unique | join(" "))] | join("\t")
+            elif (.children? | type) == "array" then
+                (path + ([.name | slug] | map(select(. != "")))) as $sub
+                | .children[] | emit($sub)
+            else empty end;
+        .roots | to_entries[]
+        | select(.key != "trash" and (.value | type) == "object")
+        | .value.children[]? | emit([])' "$1"
+}
+
+# Netscape HTML. <H3> opens a folder, </DL> closes it.
+from_html () {
+    awk '
+    function decode(s) {
+        gsub(/&lt;/, "<", s); gsub(/&gt;/, ">", s); gsub(/&quot;/, "\"", s)
+        gsub(/&#39;|&#x27;|&apos;/, "\047", s); gsub(/&nbsp;/, " ", s)
+        gsub(/&amp;/, "\\&", s)
+        gsub(/[ \t\r]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s)
+        return s
+    }
+    function slug(s) {
+        s = tolower(s)
+        gsub(/[^a-z0-9]+/, "-", s); sub(/^-/, "", s); sub(/-$/, "", s)
+        return s
+    }
+    {
+        upper = toupper($0)
+        if ((at = index(upper, "<H3")) > 0) {
+            name = substr($0, at)
+            name = substr(name, index(name, ">") + 1)
+            end = index(toupper(name), "</H3")
+            if (end) name = substr(name, 1, end - 1)
+            folder[++depth] = slug(decode(name))
+        }
+        if ((at = index(upper, "HREF=\"")) > 0) {
+            url = substr($0, at + 6)
+            rest = substr(url, index(url, "\"") + 1)
+            url = decode(substr(url, 1, index(url, "\"") - 1))
+            name = substr(rest, index(rest, ">") + 1)
+            end = index(toupper(name), "</A")
+            if (end) name = substr(name, 1, end - 1)
+            tags = ""
+            for (i = 1; i <= depth; i++)
+                if (folder[i] != "" && index(" " tags " ", " " folder[i] " ") == 0)
+                    tags = tags (tags == "" ? "" : " ") folder[i]
+            printf "%s\t%s\t%s\n", url, decode(name), tags
+        }
+        if (index(upper, "</DL>") > 0 && depth > 0) depth--
+    }' "$1"
+}
+
+case $(LC_ALL=C tr -d ' \t\r\n' < "$src" | dd bs=1 count=1 2>/dev/null) in
+    '{') from_json "$src" ;;
+    *)   from_html "$src" ;;
+esac | awk -F'\t' '$1 ~ /^(https?|ftp|file):\/\//'
diff --git a/bm-migrate b/bm-migrate
new file mode 100755
index 0000000..60166e4
--- /dev/null
+++ b/bm-migrate
@@ -0,0 +1,120 @@
+#!/bin/sh
+
+# bm-migrate: convert a bookmark file from the old sbm format
+#
+#     URL description | tag tag
+#
+# to the tab separated one bm uses now
+#
+#     URL<tab>description<tab>tag tag
+#
+# The URL is the first word, the tags are whatever follows the last " | ", and
+# a line without " | " has no tags. Comments, blank lines and lines that already
+# contain a tab are passed through, so converting twice changes nothing.
+
+usage () {
+    cat <<'EOF'
+usage: bm-migrate [-n] [file]
+  file   the bookmark file; defaults to $BOOKMARKS, then to
+         $XDG_DATA_HOME/sbm/bookmarks. "-" converts stdin to stdout.
+  -n     print the converted file instead of replacing it
+The original is kept as <file>.bak (or .bak.1, .bak.2, ... if that exists).
+EOF
+}
+
+die () {
+    printf 'bm-migrate: %s\n' "$*" >&2
+    exit 1
+}
+
+convert () {
+    awk '
+        /\t/ || /^#/ || NF == 0 { print; next }
+        {
+            url = $1
+            desc = $0
+            sub(/^[ ]*[^ ]+[ ]*/, "", desc)
+            tags = ""
+            if (match(desc, / \|[^|]*$/)) {
+                tags = substr(desc, RSTART + 2)
+                desc = substr(desc, 1, RSTART - 1)
+            }
+            gsub(/^ +| +$/, "", desc)
+            gsub(/^ +| +$/, "", tags)
+            printf "%s\t%s\t%s\n", url, desc, tags
+        }' "$@"
+}
+
+# oldlines <file>: how many lines are still in the old format.
+oldlines () {
+    awk '!/\t/ && !/^#/ && NF { n++ } END { print n + 0 }' "$1"
+}
+
+dryrun=
+while [ $# -gt 0 ]; do
+    case $1 in
+        -h|--help) usage; exit 0 ;;
+        -n|--dry-run) dryrun=1 ;;
+        -) break ;;
+        -*)
+            printf 'bm-migrate: unknown option: %s\n' "$1" >&2
+            usage >&2
+            exit 2
+            ;;
+        *) break ;;
+    esac
+    shift
+done
+if [ $# -gt 1 ]; then
+    printf 'bm-migrate: one file at a time\n' >&2
+    exit 2
+fi
+
+file=${1:-${BOOKMARKS:-${XDG_DATA_HOME:-$HOME/.local/share}/sbm/bookmarks}}
+
+if [ "$file" = - ]; then
+    convert
+    exit
+fi
+
+[ -f "$file" ] || die "no such file: $file"
+[ -r "$file" ] || die "cannot read $file"
+
+if [ -n "$dryrun" ]; then
+    convert "$file"
+    exit
+fi
+
+count=$(oldlines "$file")
+if [ "$count" -eq 0 ]; then
+    printf 'nothing to migrate in %s\n' "$file"
+    exit 0
+fi
+
+# Never overwrite an earlier backup: it may be the only copy of the original.
+backup=$file.bak
+n=0
+while [ -e "$backup" ]; do
+    n=$((n + 1))
+    backup=$file.bak.$n
+done
+cp -p "$file" "$backup" || die "could not back up to $backup"
+
+# Through cat rather than mv, so that a symlinked bookmark file stays a symlink
+# and keeps its permissions.
+# Beside the file if that directory can be written to, else in $TMPDIR;
+# noclobber (O_EXCL) keeps a planted file or symlink from being followed.
+tmp=
+for candidate in "$file.tmp.$$" "${TMPDIR:-/tmp}/sbm.$$.${file##*/}"; do
+    if (umask 077; set -C; : > "$candidate") 2>/dev/null; then
+        tmp=$candidate
+        break
+    fi
+done
+[ -n "$tmp" ] || die 'cannot create a temporary file'
+trap 'rm -f "$tmp"' EXIT INT TERM
+if ! convert "$backup" > "$tmp" || ! cat "$tmp" > "$file"; then
+    die "could not rewrite $file; the original is in $backup"
+fi
+
+printf 'migrated %d lines in %s (original kept as %s)\n' "$count" "$file" "$backup"
diff --git a/bm-title b/bm-title
new file mode 100755
index 0000000..84a17ba
--- /dev/null
+++ b/bm-title
@@ -0,0 +1,33 @@
+#!/bin/sh
+
+# bm-title: print the <title> of a web page, or nothing.
+# When it is installed, "bm --add" offers the title as the description.
+# SBM_FETCH=0 keeps it off the network.
+
+if [ $# -ne 1 ]; then
+    printf 'usage: bm-title <url>\n' >&2
+    exit 2
+fi
+[ "${SBM_FETCH:-1}" != 0 ] || exit 0
+
+# Lines are gathered until the title has been closed; awk then leaves, and
+# curl stops downloading. dd caps it at 64k for pages that are one long line
+# (it may pass a little less when its reads come up short; never more).
+curl -sL --max-time 3 -A 'Mozilla/5.0' -- "$1" 2>/dev/null \
+    | dd bs=1024 count=64 2>/dev/null | LC_ALL=C awk '
+    function title(s,    start, end) {
+        start = index(tolower(s), "<title")
+        if (!start) return
+        s = substr(s, start)
+        s = substr(s, index(s, ">") + 1)
+        end = index(tolower(s), "</title")
+        if (end) s = substr(s, 1, end - 1)
+        gsub(/&lt;/, "<", s); gsub(/&gt;/, ">", s); gsub(/&quot;/, "\"", s)
+        gsub(/&#39;|&#x27;|&apos;/, "\047", s); gsub(/&nbsp;/, " ", s)
+        gsub(/&amp;/, "\\&", s)
+        gsub(/[ \t\r]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s)
+        print s
+    }
+    { page = page " " $0 }
+    index(tolower($0), "</title") || NR >= 500 { exit }
+    END { title(page) }'
diff --git a/config.mk b/config.mk
index 1706bdb..28ad308 100644
--- a/config.mk
+++ b/config.mk
@@ -1,5 +1,14 @@
-VERSION=0.2
+VERSION=0.3
 SHELL = /bin/sh
 
 PREFIX=/usr/local
 MANPREFIX=${PREFIX}/share/man
+
+# What make install installs. bm is all you need; delete what you do not use.
+#   bm-migrate  convert a file from the old "url desc | tags" format
+#   bm-import   browser bookmarks to bm's format (Chromium files need jq)
+#   bm-check    report dead links (curl)
+#   bm-html     the bookmarks as a searchable web page
+#   bm-title    lets bm --add suggest the page title as description (curl)
+#   bm-commit   lets bm keep the file's history in git
+TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit
diff --git a/engines b/engines
new file mode 100644
index 0000000..76dfe23
--- /dev/null
+++ b/engines
@@ -0,0 +1,5 @@
+w    | https://en.wikipedia.org/w/index.php?search=%s
+gh   | https://github.com/search?q=%s
+aw   | https://wiki.archlinux.org/index.php?search=%s
+man  | https://man.archlinux.org/search?q=%s
+yt   | https://www.youtube.com/results?search_query=%s
diff --git a/test/fakefzf b/test/fakefzf
index 66b5e01..62d8979 100755
--- a/test/fakefzf
+++ b/test/fakefzf
@@ -1,13 +1,17 @@
 #!/bin/sh
 
 # Scripted stand-in for fzf, installed as "fzf" on the test PATH. Consumes one
-# line of $SBM_TEST_ANSWERS per call, like test/fakemenu:
+# line of $SBM_TEST_ANSWERS per call, like test/fakemenu. What it prints
+# depends on the options it was given, as with the real thing:
 #
-#   with --expect       key:text    prints the key, then the first row
-#                                   containing text ("enter" is the empty key)
-#   with --print-query  query:words prints the query, then the choices starting
-#                                   with each word
-#   otherwise           text        prints the first row containing text
+#   --expect             answer key:text   the typed text, the key ("enter"
+#                                          is the empty key), then the first
+#                                          row containing text, if any
+#   --multi              answer text:words the typed text, then the choices
+#                                          that start with each word
+#   --print-query alone  answer text       the typed text, then the first row
+#                                          containing it, if any
+#   none of these        answer text       the first row containing text
 #
 # An empty answer is ESC: no output, exit 130.
 
@@ -16,8 +20,9 @@
 mode=pick
 for arg in "$@"; do
     case $arg in
+        --print-query) [ "$mode" = pick ] && mode=query ;;
+        --multi)       [ "$mode" = expect ] || mode=multi ;;
         --expect=*)    mode=expect ;;
-        --print-query) mode=query ;;
     esac
 done
 
@@ -37,15 +42,19 @@ case $mode in
     expect)
         key=${answer%%:*}
         [ "$key" = enter ] && key=
-        printf '%s\n' "$key"
+        printf '%s\n%s\n' "${answer#*:}" "$key"
         row "${answer#*:}"
         ;;
-    query)
+    multi)
         printf '%s\n' "${answer%%:*}"
         for word in ${answer#*:}; do
             printf '%s\n' "$choices" | grep -- "^$word " | head -n 1
         done
         ;;
+    query)
+        printf '%s\n' "$answer"
+        row "$answer"
+        ;;
     *)
         row "$answer"
         ;;
diff --git a/test/fakemenu b/test/fakemenu
index 1027928..828b16b 100755
--- a/test/fakemenu
+++ b/test/fakemenu
@@ -18,6 +18,8 @@ case $name in
 esac
 
 choices=$(cat)
+# With $SBM_TEST_SEEN set, what each menu offered is appended to that file.
+[ -z "$SBM_TEST_SEEN" ] || printf -- '--- %s\n%s\n' "$*" "$choices" >> "$SBM_TEST_SEEN"
 answer=$(sed -n 1p "$SBM_TEST_ANSWERS")
 sed 1d "$SBM_TEST_ANSWERS" > "$SBM_TEST_ANSWERS.next"
 mv "$SBM_TEST_ANSWERS.next" "$SBM_TEST_ANSWERS"
diff --git a/test/run.sh b/test/run.sh
index 0772188..d3755b4 100755
--- a/test/run.sh
+++ b/test/run.sh
@@ -1,17 +1,25 @@
 #!/bin/sh
 
-# Non-interactive tests for bm. Menus are scripted through test/fakemenu, and
-# the clipboard and opener are stubs that write to files.
+# Non-interactive tests for bm and the bm-* tools. Menus are scripted through
+# test/fakemenu and test/fakefzf, and the clipboard and opener are stubs that
+# write to files.
 # usage: sh test/run.sh        (SBM_SH=dash sh test/run.sh to pick the shell)
 
 # Several groups point BOOKMARKS somewhere else inside a subshell on purpose.
 # shellcheck disable=SC2030,SC2031
 
 here=$(cd "$(dirname "$0")" && pwd)
-BM="${SBM_SH:-sh} $here/../bm"
+top=$(cd "$here/.." && pwd)
+BM="${SBM_SH:-sh} $top/bm"
+MIGRATE="${SBM_SH:-sh} $top/bm-migrate"
+IMPORT="${SBM_SH:-sh} $top/bm-import"
+CHECK="${SBM_SH:-sh} $top/bm-check"
+HTML="${SBM_SH:-sh} $top/bm-html"
+TITLE="${SBM_SH:-sh} $top/bm-title"
 TAB=$(printf '\t')
 
-work=$(mktemp -d "${TMPDIR:-/tmp}/sbmtest.XXXXXX") || exit 1
+work=${TMPDIR:-/tmp}/sbmtest.$$
+mkdir -m 700 "$work" || exit 1
 trap 'rm -rf "$work"' EXIT INT TERM
 
 export BOOKMARKS="$work/bookmarks"
@@ -114,7 +122,7 @@ for dup in http://example.com/docs https://example.com/docs/ \
     answers 'Second' 'code'
     $BM -a "$dup" 2>/dev/null
     rc=$?
-    eq "add rejects duplicate $dup" "$rc $(wc -l < "$BOOKMARKS" | tr -d ' ')" '1 1'
+    eq "add rejects duplicate $dup with status 3" "$rc $(wc -l < "$BOOKMARKS" | tr -d ' ')" '3 1'
 done
 answers 'Other path' 'code'
 $BM -a https://example.com/Docs 2>/dev/null
@@ -154,19 +162,25 @@ eq 'list honours the tag filter' "$($BM -t code -l | cut -f1 | paste -sd ' ' -)"
 
 eq 'tag filter matches whole tags only' "$($BM -t cod -l)" ''
 
-answers 'example'
+answers 'example' 'copy'
 $BM -t sec
-eq 'a tag filter alone implies copy' \
+eq 'a tag filter narrows the browser' \
     "$(cat "$work/clipboard")" 'https://a.example'
 
 $BM -t nosuchtag -c 2>/dev/null
 eq 'copy with an unused tag fails' "$?" '1'
 
-answers 'copy' 'Sea'
+answers 'Sea' 'copy'
 $BM
-eq 'no arguments prompts for the action' \
+eq 'no arguments: pick the bookmark, then what to do' \
     "$(cat "$work/clipboard")" 'https://c.example'
 
+answers 'Ay'
+eq '--print prints the url' "$($BM --print)" 'https://a.example'
+answers 'no such bookmark'
+$BM -c 2>/dev/null
+eq 'copy refuses text that is not a bookmark' "$?" '1'
+
 answers 'Bee'
 $BM -d >/dev/null
 eq 'delete removes only the chosen row' "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" \
@@ -187,28 +201,86 @@ kill "$watchdog" 2>/dev/null
 eq 'dmenu tag loop ends when the menu is closed' \
     "$(cat "$BOOKMARKS")" "https://dmenu.example${TAB}Escaped${TAB}sec"
 
-# ---- migrate ----
+# ---- bm-migrate ----
 
 reset
-printf '%s\n' \
-    'https://old.example Old style | code sec' \
-    'https://equwal.com  Spenser Truex'"'"'s website.' \
-    'https://pipe.example a | b | lib' \
-    "https://new.example${TAB}Already TSV${TAB}org" > "$BOOKMARKS"
-expected=$(printf '%s\n' \
+printf '%s\n' 'https://old.example Old style | code sec' > "$BOOKMARKS"
+eq 'bm warns about an old format file and names the tool' \
+    "$($BM -l 2>&1 >/dev/null | grep -c 'bm-migrate')" '1'
+
+# backups: how many backups sit beside the bookmark file.
+backups () {
+    set -- "$BOOKMARKS".bak*
+    if [ -e "$1" ]; then echo $#; else echo 0; fi
+}
+
+
+old_file () {
+    rm -f "$BOOKMARKS".bak*
+    printf '%s\n' \
+        '# my bookmarks' \
+        '' \
+        'https://old.example Old style | code sec' \
+        'https://equwal.com  Spenser Truex'"'"'s website.' \
+        'https://pipe.example a | b | lib' \
+        '  https://indented.example   spaced   out  |  org  ' \
+        'https://bare.example' \
+        'https://100.example 100%s done \n | lib' \
+        "https://new.example${TAB}Already TSV${TAB}org" > "$BOOKMARKS"
+}
+migrated=$(printf '%s\n' \
+    '# my bookmarks' \
+    '' \
     "https://old.example${TAB}Old style${TAB}code sec" \
     "https://equwal.com${TAB}Spenser Truex's website.${TAB}" \
     "https://pipe.example${TAB}a | b${TAB}lib" \
+    "https://indented.example${TAB}spaced   out${TAB}org" \
+    "https://bare.example${TAB}${TAB}" \
+    "https://100.example${TAB}100%s done \\n${TAB}lib" \
     "https://new.example${TAB}Already TSV${TAB}org")
 
-eq 'old format triggers a warning' \
-    "$($BM -l 2>&1 >/dev/null | grep -c -- '--migrate')" '1'
-$BM --migrate >/dev/null
-eq 'migrate converts old lines to TSV' "$(cat "$BOOKMARKS")" "$expected"
-eq 'migrate leaves a backup' "$(grep -c . "$BOOKMARKS.bak")" '4'
-$BM --migrate >/dev/null
-eq 'migrate is idempotent' "$(cat "$BOOKMARKS")" "$expected"
-eq 'no warning after migrating' "$($BM -l 2>&1 >/dev/null)" ''
+old_file
+original=$(cat "$BOOKMARKS")
+eq 'bm-migrate -n prints the converted file' "$($MIGRATE -n "$BOOKMARKS")" "$migrated"
+eq 'bm-migrate -n changes nothing' \
+    "$(cat "$BOOKMARKS")|$(backups)" "$original|0"
+eq 'bm-migrate - is a filter' "$($MIGRATE - < "$BOOKMARKS")" "$migrated"
+eq 'bm-migrate reports what it did' "$($MIGRATE "$BOOKMARKS")" \
+    "migrated 6 lines in $BOOKMARKS (original kept as $BOOKMARKS.bak)"
+eq 'bm-migrate converts in place' "$(cat "$BOOKMARKS")" "$migrated"
+eq 'bm-migrate keeps the original as .bak' "$(cat "$BOOKMARKS.bak")" "$original"
+eq 'bm-migrate has nothing to do the second time' "$($MIGRATE "$BOOKMARKS")" \
+    "nothing to migrate in $BOOKMARKS"
+eq 'bm-migrate then leaves file and backup alone' \
+    "$(cat "$BOOKMARKS")|$(cat "$BOOKMARKS.bak")|$(backups)" \
+    "$migrated|$original|1"
+
+printf '%s\n' 'https://late.example Added by hand | org' >> "$BOOKMARKS"
+$MIGRATE "$BOOKMARKS" >/dev/null
+eq 'bm-migrate never overwrites an earlier backup' \
+    "$(cat "$BOOKMARKS.bak")|$(grep -c late.example "$BOOKMARKS.bak.1")" "$original|1"
+
+old_file
+eq 'bm-migrate defaults to the BOOKMARKS variable' "$($MIGRATE >/dev/null; cat "$BOOKMARKS")" "$migrated"
+
+eq 'bm is quiet once the file is migrated' "$($BM -l 2>&1 >/dev/null)" ''
+
+old_file
+ln -s "$BOOKMARKS" "$work/link"
+chmod 600 "$BOOKMARKS"
+$MIGRATE "$work/link" >/dev/null
+eq 'bm-migrate keeps a symlinked file a symlink, and its mode' \
+    "$([ -L "$work/link" ] && echo link) $(find "$BOOKMARKS" -perm 600 | wc -l | tr -d ' ') $(grep -c "$TAB" "$BOOKMARKS")" \
+    'link 1 7'
+chmod 644 "$BOOKMARKS"
+
+$MIGRATE "$work/nosuchfile" 2>/dev/null
+eq 'bm-migrate fails on a missing file' "$?" '1'
+$MIGRATE --bogus 2>/dev/null
+eq 'bm-migrate rejects unknown options' "$?" '2'
+$MIGRATE a b 2>/dev/null
+eq 'bm-migrate takes one file' "$?" '2'
+rm -f "$BOOKMARKS".bak* "$work/link" "$work/link".bak*
 
 # ---- argument handling ----
 
@@ -224,11 +296,23 @@ $BM -t 2>/dev/null
 eq '-t without a tag exits 2' "$?" '2'
 
 missing=
-for flag in --copy --open --plumb --add --edit --delete --list --migrate --tag; do
+for flag in --copy --open --plumb --print --add --edit --delete --list --merge --tag --sort; do
     $BM -h | grep -q -e "$flag" || missing="$missing $flag"
 done
 eq 'help mentions every flag' "$missing" ''
 
+for gone in '--import x' --check --migrate '-s x'; do
+    # shellcheck disable=SC2086
+    $BM $gone 2>/dev/null
+    eq "bm no longer does $gone itself" "$?" '2'
+done
+
+bad=
+for script in bm bm-migrate bm-import bm-check bm-html bm-title bm-commit; do
+    [ "$(sed -n 1p "$top/$script")" = '#!/bin/sh' ] || bad="$bad $script"
+done
+eq 'every script asks for /bin/sh' "$bad" ''
+
 # ---- sorting and use counts ----
 
 seed () {
@@ -260,23 +344,39 @@ answers 'Alpha'; $BM -o
 answers 'Zed';   $BM -c
 eq 'sort used puts the most used first' "$(order -S used)" 'c b a'
 eq 'use counts are kept beside the bookmarks' \
-    "$(grep -c "^https://c.example${TAB}2${TAB}" "$BOOKMARKS.usage")" '1'
+    "$(grep -c "^https://c.example${TAB}2${TAB}[0-9]\{10,\}\$" "$BOOKMARKS.usage")" '1'
 answers 'Alpha'; $BM -d >/dev/null
 eq 'delete forgets the use count' "$(grep -c 'c.example' "$BOOKMARKS.usage")" '0'
 
-# ---- search and --print ----
-
-seed
-eq 'search matches any field, ignoring case' \
-    "$($BM -s 'ZED|sec' | cut -f1 | paste -sd ' ' -)" 'https://a.example https://b.example'
-$BM -s nothinglikethis >/dev/null
-eq 'search without a match exits 1' "$?" '1'
-eq 'search honours the tag filter' "$($BM -t code -s example | wc -l | tr -d ' ')" '2'
-answers 'Mid'
-eq '--print writes the url to stdout' "$($BM -c --print)" 'https://a.example'
-eq '--print leaves the clipboard alone' "$([ -e "$work/clipboard" ] || echo untouched)" 'untouched'
+# A writable file in a directory that is not: temporary files go to $TMPDIR.
+if [ "$(id -u)" -ne 0 ]; then
+    mkdir "$work/locked" "$work/tmp"
+    printf '%s\n' "https://keep.example${TAB}Keep${TAB}" "https://drop.example${TAB}Drop${TAB}" \
+        > "$work/locked/bookmarks"
+    : > "$work/locked/bookmarks.usage"
+    chmod 555 "$work/locked"
+    (
+        BOOKMARKS="$work/locked/bookmarks" TMPDIR="$work/tmp"
+        export BOOKMARKS TMPDIR
+        answers 'Drop'; $BM -d >/dev/null
+        answers 'Keep'; $BM -c
+        printf 'https://merged.example\n' | $BM -m >/dev/null
+    )
+    eq 'a read-only directory does not stop delete, merge or use counts' \
+        "$(cut -f1 "$work/locked/bookmarks" | paste -sd ' ' -) $(cut -f1,2 "$work/locked/bookmarks.usage")" \
+        "https://keep.example https://merged.example https://keep.example${TAB}1"
+    eq 'and no temporary file is left behind' "$(find "$work/tmp" "$work/locked" -name '*sbm*' -o -name '*.tmp.*' | wc -l | tr -d ' ')" '0'
+    ln -s "$work/locked/bookmarks" "$work/tmp/sbm.planted"
+    # tmpfile relies on this: with noclobber, ">" refuses a name that exists,
+    # a symlink included, instead of writing through it.
+    # shellcheck disable=SC2016
+    eq 'a name planted in the temporary directory is refused, not written through' \
+        "$( if ${SBM_SH:-sh} -c 'set -C; : > "$1"' sh "$work/tmp/sbm.planted" 2>/dev/null
+            then echo written; else echo refused; fi) $(grep -c . "$work/locked/bookmarks")" 'refused 2'
+    chmod 755 "$work/locked"
+fi
 
-# ---- add: fetched titles, new tags ----
+# ---- bm-title, and add with it installed ----
 
 mkdir "$work/net"
 cat > "$work/net/curl" <<'FAKE'
@@ -291,6 +391,17 @@ for arg in "$@"; do
     url=$arg
 done
 if [ -z "$probe" ]; then
+    case $url in
+        *oneline*)
+            # A minified page: one line, the title beyond the first 64k.
+            awk 'BEGIN { for (i = 0; i < 70000; i++) printf "x"; print "<title>too far</title>" }'
+            exit 0
+            ;;
+        *nearline*)
+            awk 'BEGIN { for (i = 0; i < 60000; i++) printf "x"; print "<title>near enough</title>" }'
+            exit 0
+            ;;
+    esac
     printf '<html><head>\n<meta charset="utf-8"><TITLE lang="en">\n  Fetched &amp; decoded\n  &#39;title&#39; </Title></head><body><title>no</title>'
     exit 0
 fi
@@ -305,23 +416,31 @@ FAKE
 chmod +x "$work/net/curl"
 export SBM_TEST_CURLLOG="$work/curllog"
 
+eq 'bm-title prints a decoded, squeezed title' \
+    "$(PATH="$work/net:$PATH" SBM_FETCH=1 $TITLE https://fetch.example)" "Fetched & decoded 'title'"
+eq 'bm-title stops reading a one-line page at 64k' \
+    "$(PATH="$work/net:$PATH" SBM_FETCH=1 $TITLE https://oneline.example)|$(PATH="$work/net:$PATH" SBM_FETCH=1 $TITLE https://nearline.example)" \
+    '|near enough'
+$TITLE 2>/dev/null
+eq 'bm-title wants exactly one url' "$?" '2'
+
 reset
 : > "$SBM_TEST_CURLLOG"
 answers '<default>' 'code'
-PATH="$work/net:$PATH" SBM_FETCH=1 $BM -a https://fetch.example 2>/dev/null
+PATH="$top:$work/net:$PATH" SBM_FETCH=1 $BM -a https://fetch.example 2>/dev/null
 eq 'add offers the fetched <title> as the description' \
     "$(cut -f2 "$BOOKMARKS")" "Fetched & decoded 'title'"
 
 reset
 : > "$SBM_TEST_CURLLOG"
 answers 'Mine' 'code'
-PATH="$work/net:$PATH" SBM_FETCH=1 $BM -a https://fetch.example 2>/dev/null
+PATH="$top:$work/net:$PATH" SBM_FETCH=1 $BM -a https://fetch.example 2>/dev/null
 eq 'a typed description beats the fetched title' "$(cut -f2 "$BOOKMARKS")" 'Mine'
 
 reset
 : > "$SBM_TEST_CURLLOG"
 answers 'Offline' 'code'
-PATH="$work/net:$PATH" SBM_FETCH=0 $BM -a https://fetch.example 2>/dev/null
+PATH="$top:$work/net:$PATH" SBM_FETCH=0 $BM -a https://fetch.example 2>/dev/null
 eq 'SBM_FETCH=0 never runs curl' "$(cat "$SBM_TEST_CURLLOG")" ''
 
 reset
@@ -341,39 +460,46 @@ eq 'a missing tag file is created from the first new tag' \
     "$(cat "$USERTAGS")|$(cut -f3 "$BOOKMARKS")" 'first | |first'
 cp "$here/../usertags" "$USERTAGS"
 
-# ---- import ----
+# ---- bm-import and bm --merge ----
 
 reset
 printf '%s\n' "https://old.example${TAB}Kept${TAB}org" > "$BOOKMARKS"
-eq 'import html reports its counts' \
-    "$($BM --import "$here/fixtures/netscape.html")" 'imported 3, skipped 1 duplicates'
-eq 'import html appends, keeps entities decoded and folders as tags' \
-    "$(cat "$BOOKMARKS")" "$(printf '%s\n' \
-        "https://old.example${TAB}Kept${TAB}org" \
+eq 'bm-import html prints rows: entities decoded, folders as tags, web urls only' \
+    "$($IMPORT "$here/fixtures/netscape.html")" "$(printf '%s\n' \
         "https://top.example/${TAB}Top level${TAB}" \
         "https://git.example/?a=1&b=2${TAB}Git & friends${TAB}dev-tools" \
-        "https://sh.example/posix${TAB}POSIX sh${TAB}dev-tools shell")"
-eq 'import teaches the folder tags' "$(grep -c -e '^dev-tools | $' -e '^shell | $' "$USERTAGS")" '2'
-eq 'importing twice adds nothing' \
-    "$($BM --import "$here/fixtures/netscape.html")" 'imported 0, skipped 4 duplicates'
-$BM --import "$work/nosuchfile" 2>/dev/null
-eq 'import of a missing file fails' "$?" '1'
+        "https://sh.example/posix${TAB}POSIX sh${TAB}dev-tools shell" \
+        "http://www.top.example${TAB}Top level again${TAB}")"
+eq 'bm-import leaves the bookmark file alone' "$(grep -c . "$BOOKMARKS")" '1'
+eq 'merge reports its counts' \
+    "$($IMPORT "$here/fixtures/netscape.html" | $BM --merge)" 'added 3, skipped 1 duplicates'
+eq 'merge appends only what is new' "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" \
+    'https://old.example https://top.example/ https://git.example/?a=1&b=2 https://sh.example/posix'
+eq 'merge teaches the tags' "$(grep -c -e '^dev-tools | $' -e '^shell | $' "$USERTAGS")" '2'
+eq 'merging twice adds nothing' \
+    "$($IMPORT "$here/fixtures/netscape.html" | $BM -m)" 'added 0, skipped 4 duplicates'
+eq 'merge takes a bare list of urls' \
+    "$(printf 'https://bare.example\n\n# note\n' | $BM -m; tail -n 1 "$BOOKMARKS")" \
+    "$(printf 'added 1, skipped 0 duplicates\nhttps://bare.example\t\t')"
+$IMPORT "$work/nosuchfile" 2>/dev/null
+eq 'bm-import fails on a missing file' "$?" '1'
+$IMPORT 2>/dev/null
+eq 'bm-import wants one source' "$?" '2'
 
 if command -v jq >/dev/null 2>&1; then
-    reset
-    eq 'import json reports its counts' \
-        "$($BM --import "$here/fixtures/chromium.json")" 'imported 3, skipped 1 duplicates'
-    eq 'import json skips the trash and non-web urls, tags by folder' \
-        "$(cat "$BOOKMARKS")" "$(printf '%s\n' \
+    eq 'bm-import json skips the trash and non-web urls, tags by folder' \
+        "$($IMPORT "$here/fixtures/chromium.json")" "$(printf '%s\n' \
             "https://bar.example/${TAB}Bar site${TAB}" \
             "https://blog.example/feed${TAB}A blog${TAB}news-blogs" \
-            "https://deep.example/${TAB}Deep${TAB}news-blogs tech")"
+            "https://deep.example/${TAB}Deep${TAB}news-blogs tech" \
+            "http://www.bar.example${TAB}Bar dupe${TAB}")"
 else
-    printf 'skip import json: no jq\n'
+    printf 'skip bm-import json: no jq\n'
 fi
 cp "$here/../usertags" "$USERTAGS"
 
-# ---- check ----
+# ---- bm-check ----
+
 
 reset
 printf '%s\n' \
@@ -382,19 +508,27 @@ printf '%s\n' \
     "https://moved.example${TAB}Moved${TAB}" \
     "https://nohead.example${TAB}Refuses HEAD${TAB}" \
     "https://gone.example${TAB}No answer${TAB}" > "$BOOKMARKS"
-out=$(PATH="$work/net:$PATH" $BM --check | sort)
-rc=$?
-eq 'check lists dead and moved links only' "$out" "$(printf '%s\n' \
+out=$(PATH="$work/net:$PATH" $CHECK "$BOOKMARKS" | sort)
+eq 'bm-check lists dead and moved links only' "$out" "$(printf '%s\n' \
     "000${TAB}https://gone.example" \
     "301${TAB}https://moved.example${TAB}https://new.example/" \
     "404${TAB}https://dead.example")"
-PATH="$work/net:$PATH" $BM --check >/dev/null
-eq 'check exits 1 when something is wrong' "$?" '1'
-printf '%s\n' "https://ok.example${TAB}Fine${TAB}" > "$BOOKMARKS"
-eq 'check is quiet about healthy links' "$(PATH="$work/net:$PATH" $BM --check; echo $?)" \
-    "$(printf 'all links ok\n0')"
-
-# ---- git ----
+PATH="$work/net:$PATH" $CHECK "$BOOKMARKS" >/dev/null
+eq 'bm-check exits 1 when something is wrong' "$?" '1'
+eq 'bm-check is a filter' \
+    "$($BM -l | PATH="$work/net:$PATH" $CHECK - | cut -f2 | sort | paste -sd ' ' -)" \
+    'https://dead.example https://gone.example https://moved.example'
+eq 'bm-check takes a plain list of urls' \
+    "$(printf 'https://dead.example\nhttps://ok.example\n' | PATH="$work/net:$PATH" $CHECK -)" \
+    "404${TAB}https://dead.example"
+many=$(i=0; while [ $i -lt 40 ]; do printf 'https://dead%d.example\n' $i; i=$((i + 1)); done)
+eq 'bm-check reports every link of a long list exactly once' \
+    "$(printf '%s\n' "$many" | PATH="$work/net:$PATH" $CHECK - | cut -f2 | sort -u | wc -l | tr -d ' ')" '40'
+eq 'bm-check copes with an empty list' "$(: | PATH="$work/net:$PATH" $CHECK -; echo $?)" '0'
+eq 'bm-check says nothing about healthy links' \
+    "$(printf 'https://ok.example\n' | PATH="$work/net:$PATH" $CHECK -; echo $?)" '0'
+
+# ---- bm-commit ----
 
 if command -v git >/dev/null 2>&1; then
     export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
@@ -403,6 +537,8 @@ if command -v git >/dev/null 2>&1; then
     git init -q "$repo"
     mkdir "$repo/nested"
     commits () { git -C "$repo" rev-list --count HEAD 2>/dev/null || echo 0; }
+    PATH_WAS=$PATH
+    PATH="$top:$PATH"
 
     (
         BOOKMARKS="$repo/bookmarks" USERTAGS="$repo/usertags"
@@ -439,6 +575,13 @@ if command -v git >/dev/null 2>&1; then
         SBM_GIT=0 $BM -a https://git4.example 2>/dev/null
     )
     eq 'SBM_GIT=0 never commits' "$(commits)" '3'
+    PATH=$PATH_WAS
+    (
+        BOOKMARKS="$repo/bookmarks"
+        answers 'Not installed' 'code'
+        $BM -a https://git5.example 2>/dev/null
+    )
+    eq 'without bm-commit on PATH bm does not touch git' "$(commits)" '3'
 else
     printf 'skip git: no git\n'
 fi
@@ -493,14 +636,29 @@ printf 'https://added.example\nAdded here\n' | fz 2>/dev/null
 eq 'browse: ctrl-a adds, fzf tag picker creates typed tags' \
     "$(grep added.example "$BOOKMARKS")" "https://added.example${TAB}Added here${TAB}code fresh"
 
-answers 'ctrl-a:' '' 'enter:Mid'
-printf 'https://a.example\n\n' | fz 2>/dev/null
-eq 'browse: a refused duplicate returns to the list' "$(cat "$work/opened")" 'https://a.example'
+answers 'ctrl-a:' 'enter:Mid'
+out=$(printf 'https://a.example\n\n' | fz 2>&1)
+eq 'browse: a refused duplicate is shown, then the list comes back' \
+    "$(printf '%s\n' "$out" | grep -c 'already bookmarked; press ENTER') $(cat "$work/opened")" \
+    '1 https://a.example'
+
+rm -f "$work/opened"
+answers 'enter:posix sh printf'
+fz </dev/null
+eq 'browse: text that matches nothing is searched for on the web' \
+    "$(cat "$work/opened")" 'https://duckduckgo.com/?q=posix+sh+printf'
+
+: > "$SBM_TEST_LOG"
+answers 'ctrl-s:' ''
+fz </dev/null
+eq 'browse: the preview follows the cursor' \
+    "$(grep -c -e "--preview=printf '%s.n%s.ntags: %s.nused: %s.n' {1} {2} {3} {4}" "$SBM_TEST_LOG")" '2'
 
 : > "$SBM_TEST_LOG"
 answers 'Mid'
 fz -c </dev/null
-eq 'fzf: pickers hide the raw url column' "$(grep -c -e '--with-nth=2..' "$SBM_TEST_LOG")" '1'
+eq 'fzf: rows are listed by their display column, with a live preview' \
+    "$(grep -c -e "--with-nth=5.. --preview-window=down:5:wrap --preview=printf '%s" "$SBM_TEST_LOG")" '1'
 : > "$SBM_TEST_LOG"
 answers 'Mid'
 SBM_FZF_OPTS='--height=100%' fz -c </dev/null
@@ -516,10 +674,11 @@ dm () {
 
 seed
 : > "$SBM_TEST_LOG"
-answers 'copy' 'Zed'
+answers 'Zed' 'copy'
 dm
-eq 'dmenu: no arguments asks for the action, then the bookmark' \
+eq 'dmenu: no arguments asks for the bookmark, then what to do' \
     "$(cat "$work/clipboard")" 'https://b.example'
+eq 'dmenu: the two menus' "$(sed 's/.* -p //' "$SBM_TEST_LOG" | paste -sd ' ' -)" 'bookmark: do:'
 eq 'dmenu: is called with its usual flags' "$(sed 's/ -p .*//' "$SBM_TEST_LOG" | sort -u)" \
     'dmenu -i -l 15'
 eq 'dmenu: DMENULINES is honoured' \
@@ -557,13 +716,13 @@ cp "$here/../usertags" "$USERTAGS"
 seed
 both="$work/bin:$work/fzfbin"
 : > "$SBM_TEST_LOG"
-answers 'copy' 'Zed'
+answers 'Zed' 'copy'
 ( unset SBM_MENU WAYLAND_DISPLAY; PATH="$both:$PATH" DISPLAY=:0 $BM </dev/null )
 eq 'a display means dmenu even when fzf exists' \
     "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'dmenu'
 
 : > "$SBM_TEST_LOG"
-answers 'copy' 'Zed'
+answers 'Zed' 'copy'
 ( unset SBM_MENU DISPLAY; PATH="$both:$PATH" WAYLAND_DISPLAY=wayland-0 $BM </dev/null )
 eq 'Wayland means dmenu too' "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'dmenu'
 
@@ -574,6 +733,150 @@ eq 'no display falls back to fzf' "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" '
 
 unset SBM_TEST_LOG
 
+# ---- one-screen browsing without key bindings (dmenu, custom menus) ----
+
+seed
+export SBM_TEST_SEEN="$work/seen"
+
+answers 'Zed' 'open'
+$BM
+eq 'browse: pick, then open' "$(cat "$work/opened")" 'https://b.example'
+
+: > "$SBM_TEST_SEEN"
+answers 'Zed' 'copy'
+$BM
+eq 'browse: the second menu shows the bookmark in full, below the actions' \
+    "$(sed -n '/^--- pick do:/,$p' "$SBM_TEST_SEEN" | sed 1d | paste -sd '|' -)" \
+    'open|copy|edit|delete|  https://b.example|  Zed|  tags: code lib|  used: 1'
+eq 'browse: the list starts with [add] and hides the use counts' \
+    "$(sed -n '2,3p' "$SBM_TEST_SEEN" | paste -sd '|' -)" \
+    "[add]|https://b.example${TAB}Zed${TAB}code lib"
+
+rm -f "$work/opened"
+answers 'Zed' '' 'Mid' 'open'
+$BM
+eq 'browse: closing the second menu goes back to the list' \
+    "$(cat "$work/opened")" 'https://a.example'
+
+answers 'Zed' '  tags: code lib' 'Mid' 'open'
+$BM
+eq 'browse: picking a detail line does nothing' "$(cat "$work/opened")" 'https://a.example'
+
+answers 'Alpha' 'delete' ''
+$BM
+eq 'browse: delete comes back to the list, closing the list leaves' \
+    "$? $(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" '1 https://b.example https://a.example'
+
+answers '[add]' 'https://viamenu.example' 'Via menu' 'code' ''
+$BM 2>/dev/null
+eq 'browse: [add] adds and comes back' "$(grep -c "^https://viamenu.example${TAB}Via menu${TAB}code$" "$BOOKMARKS")" '1'
+
+: > "$SBM_TEST_SEEN"
+answers '[add]' 'https://www.a.example/' 'seen' ''
+$BM 2>/dev/null
+eq 'browse: a refused duplicate is announced in a menu' \
+    "$(grep -c '^--- ask already bookmarked; press ENTER' "$SBM_TEST_SEEN")" '1'
+
+: > "$SBM_TEST_SEEN"
+answers '[add]' '' ''
+$BM 2>/dev/null
+eq 'browse: a cancelled add just comes back' \
+    "$(grep -c 'already bookmarked' "$SBM_TEST_SEEN") $(grep -c '^--- pick bookmark:' "$SBM_TEST_SEEN")" '0 2'
+unset SBM_TEST_SEEN
+
+# ---- web search from the bookmark menu ----
+
+seed
+rm -f "$work/opened" "$BOOKMARKS.usage"
+printf '%s\n' 'w   | https://en.wikipedia.org/w/index.php?search=%s' \
+              'gh | https://github.com/search?q=%s&type=repositories' > "$work/engines"
+export SBM_ENGINES="$work/engines"
+
+# went <typed text> [bm arguments]: where typing that into the menu leads.
+went () {
+    typed=$1
+    shift
+    rm -f "$work/opened"
+    answers "$typed"
+    $BM "$@" 2>/dev/null
+    cat "$work/opened" 2>/dev/null
+}
+
+eq 'open: a bookmark is opened' "$(went Zed -o)" 'https://b.example'
+eq 'open: other words are searched for' "$(went 'posix sh printf' -o)" \
+    'https://duckduckgo.com/?q=posix+sh+printf'
+eq 'open: the query is url-encoded, byte by byte' "$(went 'c++ & más?' -o)" \
+    'https://duckduckgo.com/?q=c%2B%2B+%26+m%C3%A1s%3F'
+eq 'open: one word is searched for too' "$(went suckless -o)" \
+    'https://duckduckgo.com/?q=suckless'
+eq 'open: something with a dot is an address' "$(went 'example.org/a?b=c' -o)" \
+    'https://example.org/a?b=c'
+eq 'open: a url is opened as it is' "$(went 'ftp://files.example/x' -o)" 'ftp://files.example/x'
+eq 'open: an engine keyword picks the engine' "$(went 'w dynamic menu' -o)" \
+    'https://en.wikipedia.org/w/index.php?search=dynamic+menu'
+eq 'open: an engine url may go on after the query' "$(went 'gh dmenu' -o)" \
+    'https://github.com/search?q=dmenu&type=repositories'
+eq 'open: a keyword alone is just a word' "$(went w -o)" 'https://duckduckgo.com/?q=w'
+eq 'open: an unknown keyword is part of the query' "$(went 'zz top' -o)" \
+    'https://duckduckgo.com/?q=zz+top'
+eq 'open: SBM_SEARCH sets the default engine' \
+    "$(SBM_SEARCH='https://search.example/?s=%s' went 'two words' -o)" \
+    'https://search.example/?s=two+words'
+eq 'open: works without an engines file' "$(SBM_ENGINES="$work/none" went 'w dynamic menu' -o)" \
+    'https://duckduckgo.com/?q=w+dynamic+menu'
+eq 'browse: typed text is searched for as well' "$(went 'w dmenu')" \
+    'https://en.wikipedia.org/w/index.php?search=dmenu'
+eq 'only bookmarks are counted as used' "$(cut -f1 "$BOOKMARKS.usage")" 'https://b.example'
+went '' -o >/dev/null
+eq 'open: closing the menu exits 1' "$?" '1'
+unset SBM_ENGINES
+
+# ---- bm-html ----
+
+reset
+printf '%s\n' \
+    "https://b.example/?a=1&b=2${TAB}B <b>bold</b> & \"quoted\"${TAB}code lib" \
+    "https://a.example${TAB}${TAB}" \
+    '# a comment' \
+    "javascript:alert(1)${TAB}Bookmarklet${TAB}code" \
+    "https://c.example/x${TAB}see${TAB}code" > "$BOOKMARKS"
+page=$($HTML -t 'My <marks>' "$BOOKMARKS")
+eq 'bm-html lists a bookmark under each of its tags, and the untagged' \
+    "$(printf '%s\n' "$page" | grep -c '^<li>') $(printf '%s\n' "$page" | grep '<h2' | sed 's/.*id="\([^"]*\)".*/\1/' | paste -sd ' ' -)" \
+    '4 code lib untagged'
+eq 'bm-html escapes text and attributes' \
+    "$(printf '%s\n' "$page" | grep -c '<li><a href="https://b.example/?a=1&amp;b=2">B &lt;b&gt;bold&lt;/b&gt; &amp; &quot;quoted&quot;</a> <small>b.example</small>')" '2'
+eq 'bm-html escapes the title' "$(printf '%s\n' "$page" | grep -c '<title>My &lt;marks&gt;</title>')" '1'
+eq 'bm-html never links to script' "$(printf '%s\n' "$page" | grep -ci 'javascript:')" '0'
+eq 'bm-html shows the url when there is no description' \
+    "$(printf '%s\n' "$page" | grep -c '<a href="https://a.example">https://a.example</a>')" '1'
+eq 'bm-html sorts by description within a tag' \
+    "$(printf '%s\n' "$page" | sed -n '/id="code"/,/<\/ul>/p' | grep -c .) $(printf '%s\n' "$page" | sed -n '/id="code"/,/<\/ul>/p' | sed -n 3p | grep -c 'B &lt;b')" '5 1'
+eq 'bm-html has one inline script, a filter box hidden without it, and ?q=' \
+    "$(printf '%s\n' "$page" | grep -c '<script>') $(printf '%s\n' "$page" | grep -c '<input id=q type=search placeholder="filter 3 bookmarks" hidden>') $(printf '%s\n' "$page" | grep -c 'URLSearchParams(location.search).get("q")')" \
+    '1 1 1'
+eq 'bm-html loads nothing from elsewhere' "$(printf '%s\n' "$page" | grep -c -e ' src=' -e '<link' -e '@import')" '0'
+eq 'bm-html is a filter' "$($BM -t lib -l | $HTML - | grep -c '^<li>')" '2'
+eq 'bm-html reads the bookmark file by default, whatever stdin is' \
+    "$($HTML </dev/null | grep -c '^<li>')" '4'
+eq 'bm-check reads the bookmark file by default too' \
+    "$(PATH="$work/net:$PATH" $CHECK </dev/null; echo $?)" '0'
+$HTML --bogus 2>/dev/null </dev/null
+eq 'bm-html rejects unknown options' "$?" '2'
+if command -v node >/dev/null 2>&1; then
+    printf '%s\n' "$page" | sed -n '/<script>/,/<\/script>/p' | sed '1d;$d' > "$work/page.js"
+    node --check "$work/page.js" 2>/dev/null
+    eq 'bm-html: the script parses' "$?" '0'
+fi
+
+# ---- make install ----
+
+if command -v make >/dev/null 2>&1; then
+    make -s -C "$top" install DESTDIR="$work/dest" PREFIX=/usr TOOLS='bm bm-html' >/dev/null
+    eq 'make install installs what TOOLS names, and only that' \
+        "$(cd "$work/dest/usr/bin" && for f in *; do [ -x "$f" ] && printf '%s ' "$f"; done)" 'bm bm-html '
+fi
+
 # ---- defaults ----
 
 (