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(/</, "<", s); gsub(/>/, ">", s); gsub(/"/, "\"", s)
- gsub(/'|'|'/, "\047", s); gsub(/ /, " ", s)
- gsub(/&/, "\\&", 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(/&/, "\\&", s); gsub(/</, "\\<", s); gsub(/>/, "\\>", s)
+ gsub(/"/, "\\"", 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(/</, "<", s); gsub(/>/, ">", s); gsub(/"/, "\"", s)
+ gsub(/'|'|'/, "\047", s); gsub(/ /, " ", s)
+ gsub(/&/, "\\&", 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(/</, "<", s); gsub(/>/, ">", s); gsub(/"/, "\"", s)
+ gsub(/'|'|'/, "\047", s); gsub(/ /, " ", s)
+ gsub(/&/, "\\&", 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 & decoded\n 'title' </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&b=2">B <b>bold</b> & "quoted"</a> <small>b.example</small>')" '2'
+eq 'bm-html escapes the title' "$(printf '%s\n' "$page" | grep -c '<title>My <marks></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 <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 ----
(