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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51a33aad3cbed45ed09f6fa35f2046223dca0d5dd82fe15be7c49cb8aaa4c41d
4
- data.tar.gz: 3054f0c7657e98c6c794b768ca9450992d3ee83b91526fc9cb88c5de6a4bc5ad
3
+ metadata.gz: a0f32b2a81f8c03acdc8c56ce6f9554a1229aced8a731d103fbfd53f1d03b5d3
4
+ data.tar.gz: 2d0386dcc31468311faaac5e5ec749d19623ad40f9f6b083a86b77cd9632be01
5
5
  SHA512:
6
- metadata.gz: e6ced499e2b20d75987fc8c538b95bfb5a9912549b49848c0b6e827bc3c5f8dd3aed72c17c585b5080d16c60a48832d57f6a829d5ebf465f9068969418dc4898
7
- data.tar.gz: fb1a648048fbe579ae1cdaee106da02fc1dc345874119fa0ef846dbc1d0631e63dbec150c5509b66a325ecce991969acf369ce5b096b9571d4c97269294bb66a
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; bare digits stay valid for single-source configs
17
- # written before the prefix existed. completion inserts urls now, but ids
18
- # are still first class - --bookmark takes them, the picker returns them
19
- BOOKMARK_ID = /\A(?:\d+_[a-z0-9-]+|[0-9_]+)\z/i
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
- open_bookmark(args)
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
- rescue OptionParser::InvalidOption => e
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
- term = term.tr(" ", "+")
150
- openweb(search + term) # No shell escape needed - it's a URL
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
- def initialize
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.to_sym
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
- warn "Warning: ".yel +
113
- "YAML configuration file couldn't be found. Using defaults."
114
- warn "Suggest: ".grn + "booker --install config"
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
@@ -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
- selection = input.chomp.to_i
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
- Config.default.write(:bookmarks, bms)
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
- Config.default.write(:bookmarks, selected)
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
- Config.default.write
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
@@ -50,22 +50,30 @@ module Booker
50
50
  class Chrome < Base
51
51
  def parse
52
52
  begin
53
- local_bookmarks = JSON.parse(File.read(@file_path))
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
- parse_recursive
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 = nil)
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 bookmarks toolbar root
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(b.title as TEXT) as path
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 b.parent = (
152
- SELECT id FROM moz_bookmarks
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 = File.binread(path, 512).to_s
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. --no-sort keeps booker's ordering.
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
- "--no-sort",
31
+ "--tiebreak=index",
29
32
  "--delimiter=\t",
30
33
  "--with-nth=2..",
31
34
  "--prompt=bookmark> "
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Booker
4
- VERSION = "2.0.0"
4
+ VERSION = "2.2.0"
5
5
  end
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.0.0
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.18
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: []