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