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


bm (21717 bytes)

1 #!/bin/sh
2 
3 # bm: bookmarks in a plain file, picked with dmenu (or fzf).
4 #
5 #     URL<tab>description<tab>tag tag tag
6 #
7 # bm owns that file and nothing else. Importing, link checking, the web page
8 # and so on are separate bm-* filters; see README.
9 
10 DMENULINES=${DMENULINES:-15}
11 DATADIR=${XDG_DATA_HOME:-$HOME/.local/share}/sbm
12 BOOKMARKS=${BOOKMARKS:-$DATADIR/bookmarks}
13 USERTAGS=${USERTAGS:-$DATADIR/usertags}
14 ENGINES=${SBM_ENGINES:-$DATADIR/engines}
15 SEARCH=${SBM_SEARCH:-https://duckduckgo.com/?q=%s}
16 USAGE=$BOOKMARKS.usage
17 WLOCK=$BOOKMARKS.lock
18 locked=
19 TAB=$(printf '\t')
20 
21 # Two URLs are the same bookmark when they differ only in scheme, a leading
22 # www., trailing slashes or the case of the host.
23 AWK_NORM='
24 function norm(u,    host, rest, i) {
25     sub(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "", u)
26     i = index(u, "/")
27     if (i) { host = substr(u, 1, i - 1); rest = substr(u, i) }
28     else   { host = u; rest = "" }
29     host = tolower(host)
30     sub(/^www\./, "", host)
31     sub(/\/+$/, "", rest)
32     return host rest
33 }'
34 
35 die () {
36     printf 'bm: %s\n' "$*" >&2
37     exit 1
38 }
39 
40 have () {
41     command -v "$1" >/dev/null 2>&1
42 }
43 
44 usage () {
45     cat <<'EOF'
46 usage: bm [-t tag] [-S order] [action]
47 Without an action: browse. Pick a bookmark, then what to do with it. Text
48 that is no bookmark is opened as an address, or searched for on the web.
49   -o, --open        open a bookmark, an address or a web search
50                     (-p, --plumb is the same)
51   -c, --copy        copy a bookmark's URL to the clipboard
52       --print       print a bookmark's URL
53   -a, --add [url]   add a bookmark; the url defaults to the selection
54   -e, --edit        open the bookmark file in $EDITOR at a bookmark
55   -d, --delete      delete a bookmark
56   -l, --list        print the bookmarks
57   -m, --merge       add the bookmarks on stdin, skipping the known ones
58   -t, --tag <tag>   only consider bookmarks with this tag
59   -S, --sort <o>    used (default), recent, url, desc or tag
60   -h, --help
61 EOF
62 }
63 
64 # ---- external programs: menu, clipboard, opener ----
65 
66 # dmenu when there is a display to draw it on and bm does not run in a
67 # terminal, for example from a key of the window manager. In a terminal fzf
68 # is the menu, because its preview shows the page of the bookmark under the
69 # cursor. fzf also stands in when dmenu is not installed.
70 pickmenu () {
71     [ -n "$SBM_MENU" ] && return
72     if [ -n "$DISPLAY$WAYLAND_DISPLAY" ] && ! { [ -t 0 ] && [ -t 2 ]; }; then
73         order='dmenu fzf'
74     else
75         order='fzf dmenu'
76     fi
77     for menu in $order; do
78         if have "$menu"; then
79             SBM_MENU=$menu
80             return
81         fi
82     done
83     # shellcheck disable=SC2016
84     die 'no menu program found: install dmenu or fzf, or set $SBM_MENU'
85 }
86 
87 # $SBM_MENU may carry arguments, so it is split on purpose.
88 custommenu () {
89     # shellcheck disable=SC2086
90     $SBM_MENU "$@"
91 }
92 
93 fzfcmd () {
94     # shellcheck disable=SC2086
95     fzf -i --reverse --height=40% $SBM_FZF_OPTS "$@"
96 }
97 
98 # fzfpick <fzf options>: the chosen line, or else what was typed, which is
99 # what dmenu gives by itself.
100 fzfpick () {
101     out=$(fzfcmd --print-query "$@")
102     chosen=$(printf '%s\n' "$out" | sed -n 2p)
103     printf '%s\n' "${chosen:-$(printf '%s\n' "$out" | sed -n 1p)}"
104 }
105 
106 # pick <prompt>: choices on stdin; the chosen line, or the typed text.
107 pick () {
108     case $SBM_MENU in
109         fzf)   fzfpick --prompt="$1 " ;;
110         dmenu) dmenu -i -l "$DMENULINES" -p "$1" ;;
111         *)     custommenu pick "$1" ;;
112     esac
113 }
114 
115 # pickmulti <prompt>: like pick, for any number of lines.
116 pickmulti () {
117     choices=$(cat)
118     case $SBM_MENU in
119         fzf)
120             while :; do
121                 out=$(printf '%s\n' "$choices" | fzfcmd --multi --print-query \
122                       --prompt="$1 " --header='TAB marks, ENTER confirms. No match: ENTER creates the typed tag, ESC finishes.')
123                 chosen=$(printf '%s\n' "$out" | sed 1d)
124                 if [ -n "$chosen" ]; then
125                     printf '%s\n' "$chosen"
126                     break
127                 fi
128                 typed=$(printf '%s\n' "$out" | sed -n 1p)
129                 [ -n "$typed" ] || break
130                 printf '%s\n' "$typed"
131             done
132             ;;
133         dmenu)
134             while :; do
135                 one=$(printf 'done | (choose when done)\n%s\n' "$choices" \
136                       | dmenu -i -l "$DMENULINES" -p "$1")
137                 # An empty answer means the menu was closed.
138                 [ -z "$one" ] || [ "${one%% *}" = 'done' ] && break
139                 printf '%s\n' "$one"
140             done
141             ;;
142         *)  printf '%s\n' "$choices" | custommenu multi "$1" ;;
143     esac
144 }
145 
146 # ask <prompt> [default]: free text on stdout.
147 ask () {
148     case $SBM_MENU in
149         fzf)
150             printf '%s%s: ' "$1" "${2:+ [$2]}" >&2
151             IFS= read -r reply || reply=
152             printf '%s\n' "${reply:-$2}"
153             ;;
154         dmenu)
155             if [ -n "$2" ]; then printf '%s\n' "$2"; fi | dmenu -p "$1:"
156             ;;
157         *)
158             if [ -n "$2" ]; then printf '%s\n' "$2"; fi | custommenu ask "$1"
159             ;;
160     esac
161 }
162 
163 clip_out () {
164     # shellcheck disable=SC2086,SC2016
165     if   [ -n "$SBM_COPY" ]; then $SBM_COPY
166     elif [ -n "$WAYLAND_DISPLAY" ] && have wl-copy; then wl-copy
167     elif have xclip; then xclip -i -selection clipboard
168     elif have xsel; then xsel -ib
169     else die 'no clipboard program found: set $SBM_COPY'
170     fi
171 }
172 
173 # The primary selection first, then the clipboard. Never fatal: without
174 # either there is just no URL to suggest.
175 clip_in () {
176     # shellcheck disable=SC2086
177     if   [ -n "$SBM_PASTE" ]; then $SBM_PASTE
178     elif [ -n "$WAYLAND_DISPLAY" ] && have wl-paste; then
179         wl-paste -n -p; echo; wl-paste -n
180     elif have xclip; then
181         xclip -o; echo; xclip -o -selection clipboard
182     elif have xsel; then
183         xsel -op; echo; xsel -ob
184     fi 2>/dev/null
185 }
186 
187 opener () {
188     # shellcheck disable=SC2086,SC2016
189     if   [ -n "$SBM_OPEN" ]; then $SBM_OPEN "$1"
190     elif have xdg-open; then xdg-open "$1"
191     else die 'no opener found: set $SBM_OPEN'
192     fi
193 }
194 
195 # ---- the bookmark file ----
196 
197 # Bookmarks restricted to $tagfilter and ordered by $sortorder, each followed
198 # by a fourth field: how often it was used.
199 rows () {
200     SBM_TAG=$tagfilter SBM_ORDER=$sortorder SBM_USAGE=$USAGE awk -F'\t' '
201         BEGIN {
202             while ((getline line < ENVIRON["SBM_USAGE"]) > 0) {
203                 split(line, f, "\t")
204                 used[f[1]] = f[2]
205             }
206             order = ENVIRON["SBM_ORDER"]
207         }
208         NF == 0 || /^#/ { next }
209         ENVIRON["SBM_TAG"] != "" {
210             hit = 0
211             n = split($3, tags, " ")
212             for (i = 1; i <= n; i++)
213                 if (tags[i] == ENVIRON["SBM_TAG"]) hit = 1
214             if (!hit) next
215         }
216         {
217             if (order == "used")        key = sprintf("%09d", 999999999 - used[$1])
218             else if (order == "recent") key = sprintf("%09d", 999999999 - NR)
219             else if (order == "desc")   key = tolower($2)
220             else if (order == "tag")    key = tolower($3)
221             else                        key = ""
222             printf "%s\t%s\t%s\t%s\t%s\t%d\n", key, tolower($1), $1, $2, $3, used[$1]
223         }' "$BOOKMARKS" | LC_ALL=C sort -t"$TAB" -k1,1 -k2,2 | cut -f3-
224 }
225 
226 # details <url>: a bookmark in full, one field per line.
227 details () {
228     rows | SBM_URL=$1 awk -F'\t' '$1 == ENVIRON["SBM_URL"] {
229         printf "%s\n%s\ntags: %s\nused: %d\n", $1, $2, $3, $4
230         exit
231     }'
232 }
233 
234 is_bookmark () {
235     SBM_URL=$1 awk -F'\t' '$1 == ENVIRON["SBM_URL"] { found = 1; exit }
236         END { exit !found }' "$BOOKMARKS"
237 }
238 
239 # For fzf a fifth field is added, and it is all that is listed. The first
240 # four feed the preview, which follows the cursor: the bookmark in full, then
241 # the text of its page when bm-page is installed. fzf stops the preview when
242 # the cursor moves on, so the short sleep keeps a fast scroll from fetching
243 # each page on the way. A sleep that knows no fractions fails and skips it.
244 PREVIEW="printf '%s\n%s\ntags: %s\nused: %s\n\n' {1} {2} {3} {4}; sleep 0.3 2>/dev/null; bm-page {1} 2>/dev/null"
245 fzfrows () {
246     awk -F'\t' 'NF {
247         desc = $2
248         if (length(desc) > 44) desc = substr(desc, 1, 42) ".."
249         printf "%s\t%-44s  %-18s  %s\n", $0, desc, $3, $1
250     }'
251 }
252 
253 # withpreview <fzf command and options>: for the output of fzfrows.
254 withpreview () {
255     "$@" --delimiter="$TAB" --with-nth=5.. \
256          --preview-window=right:50%:wrap --preview="$PREVIEW"
257 }
258 
259 # choose <prompt>: a bookmark's URL, or whatever else the user typed.
260 choose () {
261     if [ "$SBM_MENU" = fzf ]; then
262         rows | fzfrows | withpreview fzfpick --prompt="$1 "
263     else
264         rows | cut -f1-3 | pick "$1"
265     fi | cut -f1
266 }
267 
268 # choose_url <prompt>: like choose, but only a bookmark will do.
269 choose_url () {
270     if [ -z "$(rows)" ]; then
271         [ -n "$tagfilter" ] && die "no bookmarks tagged '$tagfilter'"
272         die 'no bookmarks yet; add one with: bm --add'
273     fi
274     url=$(choose "$1")
275     [ -n "$url" ] || exit 1
276     is_bookmark "$url" || die "not a bookmark: $url"
277     printf '%s\n' "$url"
278 }
279 
280 # find_dup <url>: print the bookmark that is the same page as <url>, if any.
281 find_dup () {
282     SBM_URL=$1 awk -F'\t' "$AWK_NORM"'
283         BEGIN { want = norm(ENVIRON["SBM_URL"]) }
284         NF && !/^#/ && norm($1) == want { print; exit }' "$BOOKMARKS"
285 }
286 
287 # tmpfile <file>: create an empty temporary file and print its name. Beside
288 # <file> if that directory can be written to, else in $TMPDIR. noclobber makes
289 # the shell create it with O_EXCL, so a file or symlink planted under that
290 # name in a shared /tmp is refused rather than followed: mktemp, in POSIX.
291 tmpfile () {
292     for candidate in "$1.tmp.$$" "${TMPDIR:-/tmp}/sbm.$$.${1##*/}"; do
293         if (umask 077; set -C; : > "$candidate") 2>/dev/null; then
294             printf '%s\n' "$candidate"
295             return 0
296         fi
297     done
298     return 1
299 }
300 
301 # rewrite <file>: replace <file> with stdin. Through cat, not mv, so that a
302 # symlinked file stays a symlink and keeps its mode.
303 rewrite () {
304     tmp=$(tmpfile "$1") || die "cannot create a temporary file for $1"
305     cat > "$tmp" && cat "$tmp" > "$1"
306     rc=$?
307     rm -f "$tmp"
308     return $rc
309 }
310 
311 # lock: take the write lock of the bookmark file, a directory beside it. bm
312 # holds it while it changes the file, and bm-sync while it reads or writes
313 # the file, so that neither writes over a change of the other. A lock whose
314 # process is gone is taken over. After $SBM_LOCK_WAIT seconds (default 10)
315 # bm stops.
316 lock () {
317     waited=0
318     until mkdir "$WLOCK" 2>/dev/null; do
319         pid=$(cat "$WLOCK/pid" 2>/dev/null)
320         if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
321             [ "$(cat "$WLOCK/pid" 2>/dev/null)" != "$pid" ] || rm -rf "$WLOCK"
322             continue
323         fi
324         [ "$waited" -lt "${SBM_LOCK_WAIT:-10}" ] \
325             || die "$BOOKMARKS is in use (bm -e or bm-sync). If neither runs, remove $WLOCK"
326         sleep 1
327         waited=$((waited + 1))
328     done
329     echo $$ > "$WLOCK/pid"
330     locked=1
331     # Also in the subshell of add_inside, which does not keep these traps.
332     trap unlock EXIT
333     trap 'exit 1' HUP INT TERM
334 }
335 
336 unlock () {
337     [ -z "$locked" ] || rm -rf "$WLOCK"
338     locked=
339 }
340 
341 # bump <url>: count a use, for the "used" order. The counts live beside the
342 # bookmark file, which stays a plain list: url, count, time of last use.
343 # srand() returns the previous seed, and the first seed is the time of day:
344 # that is "date +%s" for a POSIX that has no %s.
345 bump () {
346     [ -e "$USAGE" ] || : > "$USAGE" || return 0
347     SBM_URL=$1 awk -F'\t' '
348         BEGIN { OFS = "\t"; srand(); now = srand() }
349         $1 == ENVIRON["SBM_URL"] { $2 = $2 + 1; $3 = now; seen = 1 }
350         { print }
351         END { if (!seen) print ENVIRON["SBM_URL"], 1, now }
352     ' "$USAGE" | rewrite "$USAGE"
353 }
354 
355 # learn_tags: tag names on stdin; the ones $USERTAGS lacks are appended to it.
356 learn_tags () {
357     [ -e "$USERTAGS" ] || : > "$USERTAGS"
358     while IFS= read -r name; do
359         [ -n "$name" ] || continue
360         sed 's/[ |].*//' "$USERTAGS" | grep -Fxq -- "$name" \
361             || printf '%s | \n' "$name" >> "$USERTAGS"
362     done
363 }
364 
365 # changed <message>: the file was written. bm-commit, where installed, keeps
366 # its history in git. bm-sync, where installed, tells your other machines; it
367 # runs in the background, so that a slow network never holds the menu up.
368 changed () {
369     if have bm-commit; then bm-commit "$1"; fi
370     if have bm-sync; then bm-sync -q >/dev/null 2>&1 & fi
371 }
372 
373 # ---- actions ----
374 
375 copy_url () {
376     bump "$1"
377     printf '%s' "$1" | clip_out
378 }
379 
380 open_url () {
381     bump "$1"
382     opener "$1"
383 }
384 
385 # The editor reads the file when it starts and writes it when you save, so
386 # bm holds the lock until the editor ends. bm-sync then waits, and syncs the
387 # edit after it.
388 edit_url () {
389     lock
390     line=$(SBM_URL=$1 awk -F'\t' \
391            '$1 == ENVIRON["SBM_URL"] { print NR; exit }' "$BOOKMARKS")
392     ${VISUAL:-${EDITOR:-vi}} "+${line:-1}" "$BOOKMARKS"
393     unlock
394     changed 'bm: edit'
395 }
396 
397 delete_url () {
398     lock
399     for file in "$BOOKMARKS" "$USAGE"; do
400         [ -e "$file" ] || continue
401         SBM_URL=$1 awk -F'\t' '$1 != ENVIRON["SBM_URL"]' "$file" \
402             | rewrite "$file" || die "could not rewrite $file"
403     done
404     unlock
405     changed "bm: delete $1"
406 }
407 
408 # urlencode <text>: for a query string. awk has no ord(), hence the table.
409 urlencode () {
410     printf '%s' "$1" | LC_ALL=C awk '
411         BEGIN { for (i = 1; i < 256; i++) ord[sprintf("%c", i)] = i }
412         {
413             for (i = 1; i <= length($0); i++) {
414                 c = substr($0, i, 1)
415                 if (c ~ /[A-Za-z0-9._~-]/) out = out c
416                 else if (c == " ")         out = out "+"
417                 else                       out = out sprintf("%%%02X", ord[c])
418             }
419         }
420         END { printf "%s", out }'
421 }
422 
423 # go <text>: open text that is not a bookmark. One word with "://" or a dot
424 # in it is an address. Anything else is a web search: by the engine whose
425 # keyword is the first word ($ENGINES holds "keyword | url with %s" lines),
426 # or else by $SEARCH.
427 go () {
428     case $1 in
429         *' '*) ;;
430         *://*) opener "$1"; return ;;
431         *.*)   opener "https://$1"; return ;;
432     esac
433     engine=$SEARCH
434     query=$1
435     if [ "${1%% *}" != "$1" ] && [ -r "$ENGINES" ]; then
436         found=$(SBM_KEY=${1%% *} awk -F' *[|] *' \
437                 '$1 == ENVIRON["SBM_KEY"] { print $2; exit }' "$ENGINES")
438         if [ -n "$found" ]; then
439             engine=$found
440             query=${1#* }
441         fi
442     fi
443     opener "$(printf '%s\n' "$engine" | sed "s|%s|$(urlencode "$query")|")"
444 }
445 
446 plumb () {
447     url=$(choose 'open:')
448     [ -n "$url" ] || exit 1
449     if is_bookmark "$url"; then open_url "$url"; else go "$url"; fi
450 }
451 
452 # Exit status 3 means "already bookmarked".
453 add () {
454     url=$1
455     if [ -z "$url" ]; then
456         url=$(ask 'url' "$(clip_in | grep '://' | head -n 1)")
457     fi
458     url=$(printf '%s' "$url" | tr -d '[:space:]')
459     [ -n "$url" ] || exit 1
460 
461     dup=$(find_dup "$url")
462     if [ -n "$dup" ]; then
463         printf 'bm: already bookmarked:\n%s\n' "$dup" >&2
464         exit 3
465     fi
466 
467     # bm-title, where installed, fetches the page's title.
468     title=
469     if have bm-title; then title=$(bm-title "$url"); fi
470     desc=$(ask 'description' "$title" | tr '\t' ' ')
471 
472     [ -e "$USERTAGS" ] || : > "$USERTAGS"
473     names=$(pickmulti 'tags:' < "$USERTAGS" | sed 's/[ |].*//' | sed '/^$/d' \
474             | sort -u)
475     printf '%s\n' "$names" | learn_tags
476     tags=$(printf '%s\n' "$names" | paste -sd ' ' -)
477 
478     lock
479     printf '%s\t%s\t%s\n' "$url" "$desc" "$tags" >> "$BOOKMARKS"
480     unlock
481     changed "bm: add $url"
482 }
483 
484 # merge: bookmark lines on stdin (a bare URL per line will do); the ones not
485 # known yet are appended. This is how the output of bm-import gets in.
486 merge () {
487     new=$(tmpfile "$BOOKMARKS") || die 'cannot create a temporary file'
488     lock
489     SBM_FILE=$BOOKMARKS SBM_NEW=$new awk -F'\t' "$AWK_NORM"'
490         BEGIN {
491             while ((getline line < ENVIRON["SBM_FILE"]) > 0) {
492                 split(line, f, "\t")
493                 seen[norm(f[1])] = 1
494             }
495         }
496         NF == 0 || /^#/ { next }
497         seen[norm($1)]++ { skipped++; next }
498         { printf "%s\t%s\t%s\n", $1, $2, $3 > ENVIRON["SBM_NEW"]; added++ }
499         END { printf "added %d, skipped %d duplicates\n", added, skipped }'
500     cut -f3 "$new" | tr ' ' '\n' | sort -u | learn_tags
501     cat "$new" >> "$BOOKMARKS"
502     unlock
503     rm -f "$new"
504     changed 'bm: merge'
505 }
506 
507 nextorder () {
508     case $sortorder in
509         used)   sortorder=recent ;;
510         recent) sortorder=url ;;
511         url)    sortorder=desc ;;
512         desc)   sortorder=tag ;;
513         *)      sortorder=used ;;
514     esac
515 }
516 
517 # add exits, which must not end a browsing session; and when it refuses a
518 # duplicate the user has to get to see that before the list comes back.
519 add_inside () {
520     ( add '' )
521     if [ $? -eq 3 ]; then
522         ask 'already bookmarked; press ENTER' >/dev/null
523     fi
524 }
525 
526 # browse_fzf: fzf can bind keys, so one list does everything. Opening and
527 # copying leave it, the other keys come back to it.
528 browse_fzf () {
529     while :; do
530         header="ENTER open  ^Y copy  ^A add  ^D delete  ^E edit  ^S sort [$sortorder]"
531         out=$(rows | fzfrows | withpreview fzfcmd --print-query \
532               --expect=ctrl-y,ctrl-a,ctrl-d,ctrl-e,ctrl-s \
533               --header="$header" --prompt='bookmark: ')
534         [ -n "$out" ] || exit 1
535         typed=$(printf '%s\n' "$out" | sed -n 1p)
536         key=$(printf '%s\n' "$out" | sed -n 2p)
537         url=$(printf '%s\n' "$out" | sed -n 3p | cut -f1)
538 
539         case $key in
540             ctrl-a) add_inside; continue ;;
541             ctrl-s) nextorder; continue ;;
542         esac
543         if [ -z "$url" ]; then
544             [ -n "$key" ] && continue
545             [ -n "$typed" ] || exit 1
546             go "$typed"
547             exit 0
548         fi
549         case $key in
550             ctrl-y) copy_url "$url"; exit 0 ;;
551             ctrl-e) edit_url "$url" ;;
552             ctrl-d)
553                 case $(ask "delete $url? (y/N)") in
554                     y|Y|yes) delete_url "$url" ;;
555                 esac
556                 ;;
557             *)      open_url "$url"; exit 0 ;;
558         esac
559     done
560 }
561 
562 # browse_menu: dmenu can neither bind keys nor preview, so a second menu does
563 # both: it shows the bookmark in full and asks what to do with it. Closing it
564 # goes back to the list; closing the list leaves.
565 browse_menu () {
566     while :; do
567         chosen=$( { printf '[add]\n'; rows | cut -f1-3; } | pick 'bookmark:')
568         [ -n "$chosen" ] || exit 1
569         if [ "$chosen" = '[add]' ]; then
570             add_inside
571             continue
572         fi
573         url=$(printf '%s\n' "$chosen" | cut -f1)
574         if ! is_bookmark "$url"; then
575             go "$chosen"
576             exit 0
577         fi
578         action=$( { printf 'open\ncopy\nedit\ndelete\n'
579                     details "$url" | sed 's/^/  /'; } | pick 'do:')
580         case $action in
581             open)   open_url "$url"; exit 0 ;;
582             copy)   copy_url "$url"; exit 0 ;;
583             edit)   edit_url "$url" ;;
584             delete) delete_url "$url" ;;
585         esac
586     done
587 }
588 
589 # ---- main ----
590 
591 setaction () {
592     if [ -n "$action" ]; then
593         printf 'bm: only one action at a time\n' >&2
594         exit 2
595     fi
596     action=$1
597 }
598 
599 # needarg <flag> <count of remaining arguments> <value>
600 needarg () {
601     if [ "$2" -lt 2 ] || [ -z "$3" ]; then
602         printf 'bm: %s needs an argument\n' "$1" >&2
603         exit 2
604     fi
605 }
606 
607 action=
608 addurl=
609 tagfilter=
610 sortorder=${SBM_SORT:-used}
611 while [ $# -gt 0 ]; do
612     case $1 in
613         -h|--help)   setaction help ;;
614         -c|--copy)   setaction copy ;;
615         --print)     setaction print ;;
616         -o|--open|-p|--plumb) setaction open ;;
617         -e|--edit)   setaction edit ;;
618         -d|--delete) setaction delete ;;
619         -l|--list)   setaction list ;;
620         -m|--merge)  setaction merge ;;
621         -a|--add)
622             setaction add
623             case ${2-} in
624                 ''|-*) ;;
625                 *) addurl=$2; shift ;;
626             esac
627             ;;
628         -t|--tag)
629             needarg "$1" $# "${2-}"
630             tagfilter=$2
631             shift
632             ;;
633         -S|--sort)
634             needarg "$1" $# "${2-}"
635             sortorder=$2
636             shift
637             ;;
638         *)
639             printf 'bm: unknown argument: %s\n' "$1" >&2
640             usage >&2
641             exit 2
642             ;;
643     esac
644     shift
645 done
646 
647 case $sortorder in
648     used|recent|url|desc|tag) ;;
649     *)
650         printf 'bm: unknown sort order: %s\n' "$sortorder" >&2
651         exit 2
652         ;;
653 esac
654 
655 if [ "$action" = help ]; then
656     usage
657     exit 0
658 fi
659 
660 mkdir -p "$(dirname "$BOOKMARKS")" || die "cannot create directory for $BOOKMARKS"
661 mkdir -p "$(dirname "$USERTAGS")"
662 [ -e "$BOOKMARKS" ] || : > "$BOOKMARKS" || die "cannot create $BOOKMARKS"
663 
664 if awk '!/\t/ && !/^#/ && NF { found = 1; exit } END { exit !found }' "$BOOKMARKS"
665 then
666     printf 'bm: %s has lines in the old format; run: bm-migrate\n' "$BOOKMARKS" >&2
667 fi
668 
669 case $action in
670     list|merge) ;;
671     *) pickmenu ;;
672 esac
673 
674 case $action in
675     '')
676         if [ "$SBM_MENU" = fzf ]; then browse_fzf; else browse_menu; fi
677         ;;
678     open)   plumb ;;
679     copy)   url=$(choose_url 'copy:')   && copy_url "$url" ;;
680     print)  url=$(choose_url 'print:')  && bump "$url" && printf '%s\n' "$url" ;;
681     edit)   url=$(choose_url 'edit:')   && edit_url "$url" ;;
682     delete) url=$(choose_url 'delete:') && delete_url "$url" \
683                 && printf 'deleted %s\n' "$url" ;;
684     add)    add "$addurl" ;;
685     list)   rows | cut -f1-3 ;;
686     merge)  merge ;;
687 esac