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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booker
4
+ VERSION = "2.0.0"
5
+ end