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,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# one parser per browser: chrome keeps bookmarks in json, firefox in sqlite, and
|
|
4
|
+
# safari in a binary plist. each turns its own format into Booker::Bookmark
|
|
5
|
+
# objects, so Bookmarks itself never has to care which browser it is reading.
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require "fileutils"
|
|
9
|
+
require "open3"
|
|
10
|
+
|
|
11
|
+
require_relative "output"
|
|
12
|
+
|
|
13
|
+
using Booker::Colors
|
|
14
|
+
|
|
15
|
+
module Booker
|
|
16
|
+
module Parsers
|
|
17
|
+
# Abstract base class for bookmark parsers
|
|
18
|
+
class Base
|
|
19
|
+
attr_reader :allurls
|
|
20
|
+
|
|
21
|
+
def initialize(file_path, search_term)
|
|
22
|
+
@file_path = file_path
|
|
23
|
+
# escaped, not interpolated: the term comes straight off ARGV, so
|
|
24
|
+
# `booker "c++"` used to raise RegexpError before it ever searched
|
|
25
|
+
@searching = Regexp.new(Regexp.escape(search_term), Regexp::IGNORECASE)
|
|
26
|
+
# an empty term is the completion scripts' bare `booker <TAB>`, and //
|
|
27
|
+
# matches every field of every bookmark - so answer it once here rather
|
|
28
|
+
# than running four guaranteed matches per node
|
|
29
|
+
@match_all = search_term.empty?
|
|
30
|
+
@allurls = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Abstract method - must be implemented by subclasses
|
|
34
|
+
def parse
|
|
35
|
+
raise NotImplementedError, "Subclasses must implement parse method"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def results = @allurls
|
|
39
|
+
|
|
40
|
+
protected
|
|
41
|
+
|
|
42
|
+
# match? rather than match: this runs on every node of every source, and
|
|
43
|
+
# the MatchData the latter allocates is thrown away unread
|
|
44
|
+
def matches_search?(values)
|
|
45
|
+
@match_all || values.any? { |v| v && @searching.match?(v.to_s) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Chrome/Chromium bookmark parser (JSON format)
|
|
50
|
+
class Chrome < Base
|
|
51
|
+
def parse
|
|
52
|
+
begin
|
|
53
|
+
local_bookmarks = JSON.parse(File.read(@file_path))
|
|
54
|
+
@chrome_bookmarks = local_bookmarks["roots"]["bookmark_bar"]["children"]
|
|
55
|
+
rescue
|
|
56
|
+
warn "Warning: ".yel + "Bookmarks file not found or invalid."
|
|
57
|
+
warn "Suggest: ".grn + "booker --install bookmarks"
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
parse_recursive
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def parse_recursive(root = nil)
|
|
67
|
+
root = Folder.new(@chrome_bookmarks, "|") if root.nil?
|
|
68
|
+
|
|
69
|
+
root.json.each { |x| parse_link(root.title, x) }
|
|
70
|
+
root.json.each { |x| parse_folder(root, x) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_link(base, link)
|
|
74
|
+
return unless link["type"] == "url"
|
|
75
|
+
return unless matches_search?([base, link["name"], link["url"], link["id"]])
|
|
76
|
+
|
|
77
|
+
@allurls.push(Bookmark.new(
|
|
78
|
+
folder: base, title: link["name"], url: link["url"], id: link["id"]
|
|
79
|
+
))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parse_folder(base, link)
|
|
83
|
+
if link["type"] == "folder"
|
|
84
|
+
title = base.title + link["name"] + "/"
|
|
85
|
+
subdir = Folder.new(link["children"], title)
|
|
86
|
+
parse_recursive(subdir)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Firefox bookmark parser (SQLite format)
|
|
92
|
+
class Firefox < Base
|
|
93
|
+
class << self
|
|
94
|
+
# "is firefox running" is a question about the machine, not about a
|
|
95
|
+
# profile, so a config naming three profiles asked pgrep three times -
|
|
96
|
+
# a fork each, and the slowest single thing booker did on that config
|
|
97
|
+
attr_writer :running
|
|
98
|
+
|
|
99
|
+
def running?
|
|
100
|
+
return @running unless @running.nil?
|
|
101
|
+
|
|
102
|
+
out, _err, _status = Open3.capture3("pgrep", "-x", "firefox")
|
|
103
|
+
@running = !out.strip.empty?
|
|
104
|
+
rescue
|
|
105
|
+
@running = false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# the memo outlives a single example, so the suite drops it between them
|
|
109
|
+
def reset! = @running = nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse
|
|
113
|
+
begin
|
|
114
|
+
require "sqlite3"
|
|
115
|
+
rescue LoadError
|
|
116
|
+
warn "Error: ".red + "sqlite3 gem not installed"
|
|
117
|
+
warn "Run: ".grn + "gem install sqlite3"
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Copy database to temp file if Firefox is running (to avoid lock).
|
|
122
|
+
# Tempfile.create removes the copy when the block ends, so there is no
|
|
123
|
+
# unlink left to remember in an ensure further down
|
|
124
|
+
if firefox_running?
|
|
125
|
+
require "tempfile"
|
|
126
|
+
Tempfile.create(["places", ".sqlite"]) do |temp|
|
|
127
|
+
temp.close
|
|
128
|
+
FileUtils.cp(@file_path, temp.path)
|
|
129
|
+
query_places(temp.path)
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
query_places(@file_path)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def query_places(db_path)
|
|
139
|
+
# the block form closes the handle for us, on the error path too
|
|
140
|
+
SQLite3::Database.new(db_path) do |db|
|
|
141
|
+
db.results_as_hash = true
|
|
142
|
+
|
|
143
|
+
# Recursive CTE query to build folder paths
|
|
144
|
+
query = <<-SQL
|
|
145
|
+
WITH RECURSIVE bookmark_tree(id, parent, title, url, path) AS (
|
|
146
|
+
-- Start with bookmarks toolbar root
|
|
147
|
+
SELECT b.id, b.parent, b.title, p.url,
|
|
148
|
+
CAST(b.title as TEXT) as path
|
|
149
|
+
FROM moz_bookmarks b
|
|
150
|
+
LEFT JOIN moz_places p ON b.fk = p.id
|
|
151
|
+
WHERE b.parent = (
|
|
152
|
+
SELECT id FROM moz_bookmarks
|
|
153
|
+
WHERE guid = 'toolbar_____'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
UNION ALL
|
|
157
|
+
|
|
158
|
+
-- Recursively get children
|
|
159
|
+
SELECT b.id, b.parent, b.title, p.url,
|
|
160
|
+
CAST(bt.path || '/' || b.title as TEXT) as path
|
|
161
|
+
FROM moz_bookmarks b
|
|
162
|
+
INNER JOIN bookmark_tree bt ON b.parent = bt.id
|
|
163
|
+
LEFT JOIN moz_places p ON b.fk = p.id
|
|
164
|
+
)
|
|
165
|
+
SELECT id, title, path, url
|
|
166
|
+
FROM bookmark_tree
|
|
167
|
+
WHERE url IS NOT NULL
|
|
168
|
+
ORDER BY path, title
|
|
169
|
+
SQL
|
|
170
|
+
|
|
171
|
+
db.execute(query).each do |row|
|
|
172
|
+
folder_path = row["path"].to_s.split("/")[0..-2].join("/") + "/"
|
|
173
|
+
folder_path = "|" + Folder.normalize(folder_path)
|
|
174
|
+
|
|
175
|
+
values = [folder_path, row["title"], row["url"], row["id"].to_s]
|
|
176
|
+
if matches_search?(values)
|
|
177
|
+
@allurls << Bookmark.new(
|
|
178
|
+
folder: folder_path,
|
|
179
|
+
title: row["title"] || "",
|
|
180
|
+
url: row["url"] || "",
|
|
181
|
+
id: row["id"].to_s
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
rescue SQLite3::Exception => e
|
|
187
|
+
warn "Error: ".red + "Could not read Firefox bookmarks database"
|
|
188
|
+
warn e.message
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def firefox_running? = File.exist?(@file_path) && self.class.running?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Safari bookmark parser (binary plist format)
|
|
195
|
+
class Safari < Base
|
|
196
|
+
def parse
|
|
197
|
+
xml = convert_plist_to_xml(@file_path)
|
|
198
|
+
return if xml.nil?
|
|
199
|
+
|
|
200
|
+
begin
|
|
201
|
+
require "rexml/document"
|
|
202
|
+
doc = REXML::Document.new(xml)
|
|
203
|
+
plist_el = doc.root
|
|
204
|
+
root_node = plist_el.elements.to_a.first
|
|
205
|
+
root = parse_node(root_node)
|
|
206
|
+
rescue => e
|
|
207
|
+
warn "Warning: ".yel + "Could not parse Safari bookmarks: #{e.message}"
|
|
208
|
+
return
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
walk(root, "|")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def convert_plist_to_xml(path)
|
|
217
|
+
# sniff the head rather than reading the whole file: on the binary path
|
|
218
|
+
# the contents are thrown away and plutil reads it again from disk, and
|
|
219
|
+
# a real Bookmarks.plist grows with the user's bookmarks
|
|
220
|
+
head = File.binread(path, 512).to_s
|
|
221
|
+
|
|
222
|
+
# Already an XML plist (fixtures, non-macOS). Binary plists need plutil
|
|
223
|
+
# (macOS-only); the JSON converter rejects <data>/<date> fields, so xml1.
|
|
224
|
+
return File.read(path) if head.start_with?("<?xml") || head.include?("<plist")
|
|
225
|
+
|
|
226
|
+
# capture3 hands back the status directly, rather than leaving it in the
|
|
227
|
+
# $? global for the next line to read
|
|
228
|
+
output, _err, status = Open3.capture3("plutil", "-convert", "xml1", "-o", "-", path)
|
|
229
|
+
return output if status.success?
|
|
230
|
+
|
|
231
|
+
warn "Warning: ".yel + "Could not read Safari bookmarks file."
|
|
232
|
+
warn "Suggest: ".grn + "grant terminal 'Full Disk Access' in System Settings"
|
|
233
|
+
nil
|
|
234
|
+
rescue Errno::ENOENT
|
|
235
|
+
warn "Warning: ".yel + "Safari bookmarks file not found."
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def parse_node(el)
|
|
240
|
+
case el.name
|
|
241
|
+
when "dict"
|
|
242
|
+
result = {}
|
|
243
|
+
children = el.elements.to_a
|
|
244
|
+
children.each_slice(2) do |key_el, val_el|
|
|
245
|
+
next if key_el.nil? || val_el.nil?
|
|
246
|
+
result[key_el.text.to_s] = parse_node(val_el)
|
|
247
|
+
end
|
|
248
|
+
result
|
|
249
|
+
when "array"
|
|
250
|
+
el.elements.map { |c| parse_node(c) }
|
|
251
|
+
when "string"
|
|
252
|
+
el.text.to_s
|
|
253
|
+
when "integer"
|
|
254
|
+
el.text.to_i
|
|
255
|
+
when "real"
|
|
256
|
+
el.text.to_f
|
|
257
|
+
when "true"
|
|
258
|
+
true
|
|
259
|
+
when "false"
|
|
260
|
+
false
|
|
261
|
+
when "data", "date"
|
|
262
|
+
el.text.to_s
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def walk(node, folder_title)
|
|
267
|
+
children = node["Children"]
|
|
268
|
+
return unless children.is_a?(Array)
|
|
269
|
+
|
|
270
|
+
children.each do |child|
|
|
271
|
+
case child["WebBookmarkType"]
|
|
272
|
+
when "WebBookmarkTypeLeaf"
|
|
273
|
+
parse_leaf(folder_title, child)
|
|
274
|
+
when "WebBookmarkTypeList"
|
|
275
|
+
sub_title = folder_title + Folder.normalize(child["Title"].to_s) + "/"
|
|
276
|
+
walk(child, sub_title)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def parse_leaf(folder, leaf)
|
|
282
|
+
uri = leaf["URIDictionary"] || {}
|
|
283
|
+
title = (uri["title"] || "").to_s
|
|
284
|
+
url = (leaf["URLString"] || "").to_s
|
|
285
|
+
id = (leaf["WebBookmarkUUID"] || "").to_s
|
|
286
|
+
|
|
287
|
+
values = [folder, title, url, id]
|
|
288
|
+
if matches_search?(values)
|
|
289
|
+
@allurls << Bookmark.new(folder:, title:, url:, id:)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# an external fuzzy finder (fzf by default) as booker's selection ui: candidate
|
|
4
|
+
# lines in on stdin, the chosen one back on stdout, with the finder opening
|
|
5
|
+
# /dev/tty itself for the interface. it knows nothing about bookmarks - lines
|
|
6
|
+
# in, first tab separated field out - which keeps it testable against `head`
|
|
7
|
+
# and `cat` rather than against a real finder.
|
|
8
|
+
|
|
9
|
+
require "shellwords"
|
|
10
|
+
|
|
11
|
+
module Booker
|
|
12
|
+
module Picker
|
|
13
|
+
# --delimiter/--with-nth hide the id column: it is noise on screen, and
|
|
14
|
+
# hiding it also takes it out of the match, so typing "2_" does not pull up
|
|
15
|
+
# every bookmark from the second source. --no-sort keeps booker's ordering.
|
|
16
|
+
#
|
|
17
|
+
# deliberately no --multi: under it fzf reads tab as "mark this and move
|
|
18
|
+
# down" - the one key booker's own completion trained everybody to press -
|
|
19
|
+
# so a couple of taps to scroll would mark bookmarks silently and open them
|
|
20
|
+
# all on the next return.
|
|
21
|
+
#
|
|
22
|
+
# a plain array literal, not %w[]: neither the real tab in the delimiter nor
|
|
23
|
+
# the prompt's trailing space survives %w[]
|
|
24
|
+
DEFAULT = [
|
|
25
|
+
"fzf",
|
|
26
|
+
"--height=40%",
|
|
27
|
+
"--reverse",
|
|
28
|
+
"--no-sort",
|
|
29
|
+
"--delimiter=\t",
|
|
30
|
+
"--with-nth=2..",
|
|
31
|
+
"--prompt=bookmark> "
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# nil means decide from the environment; true or false forces it. the
|
|
36
|
+
# specs set this, and so does --list - same shape as Colors.enabled=
|
|
37
|
+
attr_writer :enabled
|
|
38
|
+
|
|
39
|
+
# #enabled? and #select both ask for the command, so an interactive run
|
|
40
|
+
# worked it out twice; both memos outlive an example, so the suite drops
|
|
41
|
+
# them between them
|
|
42
|
+
def reset!
|
|
43
|
+
@enabled = nil
|
|
44
|
+
@command = nil
|
|
45
|
+
@command_known = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enabled?
|
|
49
|
+
return @enabled unless @enabled.nil?
|
|
50
|
+
|
|
51
|
+
# tty? first: it is a pair of syscalls, where #command reads and parses
|
|
52
|
+
# ~/.booker.yml, and a run that is not interactive never needs to know
|
|
53
|
+
# whether a finder is installed
|
|
54
|
+
tty? && !command.nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# both ends, not just stdout: `booker foo | less` still writes to a
|
|
58
|
+
# terminal, but there is no keyboard on the other side of the pipe
|
|
59
|
+
def tty? = $stdin.tty? && $stdout.tty?
|
|
60
|
+
|
|
61
|
+
# the configured picker, or fzf when it is on PATH. nil when neither is
|
|
62
|
+
# installed, which is the signal to stay on booker's pre-picker behavior
|
|
63
|
+
def command
|
|
64
|
+
return @command if @command_known
|
|
65
|
+
|
|
66
|
+
@command_known = true
|
|
67
|
+
@command = resolve_command
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# no subshell to `which`: this runs before every interactive invocation,
|
|
71
|
+
# and the answer is a PATH scan booker can do itself. a name carrying a
|
|
72
|
+
# separator is a path, exactly as execvp treats it
|
|
73
|
+
def which(bin)
|
|
74
|
+
return executable?(bin) ? bin : nil if bin.include?(File::SEPARATOR)
|
|
75
|
+
|
|
76
|
+
# lazy: the answer is usually in the first few entries, and eagerly
|
|
77
|
+
# joining all forty PATH directories to throw them away is the one
|
|
78
|
+
# thing a hand written which is here to avoid
|
|
79
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).lazy
|
|
80
|
+
.map { |dir| File.join(dir, bin) }
|
|
81
|
+
.find { |path| executable?(path) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def executable?(path) = File.executable?(path) && !File.directory?(path)
|
|
85
|
+
|
|
86
|
+
# candidates in, the id of what was picked out - or nil for every other
|
|
87
|
+
# outcome: cancelled, nothing matched, no usable finder. one id and not a
|
|
88
|
+
# list, since dropping --multi above only covers the default finder and
|
|
89
|
+
# not one named in ~/.booker.yml
|
|
90
|
+
def select(lines)
|
|
91
|
+
argv = command
|
|
92
|
+
return nil if argv.nil?
|
|
93
|
+
|
|
94
|
+
io = IO.popen(argv, "r+")
|
|
95
|
+
feed(io, lines)
|
|
96
|
+
chosen = io.read
|
|
97
|
+
io.close
|
|
98
|
+
|
|
99
|
+
# fzf: 0 selected, 1 no match, 2 error, 130 interrupted. a selection
|
|
100
|
+
# that came back empty counts as a cancel too - that pairs with the
|
|
101
|
+
# finder exiting 0 the moment it is closed, before it has read anything
|
|
102
|
+
return nil unless Process.last_status&.exitstatus&.zero?
|
|
103
|
+
|
|
104
|
+
id = chosen.lines.first.to_s.split("\t", 2).first.to_s.strip
|
|
105
|
+
id unless id.empty?
|
|
106
|
+
rescue Errno::ENOENT, Interrupt
|
|
107
|
+
# the binary went away between the PATH check and the spawn, or the
|
|
108
|
+
# user interrupted booker itself rather than the finder
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def resolve_command
|
|
115
|
+
configured = Config.default.picker
|
|
116
|
+
|
|
117
|
+
# yaml reads a bare `:picker: false` as the boolean, and that means
|
|
118
|
+
# "leave the picker off" - otherwise it looks like nothing was
|
|
119
|
+
# configured and quietly starts fzf instead. blank says the same thing
|
|
120
|
+
return nil if configured == false
|
|
121
|
+
|
|
122
|
+
argv = configured ? Shellwords.split(configured.to_s) : DEFAULT
|
|
123
|
+
(!argv.empty? && which(argv.first)) ? argv : nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# one write, not one per bookmark. IO.popen hands back a synced IO, so
|
|
127
|
+
# `puts` per line is an unbuffered syscall each - measured 10x slower
|
|
128
|
+
# than a single write at a few thousand bookmarks
|
|
129
|
+
def feed(io, lines)
|
|
130
|
+
io.write(lines.join("\n") + "\n") unless lines.empty?
|
|
131
|
+
io.close_write
|
|
132
|
+
rescue Errno::EPIPE
|
|
133
|
+
# expected, not exceptional: the finder exits the instant escape is
|
|
134
|
+
# pressed, and with a few hundred bookmarks booker is usually still
|
|
135
|
+
# writing. the exit status above is what says what happened
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|