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