booker 2.0.0 → 2.2.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/lib/booker/cli.rb +14 -8
- data/lib/booker/config.rb +30 -6
- data/lib/booker/installer.rb +19 -4
- data/lib/booker/parsers.rb +32 -13
- data/lib/booker/picker.rb +5 -2
- data/lib/booker/version.rb +1 -1
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0f32b2a81f8c03acdc8c56ce6f9554a1229aced8a731d103fbfd53f1d03b5d3
|
|
4
|
+
data.tar.gz: 2d0386dcc31468311faaac5e5ec749d19623ad40f9f6b083a86b77cd9632be01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 963773985475ebf1a6e9c2fa340ceb79a607e4dd052e7c0dab1507881c0cd736f881388e7bd0445e8381dd69d561ead3bc14a9d940aed1cd08c07eb3770dc593
|
|
7
|
+
data.tar.gz: 279afd708175be5626eea44dfe3733ad435f4bb527dc4c9995bb97027376df4551b84c464f9d9ac69150cecddeb440b15b3d03f7eb8b1d706ef26589e8d03e4b
|
data/lib/booker/cli.rb
CHANGED
|
@@ -13,10 +13,10 @@ module Booker
|
|
|
13
13
|
|
|
14
14
|
# a bookmark id is "<source index>_<browser id>". safari uses a uuid where
|
|
15
15
|
# chrome and firefox number theirs, so the second half is anything id-shaped
|
|
16
|
-
# rather than digits
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
BOOKMARK_ID = /\A
|
|
16
|
+
# rather than digits. a bare number is a search: `booker 2024` is looking
|
|
17
|
+
# for a year. completion inserts urls now, but ids are still first class -
|
|
18
|
+
# --bookmark takes them, the picker returns them
|
|
19
|
+
BOOKMARK_ID = /\A\d+_[a-z0-9-]+\z/i
|
|
20
20
|
|
|
21
21
|
def initialize(args)
|
|
22
22
|
parse args
|
|
@@ -83,7 +83,9 @@ module Booker
|
|
|
83
83
|
case @mode
|
|
84
84
|
when :bookmark
|
|
85
85
|
pexit "Error: ".red + "booker --bookmark expects bookmark id" if args.empty?
|
|
86
|
-
|
|
86
|
+
# a bare number is an id from before ids carried their source, when
|
|
87
|
+
# there was only ever source 0
|
|
88
|
+
open_bookmark(args.map { |id| /\A\d+\z/.match?(id) ? "0_#{id}" : id })
|
|
87
89
|
when :install
|
|
88
90
|
installer.install(args.empty? ? %w[completion config bookmarks] : args)
|
|
89
91
|
when :search
|
|
@@ -98,7 +100,9 @@ module Booker
|
|
|
98
100
|
when :complete_raw
|
|
99
101
|
Bookmarks.new(args.join(" ")).autocomplete_raw
|
|
100
102
|
end
|
|
101
|
-
|
|
103
|
+
# ParseError rather than InvalidOption: `booker --list=x` is a
|
|
104
|
+
# NeedlessArgument, and every other subclass is just as much a usage error
|
|
105
|
+
rescue OptionParser::ParseError => e
|
|
102
106
|
pexit "Error: ".red + e.message
|
|
103
107
|
end
|
|
104
108
|
|
|
@@ -146,8 +150,10 @@ module Booker
|
|
|
146
150
|
def open_search(term)
|
|
147
151
|
puts "searching ".grn + term
|
|
148
152
|
search = Config.default.searcher
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
# encoded, not just spaces to "+": a literal + reads back as a space, &
|
|
154
|
+
# ends the query and # starts a fragment, so `c++` searched for "c"
|
|
155
|
+
require "uri"
|
|
156
|
+
openweb(search + URI.encode_www_form_component(term))
|
|
151
157
|
end
|
|
152
158
|
|
|
153
159
|
# a term matching bookmarks is offered as a choice; one matching nothing is
|
data/lib/booker/config.rb
CHANGED
|
@@ -57,6 +57,10 @@ module Booker
|
|
|
57
57
|
|
|
58
58
|
# configuration
|
|
59
59
|
class Config
|
|
60
|
+
# raised by #write rather than replacing a file that exists but would not
|
|
61
|
+
# parse: the defaults in memory are a fallback, not the user's settings
|
|
62
|
+
class Unreadable < StandardError; end
|
|
63
|
+
|
|
60
64
|
VALID = [:searcher, :bookmarks, :browser, :picker].freeze
|
|
61
65
|
HOME = ENV.fetch("HOME", "/usr/local/").freeze
|
|
62
66
|
YAMLCONF = (HOME + "/.booker.yml").freeze
|
|
@@ -71,7 +75,11 @@ module Booker
|
|
|
71
75
|
def reset! = @default = nil
|
|
72
76
|
end
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
# quiet_missing is for --install, which is about to write the very file the
|
|
79
|
+
# warning would tell the user to go and create
|
|
80
|
+
def initialize(quiet_missing: false)
|
|
81
|
+
@quiet_missing = quiet_missing
|
|
82
|
+
|
|
75
83
|
# the yaml file if there is one, and only work out the defaults when
|
|
76
84
|
# there is not: discovery walks every chrome profile directory to answer
|
|
77
85
|
# a question the config file has already answered
|
|
@@ -83,7 +91,7 @@ module Booker
|
|
|
83
91
|
|
|
84
92
|
# prune bad config keys
|
|
85
93
|
@config.each_key do |k|
|
|
86
|
-
if !VALID.include? k
|
|
94
|
+
if !VALID.include? k
|
|
87
95
|
warn "Failure:".red + " Bad key found in config file: #{k}"
|
|
88
96
|
exit 1
|
|
89
97
|
end
|
|
@@ -107,16 +115,27 @@ module Booker
|
|
|
107
115
|
|
|
108
116
|
def read(file)
|
|
109
117
|
# #write emits symbol keys, so they have to be permitted on the way back
|
|
110
|
-
YAML.safe_load_file(file, permitted_classes: [Symbol])
|
|
118
|
+
conf = YAML.safe_load_file(file, permitted_classes: [Symbol])
|
|
119
|
+
# an empty file is no config at all, which is fine to write over
|
|
120
|
+
return false if conf.nil?
|
|
121
|
+
raise Psych::Exception, "not a mapping" unless conf.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
# symbols throughout, whatever the file used: `searcher:` is how yaml is
|
|
124
|
+
# usually written, but the readers look up :searcher, so a config spelled
|
|
125
|
+
# the usual way passed validation and then read back as nil
|
|
126
|
+
conf.transform_keys { |k| k.to_s.to_sym }
|
|
111
127
|
rescue Errno::ENOENT
|
|
112
|
-
|
|
113
|
-
"
|
|
114
|
-
|
|
128
|
+
unless @quiet_missing
|
|
129
|
+
warn "Warning: ".yel +
|
|
130
|
+
"YAML configuration file couldn't be found. Using defaults."
|
|
131
|
+
warn "Suggest: ".grn + "booker --install config"
|
|
132
|
+
end
|
|
115
133
|
false
|
|
116
134
|
rescue Psych::Exception
|
|
117
135
|
# every psych failure means the same thing here - an unusable config to
|
|
118
136
|
# fall back from rather than crash on. rescuing SyntaxError alone let
|
|
119
137
|
# anchors through as an AliasesNotEnabled backtrace under psych 4
|
|
138
|
+
@unreadable = true
|
|
120
139
|
warn "Warning: ".red +
|
|
121
140
|
"YAML configuration file could not be read. Using defaults."
|
|
122
141
|
false
|
|
@@ -124,6 +143,11 @@ module Booker
|
|
|
124
143
|
|
|
125
144
|
# used for creating and updating the default configuration
|
|
126
145
|
def write(k = nil, v = nil)
|
|
146
|
+
if @unreadable
|
|
147
|
+
raise Unreadable, "#{YAMLCONF} could not be read - fix or remove it, " \
|
|
148
|
+
"it was left as is"
|
|
149
|
+
end
|
|
150
|
+
|
|
127
151
|
@config[k] = v unless k.nil? # update users yaml config file
|
|
128
152
|
File.write(YAMLCONF, @config.to_yaml)
|
|
129
153
|
end
|
data/lib/booker/installer.rb
CHANGED
|
@@ -242,6 +242,13 @@ module Booker
|
|
|
242
242
|
yield if block_given?
|
|
243
243
|
end
|
|
244
244
|
|
|
245
|
+
# every target that touches the config is about to write it, so the
|
|
246
|
+
# missing-file warning would only tell the user to do what they are doing.
|
|
247
|
+
# its own instance rather than Config.default: that memo is shared with the
|
|
248
|
+
# rest of the run, and whichever caller built it first would decide whether
|
|
249
|
+
# the warning shows for everyone
|
|
250
|
+
def config = @config ||= Config.new(quiet_missing: true)
|
|
251
|
+
|
|
245
252
|
def home
|
|
246
253
|
ENV["HOME"] || "/usr/local"
|
|
247
254
|
end
|
|
@@ -283,12 +290,18 @@ module Booker
|
|
|
283
290
|
|
|
284
291
|
input = gets
|
|
285
292
|
raise "No input provided" if input.nil?
|
|
286
|
-
|
|
293
|
+
|
|
294
|
+
# to_i alone read any typo as 0 - all sources - and a negative number
|
|
295
|
+
# as an index from the end of the list
|
|
296
|
+
selection = Integer(input.strip, 10, exception: false)
|
|
297
|
+
unless selection&.between?(0, bms.length)
|
|
298
|
+
raise "Invalid selection: #{input.strip.inspect}"
|
|
299
|
+
end
|
|
287
300
|
|
|
288
301
|
if selection == 0
|
|
289
302
|
# User selected "ALL" - save array of all paths
|
|
290
303
|
puts "Selected: ".yel + "All sources (#{bms.length} bookmark files)"
|
|
291
|
-
|
|
304
|
+
config.write(:bookmarks, bms)
|
|
292
305
|
puts "Success: ".grn + "config file updated to search all bookmark sources"
|
|
293
306
|
else
|
|
294
307
|
save_bookmarks(bms[selection - 1], "config file updated with your bookmarks")
|
|
@@ -302,7 +315,7 @@ module Booker
|
|
|
302
315
|
|
|
303
316
|
def save_bookmarks(selected, message)
|
|
304
317
|
puts "Selected: ".yel + selected
|
|
305
|
-
|
|
318
|
+
config.write(:bookmarks, selected)
|
|
306
319
|
puts "Success: ".grn + message
|
|
307
320
|
end
|
|
308
321
|
|
|
@@ -316,8 +329,10 @@ module Booker
|
|
|
316
329
|
end
|
|
317
330
|
|
|
318
331
|
def install_config
|
|
319
|
-
|
|
332
|
+
config.write
|
|
320
333
|
puts "Success: ".grn + "example config file written to ~/.booker"
|
|
334
|
+
rescue Config::Unreadable => e
|
|
335
|
+
pexit "Failure: ".red + e.message
|
|
321
336
|
rescue
|
|
322
337
|
pexit "Failure: ".red + "could not write example config file to ~/.booker"
|
|
323
338
|
end
|
data/lib/booker/parsers.rb
CHANGED
|
@@ -50,22 +50,30 @@ module Booker
|
|
|
50
50
|
class Chrome < Base
|
|
51
51
|
def parse
|
|
52
52
|
begin
|
|
53
|
-
|
|
54
|
-
@chrome_bookmarks = local_bookmarks["roots"]["bookmark_bar"]["children"]
|
|
53
|
+
roots = JSON.parse(File.read(@file_path))["roots"].select { |_, r| r.is_a?(Hash) }
|
|
55
54
|
rescue
|
|
56
55
|
warn "Warning: ".yel + "Bookmarks file not found or invalid."
|
|
57
56
|
warn "Suggest: ".grn + "booker --install bookmarks"
|
|
58
57
|
return
|
|
59
58
|
end
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
# every root, not just the bar: "other bookmarks" is where chrome files
|
|
61
|
+
# anything saved without picking a folder, and "mobile bookmarks" is
|
|
62
|
+
# what sync brings over from a phone. the bar keeps the bare "|" it
|
|
63
|
+
# always had; the others are folders like any other, named by chrome
|
|
64
|
+
top = Folder.new([], "|")
|
|
65
|
+
roots.each do |key, root|
|
|
66
|
+
if key == "bookmark_bar"
|
|
67
|
+
parse_recursive(Folder.new(root["children"], "|"))
|
|
68
|
+
else
|
|
69
|
+
parse_folder(top, root)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
62
72
|
end
|
|
63
73
|
|
|
64
74
|
private
|
|
65
75
|
|
|
66
|
-
def parse_recursive(root
|
|
67
|
-
root = Folder.new(@chrome_bookmarks, "|") if root.nil?
|
|
68
|
-
|
|
76
|
+
def parse_recursive(root)
|
|
69
77
|
root.json.each { |x| parse_link(root.title, x) }
|
|
70
78
|
root.json.each { |x| parse_folder(root, x) }
|
|
71
79
|
end
|
|
@@ -143,15 +151,19 @@ module Booker
|
|
|
143
151
|
# Recursive CTE query to build folder paths
|
|
144
152
|
query = <<-SQL
|
|
145
153
|
WITH RECURSIVE bookmark_tree(id, parent, title, url, path) AS (
|
|
146
|
-
-- Start with
|
|
154
|
+
-- Start with the children of every user-facing root: the menu is
|
|
155
|
+
-- where firefox saves a bookmark by default, so the toolbar alone
|
|
156
|
+
-- missed most of them. toolbar paths stay unprefixed, as before
|
|
147
157
|
SELECT b.id, b.parent, b.title, p.url,
|
|
148
|
-
CAST(
|
|
158
|
+
CAST(CASE r.guid
|
|
159
|
+
WHEN 'toolbar_____' THEN b.title
|
|
160
|
+
ELSE COALESCE(r.title, '') || '/' || b.title
|
|
161
|
+
END as TEXT) as path
|
|
149
162
|
FROM moz_bookmarks b
|
|
163
|
+
INNER JOIN moz_bookmarks r ON b.parent = r.id
|
|
150
164
|
LEFT JOIN moz_places p ON b.fk = p.id
|
|
151
|
-
WHERE
|
|
152
|
-
|
|
153
|
-
WHERE guid = 'toolbar_____'
|
|
154
|
-
)
|
|
165
|
+
WHERE r.guid IN
|
|
166
|
+
('toolbar_____', 'menu________', 'unfiled_____', 'mobile______')
|
|
155
167
|
|
|
156
168
|
UNION ALL
|
|
157
169
|
|
|
@@ -217,7 +229,14 @@ module Booker
|
|
|
217
229
|
# sniff the head rather than reading the whole file: on the binary path
|
|
218
230
|
# the contents are thrown away and plutil reads it again from disk, and
|
|
219
231
|
# a real Bookmarks.plist grows with the user's bookmarks
|
|
220
|
-
head =
|
|
232
|
+
head = begin
|
|
233
|
+
File.binread(path, 512).to_s
|
|
234
|
+
rescue Errno::EPERM, Errno::EACCES
|
|
235
|
+
# macOS privacy protection, when this process lacks Full Disk Access.
|
|
236
|
+
# the file is binary on a mac anyway, so leave it to plutil - which
|
|
237
|
+
# may have been granted access on its own (--install safari, [B])
|
|
238
|
+
""
|
|
239
|
+
end
|
|
221
240
|
|
|
222
241
|
# Already an XML plist (fixtures, non-macOS). Binary plists need plutil
|
|
223
242
|
# (macOS-only); the JSON converter rejects <data>/<date> fields, so xml1.
|
data/lib/booker/picker.rb
CHANGED
|
@@ -12,7 +12,10 @@ module Booker
|
|
|
12
12
|
module Picker
|
|
13
13
|
# --delimiter/--with-nth hide the id column: it is noise on screen, and
|
|
14
14
|
# hiding it also takes it out of the match, so typing "2_" does not pull up
|
|
15
|
-
# every bookmark from the second source.
|
|
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).
|
|
16
19
|
#
|
|
17
20
|
# deliberately no --multi: under it fzf reads tab as "mark this and move
|
|
18
21
|
# down" - the one key booker's own completion trained everybody to press -
|
|
@@ -25,7 +28,7 @@ module Booker
|
|
|
25
28
|
"fzf",
|
|
26
29
|
"--height=40%",
|
|
27
30
|
"--reverse",
|
|
28
|
-
"--
|
|
31
|
+
"--tiebreak=index",
|
|
29
32
|
"--delimiter=\t",
|
|
30
33
|
"--with-nth=2..",
|
|
31
34
|
"--prompt=bookmark> "
|
data/lib/booker/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: booker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeremy Warner
|
|
@@ -13,16 +13,22 @@ dependencies:
|
|
|
13
13
|
name: json
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '2.21'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4.0'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
|
-
- - "
|
|
26
|
+
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
28
|
version: '2.21'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4.0'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: sqlite3
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -105,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
105
111
|
- !ruby/object:Gem::Version
|
|
106
112
|
version: '0'
|
|
107
113
|
requirements: []
|
|
108
|
-
rubygems_version: 4.0.
|
|
114
|
+
rubygems_version: 4.0.23
|
|
109
115
|
specification_version: 4
|
|
110
116
|
summary: CLI bookmark manager for Chrome, Firefox, and Safari
|
|
111
117
|
test_files: []
|