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