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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 072d423925a81f2c79870c6019e99615906fdca3391ce795abc80bac49e4688b
4
- data.tar.gz: e01d49d23c0f36282e387c7b2391c225dde73c0aaa5d6106765296e6f0b333e2
3
+ metadata.gz: 51a33aad3cbed45ed09f6fa35f2046223dca0d5dd82fe15be7c49cb8aaa4c41d
4
+ data.tar.gz: 3054f0c7657e98c6c794b768ca9450992d3ee83b91526fc9cb88c5de6a4bc5ad
5
5
  SHA512:
6
- metadata.gz: d5facfef0a12be688be4960b4f76f982765870239a43ca1ea1197fcad4c8225d33bea1e14521bd556219d5b6b0ae51f0083ece20f405f53d9fec438e62f69722
7
- data.tar.gz: 2b01bbb94d2a50d124b31fd42289f4d2ab24155d333fccc0c96b14f0cc2898dd7823d8cba881f5eaa48b778d13df7c4c48a50f9e79420ce9b166919ce5e3f56d
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 in google chrome from cli
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)
@@ -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