booker 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/booker +3 -3
- data/completions/_booker +115 -0
- data/completions/booker.bash +52 -0
- data/completions/booker.fish +49 -0
- data/lib/booker/bookmarks.rb +164 -0
- data/lib/booker/cli.rb +226 -0
- data/lib/booker/config.rb +219 -0
- data/lib/booker/installer.rb +396 -0
- data/lib/booker/output.rb +84 -0
- data/lib/booker/parsers.rb +294 -0
- data/lib/booker/picker.rb +139 -0
- data/lib/booker/version.rb +5 -0
- data/lib/booker.rb +14 -483
- metadata +37 -47
- data/lib/bookmarks.rb +0 -435
- data/lib/config.rb +0 -190
- data/lib/consts.rb +0 -99
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51a33aad3cbed45ed09f6fa35f2046223dca0d5dd82fe15be7c49cb8aaa4c41d
|
|
4
|
+
data.tar.gz: 3054f0c7657e98c6c794b768ca9450992d3ee83b91526fc9cb88c5de6a4bc5ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e6ced499e2b20d75987fc8c538b95bfb5a9912549b49848c0b6e827bc3c5f8dd3aed72c17c585b5080d16c60a48832d57f6a829d5ebf465f9068969418dc4898
|
|
7
|
+
data.tar.gz: fb1a648048fbe579ae1cdaee106da02fc1dc345874119fa0ef846dbc1d0631e63dbec150c5509b66a325ecce991969acf369ce5b096b9571d4c97269294bb66a
|
data/bin/booker
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require_relative "../lib/booker"
|
|
4
5
|
|
|
5
6
|
# booker by jeremy warner
|
|
6
|
-
# open up a website or search
|
|
7
|
-
# also show and/or open up google chrome bookmarks
|
|
7
|
+
# open up a website or search from the cli, or open one of your bookmarks
|
|
8
8
|
|
|
9
|
-
Booker.new(ARGV)
|
|
9
|
+
Booker::CLI.new(ARGV)
|
data/completions/_booker
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#compdef booker
|
|
2
|
+
#autoload
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_booker_bookmarks() {
|
|
6
|
+
#http://zsh.sourceforge.net/Guide/zshguide06.html#l147
|
|
7
|
+
setopt glob bareglobqual nullglob rcexpandparam extendedglob automenu
|
|
8
|
+
unsetopt allexport aliases errexit octalzeroes ksharrays cshnullglob
|
|
9
|
+
|
|
10
|
+
#get bookmarks
|
|
11
|
+
local -a bookmarks sites search_terms
|
|
12
|
+
search_terms=()
|
|
13
|
+
|
|
14
|
+
#filter out 'booker', flags, already-completed URLs, and bookmark IDs typed
|
|
15
|
+
#by hand. completion inserts URLs, so anything carrying a scheme is a
|
|
16
|
+
#previous pick rather than a search term. the id pattern mirrors
|
|
17
|
+
#BOOKMARK_ID in lib/booker/cli.rb, which is the source of truth - safari
|
|
18
|
+
#ids are uuids, so digits alone do not cover them
|
|
19
|
+
for arg in "$@"; do
|
|
20
|
+
if [[ "$arg" != "booker" && "$arg" != -* && "$arg" != *://* \
|
|
21
|
+
&& ! "$arg" =~ ^([0-9]+_[a-zA-Z0-9-]+|[0-9_]+)$ ]]; then
|
|
22
|
+
search_terms+=("$arg")
|
|
23
|
+
fi
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
#join search terms and get matching bookmarks. --complete-raw is the
|
|
27
|
+
#machine readable feed: id \t title \t url, never truncated or padded
|
|
28
|
+
local search="${search_terms[*]}"
|
|
29
|
+
sites=`booker --complete-raw $search`
|
|
30
|
+
bookmarks=("${(f)${sites}}")
|
|
31
|
+
|
|
32
|
+
#terminal colors
|
|
33
|
+
BLUE=`echo -e "\033[4;34m"`
|
|
34
|
+
RESET=`echo -e "\033[0;0m"`
|
|
35
|
+
|
|
36
|
+
#parse parts, color. the p flag makes zsh read \t as a real tab.
|
|
37
|
+
#
|
|
38
|
+
#every description has to fit on one screen line. -l below prints one match
|
|
39
|
+
#per line and zsh sizes the list on that basis, so a description that wraps
|
|
40
|
+
#makes the geometry wrong by however many lines it spilled onto - the menu
|
|
41
|
+
#then redraws over itself and over the scrollback above it. browsers happily
|
|
42
|
+
#store a whole page blurb as a title, so this is not a rare case: only the
|
|
43
|
+
#display is trimmed, and `urls` keeps the full link that actually gets
|
|
44
|
+
#inserted
|
|
45
|
+
#the colour escapes below are characters zsh counts when it sizes the list
|
|
46
|
+
#but the terminal draws as nothing, so the budget has to leave room for
|
|
47
|
+
#them (13) on top of a margin. erring small only trims a title slightly
|
|
48
|
+
#early; erring large puts the wrapping back
|
|
49
|
+
local -i width=$(( ${COLUMNS:-100} - 16 ))
|
|
50
|
+
(( width < 40 )) && width=40
|
|
51
|
+
local -a urls links
|
|
52
|
+
for bookmark in ${bookmarks[@]}; do
|
|
53
|
+
local -a split
|
|
54
|
+
split=("${(@ps:\t:)bookmark}")
|
|
55
|
+
urls+=( $split[3] )
|
|
56
|
+
|
|
57
|
+
#the url is the useful half, but cap it so one long link cannot crowd
|
|
58
|
+
#the title out entirely
|
|
59
|
+
local name=$split[2] link=$split[3]
|
|
60
|
+
local -i maxlink=$(( width / 2 ))
|
|
61
|
+
(( ${#link} > maxlink )) && link="${link[1,maxlink-1]}…"
|
|
62
|
+
|
|
63
|
+
local -i room=$(( width - ${#link} - 1 ))
|
|
64
|
+
(( room < 20 )) && room=20
|
|
65
|
+
(( ${#name} > room )) && name="${name[1,room-1]}…"
|
|
66
|
+
|
|
67
|
+
links+=("$name $BLUE$link$RESET")
|
|
68
|
+
done
|
|
69
|
+
|
|
70
|
+
#finally, add completions. the inserted value is the URL rather than the
|
|
71
|
+
#bookmark id: the id was opaque on the command line, and booker opens a URL
|
|
72
|
+
#argument directly, so both land in the same place. no -Q, so zsh quotes the
|
|
73
|
+
#query strings and fragments that URLs are full of.
|
|
74
|
+
#
|
|
75
|
+
#'list force' keeps the list on screen even when a single bookmark matches,
|
|
76
|
+
#so you can still read the folder and title of what is about to be inserted
|
|
77
|
+
compstate[insert]=menu
|
|
78
|
+
compstate[list]='list force'
|
|
79
|
+
compadd -U -l -X 'found bookmarks:' -d links -a urls
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_booker() {
|
|
84
|
+
compstate[insert]=menu
|
|
85
|
+
local curcontext state line
|
|
86
|
+
typeset -A opt_args
|
|
87
|
+
|
|
88
|
+
_arguments -C \
|
|
89
|
+
'(-b)-b[do bookmark completion]'\
|
|
90
|
+
'(--bookmark)--bookmark[do bookmark completion]'\
|
|
91
|
+
'(-c)-c[show bookmark completions]'\
|
|
92
|
+
'(--complete)--complete[show bookmark completions]'\
|
|
93
|
+
'(--complete-raw)--complete-raw[tab completions, tab separated]'\
|
|
94
|
+
'(-l)-l[print the bookmark table, skipping the picker]'\
|
|
95
|
+
'(--list)--list[print the bookmark table, skipping the picker]'\
|
|
96
|
+
'(-i)-i[perform installations (all, bookmarks, completion, config, safari)]'\
|
|
97
|
+
'(--install)--install[perform installations (all, bookmarks, completion, config, safari)]'\
|
|
98
|
+
'(-s)-s[search google for...]'\
|
|
99
|
+
'(--search)--search[search google for...]'\
|
|
100
|
+
'(-h)-h[show booker help]'\
|
|
101
|
+
'(--help)--help[show booker help]'\
|
|
102
|
+
'(-v)-v[show version]'\
|
|
103
|
+
'(--version)--version[show version]'\
|
|
104
|
+
'*::bookmarks:->bookmarks'
|
|
105
|
+
|
|
106
|
+
# Handle the bookmarks state for repeated completions
|
|
107
|
+
case $state in
|
|
108
|
+
bookmarks)
|
|
109
|
+
_booker_bookmarks $words
|
|
110
|
+
;;
|
|
111
|
+
esac
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_booker
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# bash completion for booker
|
|
2
|
+
#
|
|
3
|
+
# Candidates are bookmark URLs, not bookmark ids. bash has no per-candidate
|
|
4
|
+
# descriptions (unlike zsh and fish), so a menu of bare ids -- 0_1, 2_7, ... --
|
|
5
|
+
# would tell you nothing about what you are picking. booker opens a URL
|
|
6
|
+
# argument directly, so completing to one lands in the same place as
|
|
7
|
+
# completing to an id.
|
|
8
|
+
#
|
|
9
|
+
# Install with: booker --install bash
|
|
10
|
+
|
|
11
|
+
_booker() {
|
|
12
|
+
local cur arg
|
|
13
|
+
local -a search_terms=()
|
|
14
|
+
|
|
15
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
16
|
+
COMPREPLY=()
|
|
17
|
+
|
|
18
|
+
# flags complete against the option list, not against bookmarks
|
|
19
|
+
if [[ "$cur" == -* ]]; then
|
|
20
|
+
COMPREPLY=($(compgen -W "-b --bookmark -i --install -s --search \
|
|
21
|
+
-l --list -c --complete --complete-raw -v --version -h --help" -- "$cur"))
|
|
22
|
+
return 0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# everything typed so far, minus the command name, flags, any URL already
|
|
26
|
+
# completed onto the line, and any bookmark id typed by hand
|
|
27
|
+
for arg in "${COMP_WORDS[@]:1}"; do
|
|
28
|
+
[[ -z "$arg" ]] && continue
|
|
29
|
+
[[ "$arg" == -* ]] && continue
|
|
30
|
+
# completion inserts URLs, so a scheme marks a previous pick rather
|
|
31
|
+
# than something to search for
|
|
32
|
+
[[ "$arg" == *://* ]] && continue
|
|
33
|
+
# mirrors BOOKMARK_ID in lib/booker/cli.rb: safari ids are uuids
|
|
34
|
+
[[ "$arg" =~ ^([0-9]+_[a-zA-Z0-9-]+|[0-9_]+)$ ]] && continue
|
|
35
|
+
search_terms+=("$arg")
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
# --complete-raw prints one "id <tab> title <tab> url" per match, never
|
|
39
|
+
# truncated. read -r into fields so URLs containing glob characters (?, *)
|
|
40
|
+
# survive intact -- an unquoted $(...) would let bash try to expand them.
|
|
41
|
+
local id title url
|
|
42
|
+
while IFS=$'\t' read -r id title url; do
|
|
43
|
+
[[ -n "$url" ]] && COMPREPLY+=("$url")
|
|
44
|
+
done < <(booker --complete-raw "${search_terms[@]}" 2>/dev/null)
|
|
45
|
+
|
|
46
|
+
# NOTE: deliberately not filtered through `compgen -W ... -- "$cur"`.
|
|
47
|
+
# booker matches on bookmark title as well as URL, so a candidate often
|
|
48
|
+
# does not start with what the user typed, and compgen would drop it.
|
|
49
|
+
return 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
complete -F _booker booker
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# fish completion for booker
|
|
2
|
+
#
|
|
3
|
+
# Candidates are bookmark URLs, matching zsh and bash. booker opens a URL
|
|
4
|
+
# argument directly, so this lands in the same place a bookmark id would, and
|
|
5
|
+
# the command line says what it is about to open. fish shows a description next
|
|
6
|
+
# to each candidate, so the folder and title go there.
|
|
7
|
+
#
|
|
8
|
+
# Install with: booker --install fish
|
|
9
|
+
|
|
10
|
+
function __booker_bookmarks --description 'list booker bookmarks matching the current commandline'
|
|
11
|
+
set -l tokens (commandline -poc) (commandline -ct)
|
|
12
|
+
|
|
13
|
+
# drop the command name itself
|
|
14
|
+
set -e tokens[1]
|
|
15
|
+
|
|
16
|
+
# keep the search terms: no flags, no already-completed URLs, no bookmark
|
|
17
|
+
# ids typed by hand
|
|
18
|
+
set -l terms
|
|
19
|
+
for arg in $tokens
|
|
20
|
+
test -z "$arg"; and continue
|
|
21
|
+
string match -qr '^-' -- $arg; and continue
|
|
22
|
+
# completion inserts URLs, so a scheme marks a previous pick
|
|
23
|
+
string match -qr '://' -- $arg; and continue
|
|
24
|
+
# mirrors BOOKMARK_ID in lib/booker/cli.rb: safari ids are uuids
|
|
25
|
+
string match -qr '^([0-9]+_[a-zA-Z0-9-]+|[0-9_]+)$' -- $arg; and continue
|
|
26
|
+
set -a terms $arg
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# --complete-raw prints "id <tab> title <tab> url"; fish wants
|
|
30
|
+
# "value <tab> description", so complete to the url and describe it with
|
|
31
|
+
# the folder and title
|
|
32
|
+
booker --complete-raw $terms 2>/dev/null | while read -l -d \t id title url
|
|
33
|
+
printf '%s\t%s\n' $url $title
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
complete -c booker -f -a '(__booker_bookmarks)'
|
|
38
|
+
|
|
39
|
+
complete -c booker -s b -l bookmark -d 'explicitly open bookmark'
|
|
40
|
+
complete -c booker -s s -l search -d 'explicitly search arguments'
|
|
41
|
+
complete -c booker -s l -l list -d 'print the bookmark table, skipping the picker'
|
|
42
|
+
complete -c booker -s c -l complete -d 'show tab completions'
|
|
43
|
+
complete -c booker -l complete-raw -d 'tab completions, tab separated (for shell scripts)'
|
|
44
|
+
complete -c booker -s v -l version -d 'print version'
|
|
45
|
+
complete -c booker -s h -l help -d 'show help'
|
|
46
|
+
# the shell names here mirror SHELLS in lib/booker/installer.rb, which is the source of
|
|
47
|
+
# truth - add a shell there and it needs adding here too
|
|
48
|
+
complete -c booker -s i -l install -x -d 'install booker support files' \
|
|
49
|
+
-a 'all bookmarks completion config safari zsh bash fish'
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# grab/parse bookmarks from every configured source, and the two value objects
|
|
4
|
+
# the parsers hand back. Set needs no require - autoloaded since ruby 3.2, the
|
|
5
|
+
# floor in the gemspec
|
|
6
|
+
|
|
7
|
+
require_relative "output"
|
|
8
|
+
|
|
9
|
+
using Booker::Colors
|
|
10
|
+
|
|
11
|
+
module Booker
|
|
12
|
+
# Main Bookmarks facade class
|
|
13
|
+
class Bookmarks
|
|
14
|
+
# one row per browser: everything booker knows about a bookmark source
|
|
15
|
+
# lives here, so a fourth browser is an entry rather than a hunt through the
|
|
16
|
+
# parser lookup, the installer's labels and its colors. chrome's file has no
|
|
17
|
+
# extension, so it is the fallback rather than a row to match on
|
|
18
|
+
BROWSERS = {
|
|
19
|
+
firefox: {parser: Parsers::Firefox, ext: ".sqlite", label: "[Firefox]", color: :blu},
|
|
20
|
+
safari: {parser: Parsers::Safari, ext: ".plist", label: "[Safari]", color: :cyan},
|
|
21
|
+
chrome: {parser: Parsers::Chrome, ext: nil, label: "[Chrome]", color: :yel}
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# which browser wrote this file, by name. the installer labels sources with
|
|
25
|
+
# it and the parse loop below picks a parser with it, so the two can never
|
|
26
|
+
# disagree about what a path is
|
|
27
|
+
def self.source_for(path)
|
|
28
|
+
BROWSERS.find { |_, b| b[:ext] && path&.end_with?(b[:ext]) }&.first || :chrome
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :allurls
|
|
32
|
+
|
|
33
|
+
def initialize(search_term = "")
|
|
34
|
+
@conf = Config.default
|
|
35
|
+
@allurls = []
|
|
36
|
+
seen = Set.new
|
|
37
|
+
|
|
38
|
+
# stat each configured path once, not once to count them and again to
|
|
39
|
+
# read them. the index is kept: it is the source half of every id
|
|
40
|
+
sources = @conf.bookmarks.each_with_index.select { |p, _| p && File.exist?(p) }
|
|
41
|
+
@multi_source = sources.length > 1
|
|
42
|
+
|
|
43
|
+
sources.each do |file_path, source_index|
|
|
44
|
+
source = self.class.source_for(file_path)
|
|
45
|
+
parser = BROWSERS.fetch(source)[:parser].new(file_path, search_term)
|
|
46
|
+
parser.parse
|
|
47
|
+
|
|
48
|
+
parser.results.each do |bookmark|
|
|
49
|
+
# add? is nil when the key was already there, so the dedupe check and
|
|
50
|
+
# the record of having seen it are the same call
|
|
51
|
+
next unless seen.add?([bookmark.title, bookmark.url])
|
|
52
|
+
|
|
53
|
+
@allurls << Bookmark.new(
|
|
54
|
+
folder: bookmark.folder,
|
|
55
|
+
title: bookmark.title,
|
|
56
|
+
url: bookmark.url,
|
|
57
|
+
id: "#{source_index}_#{bookmark.id}",
|
|
58
|
+
source: source
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# output for zsh autocompetion, print out id, title and cleaned url
|
|
65
|
+
def autocomplete
|
|
66
|
+
@allurls.each do |url|
|
|
67
|
+
name = clean_name(url)
|
|
68
|
+
link = clean_link(url)
|
|
69
|
+
puts url.id + ":" + name + ":" + link
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# one tab separated id/title/url triple per bookmark, shared by the shell
|
|
74
|
+
# completion feed and the picker. never truncated or padded, unlike
|
|
75
|
+
# #autocomplete: `tput cols` is unreliable in a completion subshell and a
|
|
76
|
+
# truncated url opens a broken link. tabs and newlines go from every field,
|
|
77
|
+
# not just the url - one surviving in a folder name splits that bookmark
|
|
78
|
+
# into two candidates the picker offers separately, neither openable
|
|
79
|
+
def rows
|
|
80
|
+
@allurls.map do |url|
|
|
81
|
+
[url.id, display_name(url), url.url].map { |f| f.to_s.delete("\t\n") }.join("\t")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# machine readable feed for the shell completion scripts
|
|
86
|
+
def autocomplete_raw
|
|
87
|
+
rows.each { |row| puts row }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# clean title for completion, delete anything not allowed in linktitle
|
|
91
|
+
def clean_name(url)
|
|
92
|
+
# Use half terminal width for name to leave room for URL
|
|
93
|
+
display_name(url).window((Term.width / 2).clamp(..50))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# "[source] folder | title", with no width applied
|
|
97
|
+
def display_name(url)
|
|
98
|
+
# Format: [source] folder | title (source only when multi-source)
|
|
99
|
+
prefix = (@multi_source && url.source) ? "[#{url.source}] " : ""
|
|
100
|
+
name = prefix + url.display_folder + " | " + url.title.gsub(/[^a-z0-9\-\/_ ]/i, "")
|
|
101
|
+
name.squeeze!("-")
|
|
102
|
+
name.squeeze!(" ")
|
|
103
|
+
# a top level bookmark has no folder text, which would otherwise leave a
|
|
104
|
+
# leading space - invisible in the padded #clean_name output, but not in
|
|
105
|
+
# the raw feed the completion scripts read
|
|
106
|
+
name.strip
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# clean link for completion, remove strange things from any linkurls
|
|
110
|
+
def clean_link(url)
|
|
111
|
+
link = url.url.gsub(/[,'"&?].*/, "")
|
|
112
|
+
link.gsub!(/.*:\/+/, "")
|
|
113
|
+
link.delete!(" ")
|
|
114
|
+
# Use half terminal width for URL
|
|
115
|
+
max_width = (Term.width / 2 - CODEWIDTH).clamp(..50)
|
|
116
|
+
link[0..max_width]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# get link (from id number). #each returned @allurls itself when nothing
|
|
120
|
+
# matched, so a bad id reached open_bookmark as an Array and blew up with a
|
|
121
|
+
# TypeError instead of the "bookmark not found" message
|
|
122
|
+
def bookmark_url(id)
|
|
123
|
+
@allurls.find { |url| id == url.id }&.url
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# for recursively parsing bookmarks
|
|
128
|
+
class Folder
|
|
129
|
+
attr_reader :json, :title
|
|
130
|
+
|
|
131
|
+
# the browsers disagree about what punctuation belongs in a folder name, so
|
|
132
|
+
# every parser normalizes through here rather than carrying its own copy of
|
|
133
|
+
# the rule for the levels it builds itself
|
|
134
|
+
def self.normalize(title) = title.gsub(/[:,'"]/, "-").downcase
|
|
135
|
+
|
|
136
|
+
def initialize(json, title = "|")
|
|
137
|
+
@title = Folder.normalize(title)
|
|
138
|
+
@json = json
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# one parsed bookmark. the three browsers disagree about what punctuation
|
|
143
|
+
# belongs in a title, so cleaning happens here rather than in each parser.
|
|
144
|
+
# Data.new still accepts positional args, so the parsers could pass either
|
|
145
|
+
Bookmark = Data.define(:folder, :title, :url, :id, :source) do
|
|
146
|
+
def initialize(folder:, title:, url:, id:, source: nil)
|
|
147
|
+
super(folder:, title: title.gsub(/[:'"+]/, " ").downcase, url:, id:, source:)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# the folder as a reader sees it: no leading marker, no trailing slash, a
|
|
151
|
+
# name for the top level - which "|" and "|/" both mean, both leaving
|
|
152
|
+
# nothing behind once stripped. shared by the picker feed and the --list
|
|
153
|
+
# table, which each used to decide for itself what "top" looked like
|
|
154
|
+
def display_folder
|
|
155
|
+
stripped = folder.gsub(/^\|/, "").chomp("/")
|
|
156
|
+
stripped.empty? ? "[root]" : stripped
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ruby 3.2's Data#with copies members straight across instead of going back
|
|
160
|
+
# through #initialize, so a title replaced there would keep its punctuation
|
|
161
|
+
# and case. 3.3 fixed it; spelling it out keeps the gemspec's ">= 3.2" true
|
|
162
|
+
def with(**changes) = self.class.new(**to_h.merge(changes))
|
|
163
|
+
end
|
|
164
|
+
end
|
data/lib/booker/cli.rb
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# parse booker's command line args and act on them
|
|
4
|
+
|
|
5
|
+
require_relative "output"
|
|
6
|
+
|
|
7
|
+
using Booker::Colors
|
|
8
|
+
|
|
9
|
+
module Booker
|
|
10
|
+
class CLI
|
|
11
|
+
include Browser
|
|
12
|
+
include Output
|
|
13
|
+
|
|
14
|
+
# a bookmark id is "<source index>_<browser id>". safari uses a uuid where
|
|
15
|
+
# chrome and firefox number theirs, so the second half is anything id-shaped
|
|
16
|
+
# rather than digits; bare digits stay valid for single-source configs
|
|
17
|
+
# written before the prefix existed. completion inserts urls now, but ids
|
|
18
|
+
# are still first class - --bookmark takes them, the picker returns them
|
|
19
|
+
BOOKMARK_ID = /\A(?:\d+_[a-z0-9-]+|[0-9_]+)\z/i
|
|
20
|
+
|
|
21
|
+
def initialize(args)
|
|
22
|
+
parse args
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse(args)
|
|
26
|
+
show_bookmarks if args.none?
|
|
27
|
+
|
|
28
|
+
if args.first&.start_with?("-")
|
|
29
|
+
dispatch_option(args)
|
|
30
|
+
exit 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
bookmark_ids, other_args = args.partition { |a| BOOKMARK_ID.match?(a) }
|
|
34
|
+
open_bookmark(bookmark_ids) unless bookmark_ids.empty?
|
|
35
|
+
|
|
36
|
+
unless other_args.empty?
|
|
37
|
+
# every argument being a url means they all came from tab completion,
|
|
38
|
+
# which inserts one per bookmark - open the lot. one non-url word makes
|
|
39
|
+
# the whole line a search, so a phrase containing a domain still is one
|
|
40
|
+
if other_args.all? { |arg| domain.match?(arg) }
|
|
41
|
+
other_args.each do |site|
|
|
42
|
+
puts "opening website: ".grn + site
|
|
43
|
+
openweb(prep(site))
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
pick_or_search(other_args.join(" ").strip)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def option_parser
|
|
52
|
+
# required here rather than at the top of the file: optparse is only
|
|
53
|
+
# reachable through an argument starting with "-", so `booker <term>` and
|
|
54
|
+
# every tab press paid to load it without ever building one
|
|
55
|
+
require "optparse"
|
|
56
|
+
|
|
57
|
+
@option_parser ||= OptionParser.new do |opts|
|
|
58
|
+
opts.banner = "Usage: booker [options] [arguments]"
|
|
59
|
+
opts.separator ""
|
|
60
|
+
opts.separator "Main options:"
|
|
61
|
+
opts.on("-b", "--bookmark", "explicitly open bookmark") { @mode = :bookmark }
|
|
62
|
+
opts.on("-i", "--install", "install: all|bookmarks|completion|config|safari") { @mode = :install }
|
|
63
|
+
opts.on("-s", "--search", "explicitly search arguments") { @mode = :search }
|
|
64
|
+
opts.on("-l", "--list", "print the bookmark table, skipping the picker") { @mode = :list }
|
|
65
|
+
opts.separator ""
|
|
66
|
+
opts.separator "Other options:"
|
|
67
|
+
opts.on("-c", "--complete", "show tab completions") { @mode = :complete }
|
|
68
|
+
opts.on("--complete-raw", "tab completions, tab separated (for shell scripts)") { @mode = :complete_raw }
|
|
69
|
+
opts.on("-v", "--version", "print version") {
|
|
70
|
+
puts VERSION
|
|
71
|
+
exit 0
|
|
72
|
+
}
|
|
73
|
+
opts.on_tail("-h", "--help", "show help") {
|
|
74
|
+
puts opts
|
|
75
|
+
exit 0
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def dispatch_option(args)
|
|
81
|
+
option_parser.parse!(args)
|
|
82
|
+
|
|
83
|
+
case @mode
|
|
84
|
+
when :bookmark
|
|
85
|
+
pexit "Error: ".red + "booker --bookmark expects bookmark id" if args.empty?
|
|
86
|
+
open_bookmark(args)
|
|
87
|
+
when :install
|
|
88
|
+
installer.install(args.empty? ? %w[completion config bookmarks] : args)
|
|
89
|
+
when :search
|
|
90
|
+
pexit "Error: ".red + "--search requires an argument" if args.empty?
|
|
91
|
+
open_search(args.join(" "))
|
|
92
|
+
when :list
|
|
93
|
+
# `booker --list github` narrows the table, rather than silently
|
|
94
|
+
# dropping the term and printing everything
|
|
95
|
+
show_bookmarks(args.join(" "), force_table: true)
|
|
96
|
+
when :complete
|
|
97
|
+
Bookmarks.new(args.join(" ")).autocomplete
|
|
98
|
+
when :complete_raw
|
|
99
|
+
Bookmarks.new(args.join(" ")).autocomplete_raw
|
|
100
|
+
end
|
|
101
|
+
rescue OptionParser::InvalidOption => e
|
|
102
|
+
pexit "Error: ".red + e.message
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def installer
|
|
106
|
+
# the installer pulls in find, fileutils and open3 and is reachable only
|
|
107
|
+
# through --install, so a plain search - and every completion subshell -
|
|
108
|
+
# loaded all of it to never call any of it
|
|
109
|
+
require_relative "installer"
|
|
110
|
+
|
|
111
|
+
@installer ||= Installer.new
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def openweb(url)
|
|
115
|
+
# Pass URL directly to browser without invoking shell
|
|
116
|
+
# This avoids issues with special characters like parentheses
|
|
117
|
+
browser_cmd = browse.strip
|
|
118
|
+
|
|
119
|
+
# Redirect stdout/stderr to suppress GTK warnings
|
|
120
|
+
success = system(browser_cmd, url, out: File::NULL, err: File::NULL)
|
|
121
|
+
|
|
122
|
+
unless success
|
|
123
|
+
# system reports a missing binary as nil rather than false, but the
|
|
124
|
+
# forked child still exited - with 127, the shell convention for execvp
|
|
125
|
+
# failures - so there is a status either way. the &. is only for nothing
|
|
126
|
+
# in this process having spawned a child yet
|
|
127
|
+
code = Process.last_status&.exitstatus
|
|
128
|
+
puts "Warning: ".yel + "Failed to open URL (exit code: #{code})"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# bookmark ids, opened in order. parsing every source costs hundreds of
|
|
133
|
+
# milliseconds, so a caller that already has the set - the picker does -
|
|
134
|
+
# passes it in, and it is searched once for the list rather than once per id
|
|
135
|
+
def open_bookmark(bm, store = nil)
|
|
136
|
+
store ||= Bookmarks.new
|
|
137
|
+
|
|
138
|
+
bm.each do |id|
|
|
139
|
+
url = store.bookmark_url(id)
|
|
140
|
+
pexit "Failure:".red + " bookmark #{id} not found" if url.nil?
|
|
141
|
+
puts "opening bookmark ".grn + url
|
|
142
|
+
openweb(url) # no shell quoting needed - system() handles it
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def open_search(term)
|
|
147
|
+
puts "searching ".grn + term
|
|
148
|
+
search = Config.default.searcher
|
|
149
|
+
term = term.tr(" ", "+")
|
|
150
|
+
openweb(search + term) # No shell escape needed - it's a URL
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# a term matching bookmarks is offered as a choice; one matching nothing is
|
|
154
|
+
# a search, exactly as it always was. cancelling the picker is a decision
|
|
155
|
+
# rather than a fallthrough, and `booker -s <term>` still demands a search
|
|
156
|
+
def pick_or_search(term)
|
|
157
|
+
if Picker.enabled?
|
|
158
|
+
bookmarks = Bookmarks.new(term)
|
|
159
|
+
pick_and_open(bookmarks) unless bookmarks.allurls.empty?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
open_search(term)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# offer the bookmarks as a choice and open what comes back. either way this
|
|
166
|
+
# ends the run: cancelling the picker is a decision, not a fallthrough to
|
|
167
|
+
# whatever the caller would have done instead
|
|
168
|
+
def pick_and_open(bookmarks)
|
|
169
|
+
id = Picker.select(bookmarks.rows)
|
|
170
|
+
exit 0 if id.nil?
|
|
171
|
+
open_bookmark([id], bookmarks)
|
|
172
|
+
exit 0
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def show_bookmarks(term = "", force_table: false)
|
|
176
|
+
bookmarks = Bookmarks.new(term)
|
|
177
|
+
allurls = bookmarks.allurls
|
|
178
|
+
|
|
179
|
+
if allurls.empty?
|
|
180
|
+
puts "No bookmarks found.".red
|
|
181
|
+
puts "Run: ".yel + "booker --install bookmarks".cyan
|
|
182
|
+
exit 0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# the picker owns the screen when there is one, so nothing is printed
|
|
186
|
+
# above it. --list forces the table back for anyone who wants it
|
|
187
|
+
pick_and_open(bookmarks) if !force_table && Picker.enabled?
|
|
188
|
+
|
|
189
|
+
puts "Bookmarks:".grn + " (usage: booker <id> or booker <search>)"
|
|
190
|
+
puts ""
|
|
191
|
+
|
|
192
|
+
widths = column_widths
|
|
193
|
+
allurls.each { |bookmark| puts row(bookmark, widths) }
|
|
194
|
+
|
|
195
|
+
puts ""
|
|
196
|
+
puts "Found #{allurls.length} bookmarks".grn
|
|
197
|
+
puts ""
|
|
198
|
+
puts "Examples:".yel
|
|
199
|
+
puts " #{"booker #{allurls.first.id}".ljust(20).cyan} # Open first bookmark"
|
|
200
|
+
puts " #{"booker github".ljust(20).cyan} # Search bookmarks for 'github'"
|
|
201
|
+
puts " #{"booker --help".ljust(20).cyan} # Show help"
|
|
202
|
+
|
|
203
|
+
exit 0
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# what is left of the terminal once the id column and the single spaces
|
|
207
|
+
# between columns are spoken for, shared out by proportion with a floor
|
|
208
|
+
# apiece so a narrow window still shows something of every field
|
|
209
|
+
ID_WIDTH = 10
|
|
210
|
+
SHARES = {folder: [0.20, 15], title: [0.30, 20], url: [0.50, 30]}.freeze
|
|
211
|
+
|
|
212
|
+
def column_widths
|
|
213
|
+
remaining = Term.width - ID_WIDTH - 3
|
|
214
|
+
SHARES.transform_values { |share, floor| (remaining * share).clamp(floor..).to_i }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def row(bookmark, widths)
|
|
218
|
+
[
|
|
219
|
+
bookmark.id.to_s.window(ID_WIDTH).grn,
|
|
220
|
+
bookmark.display_folder.window(widths[:folder]).blu,
|
|
221
|
+
bookmark.title.window(widths[:title]).yel,
|
|
222
|
+
bookmark.url.window(widths[:url])
|
|
223
|
+
].join(" ")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|