booker 1.2.1 → 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 -423
- metadata +41 -24
- data/lib/bookmarks.rb +0 -324
- data/lib/config.rb +0 -187
- data/lib/consts.rb +0 -114
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# configuation - which platform we are on, how to open a link, and where the
|
|
4
|
+
# bookmarks live
|
|
5
|
+
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
require_relative "output"
|
|
9
|
+
|
|
10
|
+
using Booker::Colors
|
|
11
|
+
|
|
12
|
+
module Booker
|
|
13
|
+
# detect operating system
|
|
14
|
+
module OS
|
|
15
|
+
def self.windows? = RUBY_PLATFORM.match?(/cygwin|mswin|mingw|bccwin|wince|emx/)
|
|
16
|
+
|
|
17
|
+
def self.mac? = RUBY_PLATFORM.match?(/darwin/)
|
|
18
|
+
|
|
19
|
+
def self.linux? = !(windows? || mac?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# return browser (chrome) opening command
|
|
23
|
+
module Browser
|
|
24
|
+
def browse
|
|
25
|
+
if OS.windows?
|
|
26
|
+
# alternatively, start seems to work - probably check if powershell v cygwin?
|
|
27
|
+
'/cygdrive/c/Program\ Files\ \(x86\)/Google/Chrome/Application/chrome.exe '
|
|
28
|
+
elsif OS.mac?
|
|
29
|
+
"open "
|
|
30
|
+
else
|
|
31
|
+
# linux? is defined as "neither of the above", so there is no fourth
|
|
32
|
+
# branch for #browse to fall out of and return nil from
|
|
33
|
+
"xdg-open "
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# what an explicit scheme looks like. #domain and #prep are two halves of
|
|
38
|
+
# one grammar - is this a url, does it already say how to fetch it - so both
|
|
39
|
+
# read the rule from here rather than spelling it out twice and drifting
|
|
40
|
+
SCHEME = /[a-z][a-z0-9+.-]*:\/\//i
|
|
41
|
+
|
|
42
|
+
# does this argument look like a website rather than a search term? an
|
|
43
|
+
# explicit scheme, or a bare host with a dot and an alphabetic tld. every
|
|
44
|
+
# tld has to work: completion inserts real bookmark urls, and the old
|
|
45
|
+
# (io|com|net|org|...) list sent .dev and .ai off to the search engine
|
|
46
|
+
def domain
|
|
47
|
+
%r{\A(?:#{SCHEME}\S+|(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:[/?#]\S*)?)\z}io
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# only a bare host needs a scheme invented for it. testing for "http" alone
|
|
51
|
+
# sent everything else down the same branch, so a completed
|
|
52
|
+
# chrome://bookmarks/ came out as http://chrome://bookmarks/
|
|
53
|
+
def prep(url)
|
|
54
|
+
/\A#{SCHEME}/io.match?(url) ? url : "http://" + url
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# configuration
|
|
59
|
+
class Config
|
|
60
|
+
VALID = [:searcher, :bookmarks, :browser, :picker].freeze
|
|
61
|
+
HOME = ENV.fetch("HOME", "/usr/local/").freeze
|
|
62
|
+
YAMLCONF = (HOME + "/.booker.yml").freeze
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# one instance per run. Bookmarks, Picker and the CLI want the same
|
|
66
|
+
# answers, and one Config apiece meant parsing the yaml three times,
|
|
67
|
+
# discovering three times, and warning three times to say it once
|
|
68
|
+
def default = @default ||= new
|
|
69
|
+
|
|
70
|
+
# the memo outlives a single example, so the suite drops it between them
|
|
71
|
+
def reset! = @default = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize
|
|
75
|
+
# the yaml file if there is one, and only work out the defaults when
|
|
76
|
+
# there is not: discovery walks every chrome profile directory to answer
|
|
77
|
+
# a question the config file has already answered
|
|
78
|
+
@config = read(YAMLCONF) || {
|
|
79
|
+
browser: "open ",
|
|
80
|
+
searcher: "https://google.com/search?q=",
|
|
81
|
+
bookmarks: detect_default_bookmarks
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# prune bad config keys
|
|
85
|
+
@config.each_key do |k|
|
|
86
|
+
if !VALID.include? k.to_sym
|
|
87
|
+
warn "Failure:".red + " Bad key found in config file: #{k}"
|
|
88
|
+
exit 1
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def detect_default_bookmarks
|
|
94
|
+
# Return ALL available bookmark sources (Chrome, Firefox, Safari)
|
|
95
|
+
all_sources = discover_all_bookmark_sources
|
|
96
|
+
|
|
97
|
+
# If we found sources, return them all; otherwise return a default single path
|
|
98
|
+
if all_sources.empty?
|
|
99
|
+
# Fallback to macOS Chrome default for backward compatibility
|
|
100
|
+
HOME + "/Library/Application Support/Google/Chrome/Profile 1/Bookmarks"
|
|
101
|
+
else
|
|
102
|
+
all_sources
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def discover_all_bookmark_sources = Sources.discover
|
|
107
|
+
|
|
108
|
+
def read(file)
|
|
109
|
+
# #write emits symbol keys, so they have to be permitted on the way back
|
|
110
|
+
YAML.safe_load_file(file, permitted_classes: [Symbol])
|
|
111
|
+
rescue Errno::ENOENT
|
|
112
|
+
warn "Warning: ".yel +
|
|
113
|
+
"YAML configuration file couldn't be found. Using defaults."
|
|
114
|
+
warn "Suggest: ".grn + "booker --install config"
|
|
115
|
+
false
|
|
116
|
+
rescue Psych::Exception
|
|
117
|
+
# every psych failure means the same thing here - an unusable config to
|
|
118
|
+
# fall back from rather than crash on. rescuing SyntaxError alone let
|
|
119
|
+
# anchors through as an AliasesNotEnabled backtrace under psych 4
|
|
120
|
+
warn "Warning: ".red +
|
|
121
|
+
"YAML configuration file could not be read. Using defaults."
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# used for creating and updating the default configuration
|
|
126
|
+
def write(k = nil, v = nil)
|
|
127
|
+
@config[k] = v unless k.nil? # update users yaml config file
|
|
128
|
+
File.write(YAMLCONF, @config.to_yaml)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# always an array, so callers never branch on it: configs written before
|
|
132
|
+
# multi-source support hold a single path string
|
|
133
|
+
def bookmarks = Array(@config[:bookmarks])
|
|
134
|
+
|
|
135
|
+
def searcher = @config[:searcher]
|
|
136
|
+
|
|
137
|
+
# the finder command with its flags, split with Shellwords rather than
|
|
138
|
+
# handed to a shell. nil means "auto detect fzf", which is why #write leaves
|
|
139
|
+
# it out of the defaults: #initialize exits on keys it does not know, so a
|
|
140
|
+
# config naming it is unreadable by a booker predating the key
|
|
141
|
+
def picker = @config[:picker]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# where browsers keep their bookmarks. one implementation, shared by the
|
|
145
|
+
# auto-detection Config falls back on and by `booker --install bookmarks` -
|
|
146
|
+
# the installer's job is to show what auto-detection would find, so a second
|
|
147
|
+
# copy of these paths guarantees it eventually shows something else. nothing
|
|
148
|
+
# here tags a source: Bookmarks.source_for reads that off the filename
|
|
149
|
+
module Sources
|
|
150
|
+
CHROME = [
|
|
151
|
+
"/Library/Application Support/Google/Chrome", # macOS
|
|
152
|
+
"/Library/Application Support/Chromium", # macOS Chromium
|
|
153
|
+
"/.config/google-chrome", # Linux
|
|
154
|
+
"/.config/chromium", # Linux Chromium
|
|
155
|
+
"/snap/chromium/common/chromium", # Linux (snap)
|
|
156
|
+
"/snap/chromium/current/.config/chromium", # Linux (snap alt)
|
|
157
|
+
"/AppData/Local/Google/Chrome/User Data" # Windows
|
|
158
|
+
].freeze
|
|
159
|
+
|
|
160
|
+
FIREFOX = [
|
|
161
|
+
"/.mozilla/firefox", # Linux
|
|
162
|
+
"/snap/firefox/common/.mozilla/firefox", # Linux (snap)
|
|
163
|
+
"/Library/Application Support/Firefox", # macOS
|
|
164
|
+
"/AppData/Roaming/Mozilla/Firefox" # Windows
|
|
165
|
+
].freeze
|
|
166
|
+
|
|
167
|
+
SAFARI = "/Library/Safari/Bookmarks.plist"
|
|
168
|
+
|
|
169
|
+
class << self
|
|
170
|
+
def discover = (chrome + firefox + safari).uniq
|
|
171
|
+
|
|
172
|
+
# every places.sqlite named by a profiles.ini. any Path= counts, and the
|
|
173
|
+
# [Install...] block firefox also writes names a profile already listed,
|
|
174
|
+
# hence the uniq
|
|
175
|
+
def profiles(ini_path, base)
|
|
176
|
+
File.readlines(ini_path).filter_map do |line|
|
|
177
|
+
key, value = line.strip.split("=", 2)
|
|
178
|
+
next unless key == "Path" && value
|
|
179
|
+
|
|
180
|
+
db = File.join(base, value.strip, "places.sqlite")
|
|
181
|
+
db if File.exist?(db)
|
|
182
|
+
end.uniq
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def chrome
|
|
188
|
+
expand(CHROME).flat_map do |base|
|
|
189
|
+
# one level down, not "**": chrome puts Bookmarks directly inside a
|
|
190
|
+
# profile directory, and the rest of the tree is Cache and Service
|
|
191
|
+
# Worker storage - tens of thousands of files to walk for nothing. the
|
|
192
|
+
# second pattern covers a base that already names a profile. globbing
|
|
193
|
+
# relative to base keeps a metacharacter in the path itself - a
|
|
194
|
+
# profile named "Chrome [work]", say - out of the pattern
|
|
195
|
+
Dir.glob(["Bookmarks", "*/Bookmarks"], base: base)
|
|
196
|
+
.map { |relative| File.join(base, relative) }
|
|
197
|
+
.select { |file| File.file?(file) }
|
|
198
|
+
rescue
|
|
199
|
+
# Skip if we can't read this directory
|
|
200
|
+
[]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def firefox
|
|
205
|
+
expand(FIREFOX).flat_map do |base|
|
|
206
|
+
ini = File.join(base, "profiles.ini")
|
|
207
|
+
File.exist?(ini) ? profiles(ini, base) : []
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def safari
|
|
212
|
+
path = Config::HOME + SAFARI
|
|
213
|
+
File.exist?(path) ? [path] : []
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def expand(bases) = bases.map { |b| Config::HOME + b }.select { |b| Dir.exist?(b) }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# everything behind `booker --install`: shell completion, the config file,
|
|
4
|
+
# finding browser bookmarks, and the opt-in safari permission walkthrough
|
|
5
|
+
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "open3"
|
|
8
|
+
|
|
9
|
+
require_relative "output"
|
|
10
|
+
|
|
11
|
+
using Booker::Colors
|
|
12
|
+
|
|
13
|
+
module Booker
|
|
14
|
+
class Installer
|
|
15
|
+
include Output
|
|
16
|
+
|
|
17
|
+
# shells we ship completion for, and where those scripts live
|
|
18
|
+
SHELLS = %w[zsh bash fish].freeze
|
|
19
|
+
|
|
20
|
+
# what ~/.zshrc needs for a script in ~/.zsh/completion to be found
|
|
21
|
+
ZSHRC_LINES = [
|
|
22
|
+
"fpath=(~/.zsh/completion $fpath)",
|
|
23
|
+
"autoload -Uz compinit && compinit"
|
|
24
|
+
].freeze
|
|
25
|
+
COMPLETIONS_DIR = File.expand_path("../../completions", __dir__).freeze
|
|
26
|
+
|
|
27
|
+
# if any of these exist, bash-completion is installed and will autoload our
|
|
28
|
+
# script out of the XDG user directory
|
|
29
|
+
BASH_COMPLETION_MARKERS = [
|
|
30
|
+
"/usr/share/bash-completion/bash_completion",
|
|
31
|
+
"/etc/bash_completion",
|
|
32
|
+
"/usr/local/etc/profile.d/bash_completion.sh",
|
|
33
|
+
"/opt/homebrew/etc/profile.d/bash_completion.sh"
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# 'all' expands to the full install list (including opt-in safari)
|
|
37
|
+
ALL = %w[completion config bookmarks safari].freeze
|
|
38
|
+
|
|
39
|
+
def install(args)
|
|
40
|
+
args.flat_map { |target| /^all$/i.match?(target) ? ALL : target }
|
|
41
|
+
.each { |target| install_one(target) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def install_one(target)
|
|
45
|
+
if /comp/i.match?(target) # completion for every shell on this machine
|
|
46
|
+
install_completion
|
|
47
|
+
elsif (shell = SHELLS.find { |s| target.downcase.include?(s) })
|
|
48
|
+
install_completion_for(shell) # completion for one named shell
|
|
49
|
+
elsif /book/i.match?(target) # bookmarks installation
|
|
50
|
+
install_bookmarks
|
|
51
|
+
elsif /conf/i.match?(target) # default config file generation
|
|
52
|
+
install_config
|
|
53
|
+
elsif /safari/i.match?(target) # opt-in Safari FDA setup (macOS only)
|
|
54
|
+
install_safari
|
|
55
|
+
else # unknown argument passed into install
|
|
56
|
+
pexit "Failure: ".red + "unknown installation option (#{target})"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# install completion for every supported shell present on this machine, so a
|
|
61
|
+
# zsh user who also drops into bash gets both without running install twice
|
|
62
|
+
def install_completion
|
|
63
|
+
found, missing = SHELLS.partition { |shell| shell_present?(shell) }
|
|
64
|
+
|
|
65
|
+
if found.empty?
|
|
66
|
+
puts "Warning: ".yel + "no supported shell found (#{SHELLS.join(", ")})"
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
found.each { |shell| install_completion_for(shell) }
|
|
71
|
+
|
|
72
|
+
puts "Skip: ".yel + "#{missing.join(", ")} not installed" unless missing.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# every shell in SHELLS has an install_completion_<shell>, so adding a fourth
|
|
76
|
+
# means adding the script, the method, and the SHELLS entry - nothing here
|
|
77
|
+
def install_completion_for(shell)
|
|
78
|
+
send("install_completion_#{shell}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def install_completion_zsh
|
|
82
|
+
# check if zsh is even installed for this user - capture3 raises
|
|
83
|
+
# Errno::ENOENT when it is not, same as the backtick this replaces
|
|
84
|
+
begin
|
|
85
|
+
out, _err, _status = Open3.capture3("zsh", "-c", "echo $fpath")
|
|
86
|
+
fpath = out.split(" ")
|
|
87
|
+
rescue
|
|
88
|
+
pexit "Failure: ".red + "zsh is probably not installed, could not find $fpath"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Try user-writable directories first, then system directories
|
|
92
|
+
user_home = home
|
|
93
|
+
writable_dirs = fpath.select do |fp|
|
|
94
|
+
fp.start_with?(user_home) && File.directory?(fp) && File.writable?(fp)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# If no user-writable directories, try to create one
|
|
98
|
+
if writable_dirs.empty?
|
|
99
|
+
user_completion_dir = File.join(user_home, ".zsh", "completion")
|
|
100
|
+
begin
|
|
101
|
+
# mkdir_p is happy either way, but saying "Created" every time a
|
|
102
|
+
# developer reinstalls is a small lie about what just happened
|
|
103
|
+
existed = File.directory?(user_completion_dir)
|
|
104
|
+
FileUtils.mkdir_p(user_completion_dir)
|
|
105
|
+
writable_dirs << user_completion_dir
|
|
106
|
+
puts "Created user completion directory: #{user_completion_dir}".yel unless existed
|
|
107
|
+
|
|
108
|
+
# Auto-configure .zshrc if it exists
|
|
109
|
+
zshrc = File.join(user_home, ".zshrc")
|
|
110
|
+
if File.exist?(zshrc)
|
|
111
|
+
# the marker is the directory, not the whole block: a zshrc that
|
|
112
|
+
# already puts it on $fpath is configured, however it spelled the
|
|
113
|
+
# compinit call next to it
|
|
114
|
+
append_once(zshrc, ZSHRC_LINES, marker: ".zsh/completion", label: "~/.zshrc") do
|
|
115
|
+
puts "Run: ".yel + "source ~/.zshrc".cyan + " to activate"
|
|
116
|
+
end
|
|
117
|
+
else
|
|
118
|
+
puts "Add this to your ~/.zshrc: ".yel + "fpath=(~/.zsh/completion $fpath)".cyan
|
|
119
|
+
end
|
|
120
|
+
rescue
|
|
121
|
+
# Couldn't create user dir, try system dirs as fallback
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Try writable directories first, then all directories as fallback
|
|
126
|
+
dirs_to_try = writable_dirs + fpath.reject { |fp| writable_dirs.include?(fp) }
|
|
127
|
+
|
|
128
|
+
success = false
|
|
129
|
+
dirs_to_try.each do |fp|
|
|
130
|
+
next unless File.directory?(fp)
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
# nothing is autoloaded here on purpose: `zsh -c 'autoload -U
|
|
134
|
+
# _booker'` loaded it into a subshell that exited on the next line.
|
|
135
|
+
# clear_zsh_compdump below is what reaches a new shell
|
|
136
|
+
File.write(File.join(fp, "_booker"), completion_script("_booker"))
|
|
137
|
+
puts "Success: ".grn + "installed zsh autocompletion in #{fp}"
|
|
138
|
+
success = true
|
|
139
|
+
break
|
|
140
|
+
rescue
|
|
141
|
+
# Try next directory silently
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if success
|
|
146
|
+
clear_zsh_compdump
|
|
147
|
+
else
|
|
148
|
+
puts "Warning: ".yel + "Could not install ZSH completion to any directory in $fpath"
|
|
149
|
+
puts "Try manually: ".grn + "mkdir -p ~/.zsh/completion && booker --install zsh"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# compinit caches what it found in $fpath, so a shell started against an old
|
|
154
|
+
# dump keeps describing the completion booker just replaced. dropping the
|
|
155
|
+
# cache - zsh rebuilds it on the next start - is how a new script reaches
|
|
156
|
+
# the next shell. nothing helps the shell you ran this from, which autoloaded
|
|
157
|
+
# _booker for the life of the session, hence the unfunction advice
|
|
158
|
+
def clear_zsh_compdump
|
|
159
|
+
base = ENV["ZDOTDIR"] || home
|
|
160
|
+
dumps = Dir.glob(File.join(base, ".zcompdump*"))
|
|
161
|
+
return if dumps.empty?
|
|
162
|
+
|
|
163
|
+
dumps.each do |dump|
|
|
164
|
+
File.delete(dump)
|
|
165
|
+
rescue
|
|
166
|
+
# a dump we cannot remove is not worth failing an install over
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
puts "Refreshed: ".grn + "zsh completion cache (rebuilds on next shell)"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# bash-completion (when installed) autoloads from the XDG user directory.
|
|
173
|
+
# Without it there is nothing doing the loading, so fall back to a plain
|
|
174
|
+
# directory plus a source line in ~/.bashrc.
|
|
175
|
+
def install_completion_bash
|
|
176
|
+
autoloaded = bash_completion_present?
|
|
177
|
+
dir = if autoloaded
|
|
178
|
+
File.join(xdg_data_home, "bash-completion", "completions")
|
|
179
|
+
else
|
|
180
|
+
File.join(home, ".bash_completion.d")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
FileUtils.mkdir_p(dir)
|
|
184
|
+
File.write(File.join(dir, "booker"), completion_script("booker.bash"))
|
|
185
|
+
puts "Success: ".grn + "installed bash completion in #{dir}"
|
|
186
|
+
|
|
187
|
+
# with bash-completion installed there is nothing left to wire up
|
|
188
|
+
unless autoloaded
|
|
189
|
+
append_once(
|
|
190
|
+
File.join(home, ".bashrc"),
|
|
191
|
+
"[ -f ~/.bash_completion.d/booker ] && . ~/.bash_completion.d/booker"
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
puts "Run: ".yel + "source ~/.bashrc".cyan + " to activate"
|
|
196
|
+
rescue => e
|
|
197
|
+
puts "Warning: ".yel + "could not install bash completion (#{e.message})"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# fish autoloads anything in its completions directory, so there is no rc
|
|
201
|
+
# file to edit here
|
|
202
|
+
def install_completion_fish
|
|
203
|
+
dir = File.join(xdg_config_home, "fish", "completions")
|
|
204
|
+
FileUtils.mkdir_p(dir)
|
|
205
|
+
File.write(File.join(dir, "booker.fish"), completion_script("booker.fish"))
|
|
206
|
+
puts "Success: ".grn + "installed fish completion in #{dir}"
|
|
207
|
+
puts "Run: ".yel + "exec fish".cyan + " to activate"
|
|
208
|
+
rescue => e
|
|
209
|
+
puts "Warning: ".yel + "could not install fish completion (#{e.message})"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# read one of the shipped completion scripts (completions/ sits next to lib/,
|
|
213
|
+
# and is listed in the gemspec so it ships with the gem)
|
|
214
|
+
def completion_script(name)
|
|
215
|
+
File.read(File.join(COMPLETIONS_DIR, name))
|
|
216
|
+
rescue Errno::ENOENT
|
|
217
|
+
pexit "Failure: ".red + "completion script #{name} missing from #{COMPLETIONS_DIR}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Picker.which, not `sh -c "command -v"`: a fork per shell to answer a PATH
|
|
221
|
+
# question booker already knows how to answer in process
|
|
222
|
+
def shell_present?(shell) = !Picker.which(shell).nil?
|
|
223
|
+
|
|
224
|
+
def bash_completion_present?
|
|
225
|
+
BASH_COMPLETION_MARKERS.any? { |marker| File.exist?(marker) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# append to an rc file once - install is expected to be re-runnable without
|
|
229
|
+
# stacking up duplicates. `marker` is what counts as "already there" when
|
|
230
|
+
# that is narrower than what gets written; `label` is the name shown
|
|
231
|
+
def append_once(rcfile, lines, marker: lines, label: rcfile)
|
|
232
|
+
if File.exist?(rcfile) && File.read(rcfile).include?(marker)
|
|
233
|
+
puts "#{label} already configured".grn
|
|
234
|
+
return
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
File.open(rcfile, "a") do |f|
|
|
238
|
+
f.puts "\n# Booker completion"
|
|
239
|
+
f.puts lines
|
|
240
|
+
end
|
|
241
|
+
puts "Added completion to #{label}".grn
|
|
242
|
+
yield if block_given?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def home
|
|
246
|
+
ENV["HOME"] || "/usr/local"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def xdg_data_home
|
|
250
|
+
ENV["XDG_DATA_HOME"] || File.join(home, ".local", "share")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def xdg_config_home
|
|
254
|
+
ENV["XDG_CONFIG_HOME"] || File.join(home, ".config")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# locate bookmarks files, show the user, write the choice to the config.
|
|
258
|
+
# the search is Sources.discover, the same code auto-detection falls back
|
|
259
|
+
# on, so this offers exactly what booker would find on its own
|
|
260
|
+
def install_bookmarks
|
|
261
|
+
puts "searching for browser bookmarks..."
|
|
262
|
+
begin
|
|
263
|
+
bms = Sources.discover
|
|
264
|
+
|
|
265
|
+
if bms.empty? # no bookmarks found
|
|
266
|
+
puts "Failure: ".red + "bookmarks file could not be found."
|
|
267
|
+
raise
|
|
268
|
+
elsif bms.length == 1
|
|
269
|
+
# Auto-select if only one source found
|
|
270
|
+
selected = bms.first
|
|
271
|
+
puts "Found bookmark source: #{bookmark_type_label(Bookmarks.source_for(selected))} #{selected}".yel
|
|
272
|
+
save_bookmarks(selected, "config file updated with your bookmarks")
|
|
273
|
+
else # have user select a file
|
|
274
|
+
puts "select bookmarks source: "
|
|
275
|
+
|
|
276
|
+
# Offer "ALL" as first option if multiple sources found
|
|
277
|
+
puts "0".grn + " - " + "[ALL SOURCES]".cyan + " (search across all browsers)"
|
|
278
|
+
|
|
279
|
+
bms.each_with_index do |path, i|
|
|
280
|
+
label = bookmark_type_label(Bookmarks.source_for(path), color: true)
|
|
281
|
+
puts (i + 1).to_s.grn + " - " + label + " " + path
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
input = gets
|
|
285
|
+
raise "No input provided" if input.nil?
|
|
286
|
+
selection = input.chomp.to_i
|
|
287
|
+
|
|
288
|
+
if selection == 0
|
|
289
|
+
# User selected "ALL" - save array of all paths
|
|
290
|
+
puts "Selected: ".yel + "All sources (#{bms.length} bookmark files)"
|
|
291
|
+
Config.default.write(:bookmarks, bms)
|
|
292
|
+
puts "Success: ".grn + "config file updated to search all bookmark sources"
|
|
293
|
+
else
|
|
294
|
+
save_bookmarks(bms[selection - 1], "config file updated with your bookmarks")
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
rescue => e
|
|
298
|
+
puts e.message
|
|
299
|
+
pexit "Failure: ".red + "could not add bookmarks to config file ~/.booker"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def save_bookmarks(selected, message)
|
|
304
|
+
puts "Selected: ".yel + selected
|
|
305
|
+
Config.default.write(:bookmarks, selected)
|
|
306
|
+
puts "Success: ".grn + message
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# the label and its color come off the browser table, so a fourth browser
|
|
310
|
+
# is a row there rather than two more branches here
|
|
311
|
+
def bookmark_type_label(type, color: false)
|
|
312
|
+
browser = Bookmarks::BROWSERS[type]
|
|
313
|
+
return "[?]" if browser.nil?
|
|
314
|
+
|
|
315
|
+
color ? Colors.paint(browser[:label], browser[:color]) : browser[:label]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def install_config
|
|
319
|
+
Config.default.write
|
|
320
|
+
puts "Success: ".grn + "example config file written to ~/.booker"
|
|
321
|
+
rescue
|
|
322
|
+
pexit "Failure: ".red + "could not write example config file to ~/.booker"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Opt-in Safari setup: walks through granting Full Disk Access so booker
|
|
326
|
+
# can read ~/Library/Safari/Bookmarks.plist. Not included in the default
|
|
327
|
+
# --install flow because it requires a TCC permission grant.
|
|
328
|
+
def install_safari
|
|
329
|
+
plist = File.join(home, "Library/Safari/Bookmarks.plist")
|
|
330
|
+
fda_url = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
|
|
331
|
+
|
|
332
|
+
unless RUBY_PLATFORM.include?("darwin")
|
|
333
|
+
puts "Skip: ".yel + "Safari support is macOS-only."
|
|
334
|
+
return
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
unless File.exist?(plist)
|
|
338
|
+
puts "Skip: ".yel + "Safari bookmarks not found at #{plist}"
|
|
339
|
+
puts "Hint: ".grn + "launch Safari at least once, then re-run."
|
|
340
|
+
return
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
if safari_readable?(plist)
|
|
344
|
+
puts "PASS: ".grn + "Safari bookmarks are already readable."
|
|
345
|
+
return
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
puts "Safari stores bookmarks at:"
|
|
349
|
+
puts " #{plist}".cyan
|
|
350
|
+
puts
|
|
351
|
+
puts "That file is protected by macOS TCC. Grant Full Disk Access to one of:"
|
|
352
|
+
puts
|
|
353
|
+
puts " [A] ".cyan + "Your terminal app".yel + " (simplest; inherited by any tool)"
|
|
354
|
+
puts " [B] ".cyan + "/usr/bin/plutil only".yel + " (narrower scope)"
|
|
355
|
+
puts
|
|
356
|
+
print "Pick [A] or [B] (default A): "
|
|
357
|
+
$stdout.flush
|
|
358
|
+
choice = $stdin.gets.to_s.strip.upcase
|
|
359
|
+
choice = "A" if choice.empty?
|
|
360
|
+
pexit "Error: ".red + "invalid choice." unless %w[A B].include?(choice)
|
|
361
|
+
|
|
362
|
+
puts
|
|
363
|
+
puts "Opening the Full Disk Access pane..."
|
|
364
|
+
system("open", fda_url)
|
|
365
|
+
puts
|
|
366
|
+
|
|
367
|
+
puts "In the pane that just opened:"
|
|
368
|
+
if choice == "A"
|
|
369
|
+
puts " 1. Click ".yel + "+".cyan + ", add your terminal app from /Applications"
|
|
370
|
+
puts " 2. Toggle it ".yel + "on".cyan
|
|
371
|
+
puts " 3. ".yel + "Fully quit".cyan + " the terminal (Cmd+Q), reopen it,"
|
|
372
|
+
puts " and re-run ".yel + "booker --install safari".cyan + " to verify."
|
|
373
|
+
else
|
|
374
|
+
puts " 1. Click ".yel + "+".cyan
|
|
375
|
+
puts " 2. Press ".yel + "Cmd+Shift+G".cyan + " (opens 'Go to Folder')".yel
|
|
376
|
+
puts " 3. Type ".yel + "/usr/bin/plutil".cyan + " and press Return".yel
|
|
377
|
+
puts " 4. Click ".yel + "Open".cyan + ", then toggle it ".yel + "on".cyan
|
|
378
|
+
puts
|
|
379
|
+
print "Press Return once you've added plutil and toggled it on... "
|
|
380
|
+
$stdout.flush
|
|
381
|
+
$stdin.gets
|
|
382
|
+
|
|
383
|
+
if safari_readable?(plist)
|
|
384
|
+
puts "PASS: ".grn + "Safari bookmarks are now readable."
|
|
385
|
+
else
|
|
386
|
+
puts "FAIL: ".red + "still can't read the bookmarks file."
|
|
387
|
+
puts "Try fully quitting the terminal (Cmd+Q) and re-running."
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def safari_readable?(plist)
|
|
393
|
+
system("plutil", "-lint", "-s", plist, out: File::NULL, err: File::NULL)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# terminal width, colored strings, and exiting with a message: the presentation
|
|
4
|
+
# helpers the rest of booker leans on
|
|
5
|
+
|
|
6
|
+
require "io/console"
|
|
7
|
+
|
|
8
|
+
module Booker
|
|
9
|
+
# int number of columns on screen, answered in process rather than by a `tput
|
|
10
|
+
# cols` subshell on every run. io/console reads /dev/tty rather than stdout,
|
|
11
|
+
# so a width still comes back through a pipe, and nil when there is no
|
|
12
|
+
# terminal at all - a completion subshell, or ci
|
|
13
|
+
module Term
|
|
14
|
+
def self.width
|
|
15
|
+
@width ||= IO.console&.winsize&.last&.nonzero? ||
|
|
16
|
+
ENV["COLUMNS"]&.to_i&.nonzero? ||
|
|
17
|
+
100
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# compl. color codes space
|
|
22
|
+
CODEWIDTH = 16
|
|
23
|
+
|
|
24
|
+
# print a message and stop, for the cli and the installer both reporting a
|
|
25
|
+
# failure and exiting in one breath. stderr, not stdout: stdout is the data
|
|
26
|
+
# channel the completion scripts read
|
|
27
|
+
module Output
|
|
28
|
+
def pexit(msg, sig = 1)
|
|
29
|
+
warn msg
|
|
30
|
+
exit sig
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# "Success: ".grn at every call site, but as a refinement rather than an open
|
|
35
|
+
# class: a gem claiming String#red for the whole process is taking a name it
|
|
36
|
+
# does not own. files that want these say `using Booker::Colors` at the top
|
|
37
|
+
module Colors
|
|
38
|
+
CODES = {red: 31, grn: 32, yel: 33, blu: 34, cyan: 36}.freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# nil means decide per call from the environment; true or false forces it,
|
|
42
|
+
# which is what the specs do since their $stdout is never a terminal
|
|
43
|
+
attr_writer :enabled
|
|
44
|
+
|
|
45
|
+
# honor NO_COLOR (no-color.org), and skip the escapes when stdout is not a
|
|
46
|
+
# terminal - otherwise `booker > out.txt` and `booker | less` fill up with
|
|
47
|
+
# raw \033[ sequences
|
|
48
|
+
def enabled?
|
|
49
|
+
return @enabled unless @enabled.nil?
|
|
50
|
+
|
|
51
|
+
ENV["NO_COLOR"].to_s.empty? && $stdout.tty?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# color by name, reachable without the refinement. a caller holding the
|
|
55
|
+
# color as data - the browser table's :yel, say - cannot go through the
|
|
56
|
+
# refined methods, because refinements are invisible to send
|
|
57
|
+
def paint(str, name)
|
|
58
|
+
return str unless enabled?
|
|
59
|
+
|
|
60
|
+
"\033[0;#{CODES.fetch(name)};49m#{str}\033[0;0m"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
refine String do
|
|
65
|
+
def window(width)
|
|
66
|
+
if length >= width
|
|
67
|
+
self[0..width - 1]
|
|
68
|
+
else
|
|
69
|
+
ljust(width)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def blu = Colors.paint(self, :blu)
|
|
74
|
+
|
|
75
|
+
def cyan = Colors.paint(self, :cyan)
|
|
76
|
+
|
|
77
|
+
def yel = Colors.paint(self, :yel)
|
|
78
|
+
|
|
79
|
+
def grn = Colors.paint(self, :grn)
|
|
80
|
+
|
|
81
|
+
def red = Colors.paint(self, :red)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|