booker 1.3.0 → 2.1.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.
@@ -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
@@ -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,142 @@
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. fzf ranks by match score, so a
16
+ # literal "sofa" beats s..o..f..a scattered across a long url, and
17
+ # --tiebreak=index keeps booker's ordering among equal scores (and for the
18
+ # empty query, which fzf never sorts).
19
+ #
20
+ # deliberately no --multi: under it fzf reads tab as "mark this and move
21
+ # down" - the one key booker's own completion trained everybody to press -
22
+ # so a couple of taps to scroll would mark bookmarks silently and open them
23
+ # all on the next return.
24
+ #
25
+ # a plain array literal, not %w[]: neither the real tab in the delimiter nor
26
+ # the prompt's trailing space survives %w[]
27
+ DEFAULT = [
28
+ "fzf",
29
+ "--height=40%",
30
+ "--reverse",
31
+ "--tiebreak=index",
32
+ "--delimiter=\t",
33
+ "--with-nth=2..",
34
+ "--prompt=bookmark> "
35
+ ].freeze
36
+
37
+ class << self
38
+ # nil means decide from the environment; true or false forces it. the
39
+ # specs set this, and so does --list - same shape as Colors.enabled=
40
+ attr_writer :enabled
41
+
42
+ # #enabled? and #select both ask for the command, so an interactive run
43
+ # worked it out twice; both memos outlive an example, so the suite drops
44
+ # them between them
45
+ def reset!
46
+ @enabled = nil
47
+ @command = nil
48
+ @command_known = false
49
+ end
50
+
51
+ def enabled?
52
+ return @enabled unless @enabled.nil?
53
+
54
+ # tty? first: it is a pair of syscalls, where #command reads and parses
55
+ # ~/.booker.yml, and a run that is not interactive never needs to know
56
+ # whether a finder is installed
57
+ tty? && !command.nil?
58
+ end
59
+
60
+ # both ends, not just stdout: `booker foo | less` still writes to a
61
+ # terminal, but there is no keyboard on the other side of the pipe
62
+ def tty? = $stdin.tty? && $stdout.tty?
63
+
64
+ # the configured picker, or fzf when it is on PATH. nil when neither is
65
+ # installed, which is the signal to stay on booker's pre-picker behavior
66
+ def command
67
+ return @command if @command_known
68
+
69
+ @command_known = true
70
+ @command = resolve_command
71
+ end
72
+
73
+ # no subshell to `which`: this runs before every interactive invocation,
74
+ # and the answer is a PATH scan booker can do itself. a name carrying a
75
+ # separator is a path, exactly as execvp treats it
76
+ def which(bin)
77
+ return executable?(bin) ? bin : nil if bin.include?(File::SEPARATOR)
78
+
79
+ # lazy: the answer is usually in the first few entries, and eagerly
80
+ # joining all forty PATH directories to throw them away is the one
81
+ # thing a hand written which is here to avoid
82
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).lazy
83
+ .map { |dir| File.join(dir, bin) }
84
+ .find { |path| executable?(path) }
85
+ end
86
+
87
+ def executable?(path) = File.executable?(path) && !File.directory?(path)
88
+
89
+ # candidates in, the id of what was picked out - or nil for every other
90
+ # outcome: cancelled, nothing matched, no usable finder. one id and not a
91
+ # list, since dropping --multi above only covers the default finder and
92
+ # not one named in ~/.booker.yml
93
+ def select(lines)
94
+ argv = command
95
+ return nil if argv.nil?
96
+
97
+ io = IO.popen(argv, "r+")
98
+ feed(io, lines)
99
+ chosen = io.read
100
+ io.close
101
+
102
+ # fzf: 0 selected, 1 no match, 2 error, 130 interrupted. a selection
103
+ # that came back empty counts as a cancel too - that pairs with the
104
+ # finder exiting 0 the moment it is closed, before it has read anything
105
+ return nil unless Process.last_status&.exitstatus&.zero?
106
+
107
+ id = chosen.lines.first.to_s.split("\t", 2).first.to_s.strip
108
+ id unless id.empty?
109
+ rescue Errno::ENOENT, Interrupt
110
+ # the binary went away between the PATH check and the spawn, or the
111
+ # user interrupted booker itself rather than the finder
112
+ nil
113
+ end
114
+
115
+ private
116
+
117
+ def resolve_command
118
+ configured = Config.default.picker
119
+
120
+ # yaml reads a bare `:picker: false` as the boolean, and that means
121
+ # "leave the picker off" - otherwise it looks like nothing was
122
+ # configured and quietly starts fzf instead. blank says the same thing
123
+ return nil if configured == false
124
+
125
+ argv = configured ? Shellwords.split(configured.to_s) : DEFAULT
126
+ (!argv.empty? && which(argv.first)) ? argv : nil
127
+ end
128
+
129
+ # one write, not one per bookmark. IO.popen hands back a synced IO, so
130
+ # `puts` per line is an unbuffered syscall each - measured 10x slower
131
+ # than a single write at a few thousand bookmarks
132
+ def feed(io, lines)
133
+ io.write(lines.join("\n") + "\n") unless lines.empty?
134
+ io.close_write
135
+ rescue Errno::EPIPE
136
+ # expected, not exceptional: the finder exits the instant escape is
137
+ # pressed, and with a few hundred bookmarks booker is usually still
138
+ # writing. the exit status above is what says what happened
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booker
4
+ VERSION = "2.1.0"
5
+ end