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.
data/lib/bookmarks.rb DELETED
@@ -1,435 +0,0 @@
1
- # grab/parse bookmarks from json file on computer
2
- require "fileutils"
3
-
4
- # get int number of columns in half of screen
5
- def terminal_width
6
- guess = `tput cols`.to_i
7
- (guess == 0) ? 100 : guess
8
- end
9
- TERMWIDTH = terminal_width
10
-
11
- # compl. color codes space
12
- CODEWIDTH = 16
13
-
14
- # add some colors, windowing methods
15
- class String
16
- def window(width)
17
- if length >= width
18
- self[0..width - 1]
19
- else
20
- ljust(width)
21
- end
22
- end
23
-
24
- def colorize(color, mod)
25
- "\033[#{mod};#{color};49m#{self}\033[0;0m"
26
- end
27
-
28
- def reset
29
- colorize(0, 0)
30
- end
31
-
32
- def blu
33
- colorize(34, 0)
34
- end
35
-
36
- def cyan
37
- colorize(36, 0)
38
- end
39
-
40
- def yel
41
- colorize(33, 0)
42
- end
43
-
44
- def grn
45
- colorize(32, 0)
46
- end
47
-
48
- def red
49
- colorize(31, 0)
50
- end
51
- end
52
-
53
- # Abstract base class for bookmark parsers
54
- class BookmarkParser
55
- attr_reader :allurls
56
-
57
- def initialize(file_path, search_term)
58
- @file_path = file_path
59
- @searching = /#{search_term}/i
60
- @allurls = []
61
- end
62
-
63
- # Abstract method - must be implemented by subclasses
64
- def parse
65
- raise NotImplementedError, "Subclasses must implement parse method"
66
- end
67
-
68
- def results
69
- @allurls
70
- end
71
-
72
- protected
73
-
74
- def matches_search?(values)
75
- values.any? { |v| v && @searching.match(v.to_s) }
76
- end
77
- end
78
-
79
- # Chrome/Chromium bookmark parser (JSON format)
80
- class ChromeBookmarkParser < BookmarkParser
81
- def parse
82
- begin
83
- local_bookmarks = JSON.parse(File.read(@file_path))
84
- @chrome_bookmarks = local_bookmarks["roots"]["bookmark_bar"]["children"]
85
- rescue
86
- puts "Warning: ".yel + "Bookmarks file not found or invalid."
87
- puts "Suggest: ".grn + "booker --install bookmarks"
88
- @chrome_bookmarks = []
89
- return
90
- end
91
-
92
- parse_recursive
93
- end
94
-
95
- private
96
-
97
- def parse_recursive(root = nil)
98
- root = Folder.new(@chrome_bookmarks, "|") if root.nil?
99
-
100
- root.json.each { |x| parse_link(root.title, x) }
101
- root.json.each { |x| parse_folder(root, x) }
102
- end
103
-
104
- def parse_link(base, link)
105
- checking = [base, link["name"], link["url"], link["id"]]
106
- if matches_search?(checking)
107
- if link["type"] == "url"
108
- @allurls.push(Bookmark.new(base, link["name"], link["url"], link["id"]))
109
- end
110
- end
111
- end
112
-
113
- def parse_folder(base, link)
114
- if link["type"] == "folder"
115
- title = base.title + link["name"] + "/"
116
- subdir = Folder.new(link["children"], title)
117
- parse_recursive(subdir)
118
- end
119
- end
120
- end
121
-
122
- # Firefox bookmark parser (SQLite format)
123
- class FirefoxBookmarkParser < BookmarkParser
124
- def parse
125
- begin
126
- require "sqlite3"
127
- rescue LoadError
128
- puts "Error: ".red + "sqlite3 gem not installed"
129
- puts "Run: ".grn + "gem install sqlite3"
130
- return
131
- end
132
-
133
- db_path = @file_path
134
-
135
- # Copy database to temp file if Firefox is running (to avoid lock)
136
- if firefox_running?
137
- require "tempfile"
138
- temp = Tempfile.new(["places", ".sqlite"])
139
- temp.close
140
- FileUtils.cp(@file_path, temp.path)
141
- db_path = temp.path
142
- end
143
-
144
- begin
145
- db = SQLite3::Database.new(db_path)
146
- db.results_as_hash = true
147
-
148
- # Recursive CTE query to build folder paths
149
- query = <<-SQL
150
- WITH RECURSIVE bookmark_tree(id, parent, title, url, path) AS (
151
- -- Start with bookmarks toolbar root
152
- SELECT b.id, b.parent, b.title, p.url,
153
- CAST(b.title as TEXT) as path
154
- FROM moz_bookmarks b
155
- LEFT JOIN moz_places p ON b.fk = p.id
156
- WHERE b.parent = (
157
- SELECT id FROM moz_bookmarks
158
- WHERE guid = 'toolbar_____'
159
- )
160
-
161
- UNION ALL
162
-
163
- -- Recursively get children
164
- SELECT b.id, b.parent, b.title, p.url,
165
- CAST(bt.path || '/' || b.title as TEXT) as path
166
- FROM moz_bookmarks b
167
- INNER JOIN bookmark_tree bt ON b.parent = bt.id
168
- LEFT JOIN moz_places p ON b.fk = p.id
169
- )
170
- SELECT id, title, path, url
171
- FROM bookmark_tree
172
- WHERE url IS NOT NULL
173
- ORDER BY path, title
174
- SQL
175
-
176
- db.execute(query).each do |row|
177
- folder_path = row["path"].to_s.split("/")[0..-2].join("/") + "/"
178
- folder_path = "|" + folder_path.gsub(/[:,'"]/, "-").downcase
179
-
180
- values = [folder_path, row["title"], row["url"], row["id"].to_s]
181
- if matches_search?(values)
182
- @allurls << Bookmark.new(
183
- folder_path,
184
- row["title"] || "",
185
- row["url"] || "",
186
- row["id"].to_s
187
- )
188
- end
189
- end
190
- rescue SQLite3::Exception => e
191
- puts "Error: ".red + "Could not read Firefox bookmarks database"
192
- puts e.message
193
- ensure
194
- db&.close
195
- temp&.unlink
196
- end
197
- end
198
-
199
- private
200
-
201
- def firefox_running?
202
- # Simple check - try to get a shared lock
203
- # If Firefox is running, it has an exclusive lock
204
- return false unless File.exist?(@file_path)
205
-
206
- begin
207
- File.open(@file_path, "r") do |f|
208
- # If we can open for reading, Firefox might still be running
209
- # but we'll handle it by copying to temp
210
- end
211
- # Check for Firefox process
212
- `pgrep -x firefox 2>/dev/null`.strip != ""
213
- rescue
214
- false
215
- end
216
- end
217
- end
218
-
219
- # Safari bookmark parser (binary plist format)
220
- class SafariBookmarkParser < BookmarkParser
221
- def parse
222
- xml = convert_plist_to_xml(@file_path)
223
- return if xml.nil?
224
-
225
- begin
226
- require "rexml/document"
227
- doc = REXML::Document.new(xml)
228
- plist_el = doc.root
229
- root_node = plist_el.elements.to_a.first
230
- root = parse_node(root_node)
231
- rescue => e
232
- puts "Warning: ".yel + "Could not parse Safari bookmarks: #{e.message}"
233
- return
234
- end
235
-
236
- walk(root, "|")
237
- end
238
-
239
- private
240
-
241
- def convert_plist_to_xml(path)
242
- contents = File.read(path)
243
- # Already an XML plist (fixtures, non-macOS). Binary plists need plutil
244
- # (macOS-only); the JSON converter rejects <data>/<date> fields, so xml1.
245
- return contents if contents.start_with?("<?xml") || contents.include?("<plist")
246
-
247
- output = IO.popen(["plutil", "-convert", "xml1", "-o", "-", path], err: File::NULL, &:read)
248
- return output if $?.success?
249
-
250
- puts "Warning: ".yel + "Could not read Safari bookmarks file."
251
- puts "Suggest: ".grn + "grant terminal 'Full Disk Access' in System Settings"
252
- nil
253
- rescue Errno::ENOENT
254
- puts "Warning: ".yel + "Safari bookmarks file not found."
255
- nil
256
- end
257
-
258
- def parse_node(el)
259
- case el.name
260
- when "dict"
261
- result = {}
262
- children = el.elements.to_a
263
- children.each_slice(2) do |key_el, val_el|
264
- next if key_el.nil? || val_el.nil?
265
- result[key_el.text.to_s] = parse_node(val_el)
266
- end
267
- result
268
- when "array"
269
- el.elements.map { |c| parse_node(c) }
270
- when "string"
271
- el.text.to_s
272
- when "integer"
273
- el.text.to_i
274
- when "real"
275
- el.text.to_f
276
- when "true"
277
- true
278
- when "false"
279
- false
280
- when "data", "date"
281
- el.text.to_s
282
- end
283
- end
284
-
285
- def walk(node, folder_title)
286
- children = node["Children"]
287
- return unless children.is_a?(Array)
288
-
289
- children.each do |child|
290
- case child["WebBookmarkType"]
291
- when "WebBookmarkTypeLeaf"
292
- parse_leaf(folder_title, child)
293
- when "WebBookmarkTypeList"
294
- name = child["Title"].to_s
295
- sub_title = folder_title + name.gsub(/[:,'"]/, "-").downcase + "/"
296
- walk(child, sub_title)
297
- end
298
- end
299
- end
300
-
301
- def parse_leaf(folder, leaf)
302
- uri = leaf["URIDictionary"] || {}
303
- title = (uri["title"] || "").to_s
304
- url = (leaf["URLString"] || "").to_s
305
- id = (leaf["WebBookmarkUUID"] || "").to_s
306
-
307
- values = [folder, title, url, id]
308
- if matches_search?(values)
309
- @allurls << Bookmark.new(folder, title, url, id)
310
- end
311
- end
312
- end
313
-
314
- # Main Bookmarks facade class
315
- class Bookmarks
316
- PARSER_SOURCE = {
317
- ChromeBookmarkParser => "chrome",
318
- FirefoxBookmarkParser => "firefox",
319
- SafariBookmarkParser => "safari"
320
- }
321
-
322
- def initialize(search_term = "")
323
- @conf = BConfig.new
324
- file_paths = @conf.bookmarks
325
- @allurls = []
326
- seen = {}
327
-
328
- loaded_sources = file_paths.count { |p| p && File.exist?(p) }
329
- @multi_source = loaded_sources > 1
330
-
331
- file_paths.each_with_index do |file_path, source_index|
332
- next unless file_path && File.exist?(file_path)
333
-
334
- parser_class = detect_parser(file_path)
335
- source = PARSER_SOURCE[parser_class]
336
- parser = parser_class.new(file_path, search_term)
337
- parser.parse
338
-
339
- parser.results.each do |bookmark|
340
- key = [bookmark.title, bookmark.url]
341
- next if seen[key]
342
- seen[key] = true
343
-
344
- @allurls << Bookmark.new(
345
- bookmark.folder,
346
- bookmark.title,
347
- bookmark.url,
348
- "#{source_index}_#{bookmark.id}",
349
- source
350
- )
351
- end
352
- end
353
- end
354
-
355
- # output for zsh autocompetion, print out id, title and cleaned url
356
- def autocomplete
357
- @allurls.each do |url|
358
- name = clean_name(url)
359
- link = clean_link(url)
360
- puts url.id + ":" + name + ":" + link
361
- end
362
- end
363
-
364
- # clean title for completion, delete anything not allowed in linktitle
365
- def clean_name(url)
366
- # Clean up folder display (remove leading |, show [root] for top-level)
367
- folder = url.folder.gsub(/^\|/, "")
368
- folder = (folder == "/") ? "[root]" : folder.chomp("/")
369
-
370
- # Format: [source] folder | title (source only when multi-source)
371
- prefix = (@multi_source && url.source) ? "[#{url.source}] " : ""
372
- name = prefix + folder + " | " + url.title.gsub(/[^a-z0-9\-\/_ ]/i, "")
373
- name.squeeze!("-")
374
- name.squeeze!(" ")
375
- # Use half terminal width for name to leave room for URL
376
- name.window([TERMWIDTH / 2, 50].min)
377
- end
378
-
379
- # clean link for completion, remove strange things from any linkurls
380
- def clean_link(url)
381
- link = url.url.gsub(/[,'"&?].*/, "")
382
- link.gsub!(/.*:\/+/, "")
383
- link.delete!(" ")
384
- # Use half terminal width for URL
385
- max_width = [TERMWIDTH / 2 - CODEWIDTH, 50].min
386
- link[0..max_width]
387
- end
388
-
389
- # get link (from id number)
390
- def bookmark_url(id)
391
- @allurls.each do |url|
392
- return url.url if id == url.id
393
- end
394
- end
395
-
396
- private
397
-
398
- def detect_parser(file_path)
399
- if file_path&.end_with?(".sqlite")
400
- FirefoxBookmarkParser
401
- elsif file_path&.end_with?(".plist")
402
- SafariBookmarkParser
403
- else
404
- ChromeBookmarkParser
405
- end
406
- end
407
- end # close bookmarks class
408
-
409
- # for recursively parsing bookmarks
410
- class Folder
411
- include Enumerable
412
-
413
- attr_reader :json, :title
414
- def initialize(json, title = "|")
415
- @title = title.gsub(/[:,'"]/, "-").downcase
416
- @json = json
417
- end
418
-
419
- # needed for Enumerable
420
- def each
421
- @json.each
422
- end
423
- end
424
-
425
- # clean bookmark title, set attrs
426
- class Bookmark
427
- attr_reader :title, :folder, :url, :id, :source
428
- def initialize(f, t, u, id, source = nil)
429
- @title = t.gsub(/[:'"+]/, " ").downcase
430
- @folder = f
431
- @url = u
432
- @id = id
433
- @source = source
434
- end
435
- end
data/lib/config.rb DELETED
@@ -1,190 +0,0 @@
1
- # configuation - get where bookmarks are
2
-
3
- # detect operating system
4
- module OS
5
- def self.windows?
6
- (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
7
- end
8
-
9
- def self.mac?
10
- (/darwin/ =~ RUBY_PLATFORM) != nil
11
- end
12
-
13
- def self.linux?
14
- !(OS.windows? or OS.mac?)
15
- end
16
- end
17
-
18
- # return browser (chrome) opening command
19
- module Browser
20
- extend OS
21
-
22
- def browse
23
- if OS.windows?
24
- # alternatively, start seems to work - probably check if powershell v cygwin?
25
- '/cygdrive/c/Program\ Files\ \(x86\)/Google/Chrome/Application/chrome.exe '
26
- elsif OS.mac?
27
- "open "
28
- elsif OS.linux?
29
- "xdg-open "
30
- end
31
- end
32
-
33
- def domain
34
- /.*(io|com|web|net|org|gov|edu|xyz)(\/.*)?$/i
35
- end
36
-
37
- # helper methods
38
- def prep(url)
39
- if /^http/.match?(url)
40
- url
41
- else
42
- "http://" + url
43
- end
44
- end
45
-
46
- def wrap(url)
47
- "\"#{url}\""
48
- end
49
- end
50
-
51
- # configuration
52
- class BConfig
53
- VALID = [:searcher, :bookmarks, :browser]
54
- HOME = ENV["HOME"].nil? ? "/usr/local/" : ENV["HOME"]
55
- YAMLCONF = HOME + "/.booker.yml"
56
-
57
- def initialize
58
- # config defaults (for osx, default chrome profile)
59
- readyaml = read(YAMLCONF)
60
- default_bookmarks = detect_default_bookmarks
61
- default_config = {
62
- browser: "open ",
63
- searcher: "https://google.com/search?q=",
64
- bookmarks: default_bookmarks
65
- }
66
-
67
- # configure w/ yaml config file, if it exists
68
- @config = readyaml || default_config
69
-
70
- # prune bad config keys
71
- @config.each do |k, v|
72
- if !VALID.include? k.to_sym
73
- puts "Failure:".red + " Bad key found in config file: #{k}"
74
- exit 1
75
- end
76
- end
77
- end
78
-
79
- def detect_default_bookmarks
80
- # Return ALL available bookmark sources (Chrome, Firefox, Safari)
81
- all_sources = discover_all_bookmark_sources
82
-
83
- # If we found sources, return them all; otherwise return a default single path
84
- if all_sources.empty?
85
- # Fallback to macOS Chrome default for backward compatibility
86
- HOME + "/Library/Application Support/Google/Chrome/Profile 1/Bookmarks"
87
- else
88
- all_sources
89
- end
90
- end
91
-
92
- def discover_all_bookmark_sources
93
- sources = []
94
-
95
- # Discover all Chrome/Chromium bookmarks
96
- chrome_base_paths = [
97
- HOME + "/Library/Application Support/Google/Chrome", # macOS
98
- HOME + "/Library/Application Support/Chromium", # macOS Chromium
99
- HOME + "/.config/google-chrome", # Linux
100
- HOME + "/.config/chromium", # Linux Chromium
101
- HOME + "/snap/chromium/common/chromium", # Linux (snap)
102
- HOME + "/snap/chromium/current/.config/chromium", # Linux (snap alt)
103
- HOME + "/AppData/Local/Google/Chrome/User Data" # Windows
104
- ]
105
-
106
- chrome_base_paths.each do |base|
107
- next unless Dir.exist?(base)
108
-
109
- # Find all profile directories and their Bookmarks files
110
- begin
111
- Dir.glob(File.join(base, "**/Bookmarks")).each do |bookmark_file|
112
- sources << bookmark_file if File.file?(bookmark_file)
113
- end
114
- rescue
115
- # Skip if we can't read this directory
116
- end
117
- end
118
-
119
- # Discover all Firefox profiles
120
- firefox_base_paths = [
121
- HOME + "/.mozilla/firefox", # Linux
122
- HOME + "/snap/firefox/common/.mozilla/firefox", # Linux (snap)
123
- HOME + "/Library/Application Support/Firefox", # macOS
124
- HOME + "/AppData/Roaming/Mozilla/Firefox" # Windows
125
- ]
126
-
127
- firefox_base_paths.each do |firefox_base|
128
- next unless Dir.exist?(firefox_base)
129
-
130
- profiles_ini = File.join(firefox_base, "profiles.ini")
131
- next unless File.exist?(profiles_ini)
132
-
133
- # Parse all Firefox profiles
134
- File.readlines(profiles_ini).each do |line|
135
- if line.include?("Path=")
136
- profile_path = line.split("=", 2)[1].strip
137
- db_path = File.join(firefox_base, profile_path, "places.sqlite")
138
- sources << db_path if File.exist?(db_path)
139
- end
140
- end
141
- end
142
-
143
- safari_path = HOME + "/Library/Safari/Bookmarks.plist"
144
- sources << safari_path if File.exist?(safari_path)
145
-
146
- sources.uniq
147
- end
148
-
149
- def read(file)
150
- begin
151
- config = YAML.load(IO.read(file))
152
- rescue Errno::ENOENT
153
- warn "Warning: ".yel +
154
- "YAML configuration file couldn't be found. Using defaults."
155
- warn "Suggest: ".grn + "booker --install config"
156
- return false
157
- rescue Psych::SyntaxError
158
- warn "Warning: ".red +
159
- "YAML configuration file contains invalid syntax. Using defaults."
160
- return false
161
- end
162
- config
163
- end
164
-
165
- # used for creating and updating the default configuration
166
- def write(k = nil, v = nil)
167
- if k.nil? && v.nil?
168
- else
169
- @config[k] = v # update users yaml config file
170
- end
171
- File.write(YAMLCONF, @config.to_yaml)
172
- end
173
-
174
- def bookmarks
175
- # Return array of bookmark sources
176
- # Handles both old single-path configs and new multi-source configs
177
- result = @config[:bookmarks]
178
-
179
- if result.is_a?(Array)
180
- result
181
- else
182
- # Single path - wrap in array for consistent interface
183
- [result]
184
- end
185
- end
186
-
187
- def searcher
188
- @config[:searcher]
189
- end
190
- end
data/lib/consts.rb DELETED
@@ -1,99 +0,0 @@
1
- # file for large constant strings
2
-
3
- # todo - wrap in module
4
-
5
- DEF_CONFIG = <<~EOS
6
- ---
7
- :searcher: https://google.com/search?q=
8
- :bookmarks: null
9
- EOS
10
-
11
- COMPLETION = <<~EOS
12
- #compdef booker
13
- #autoload
14
-
15
-
16
- _booker_bookmarks() {
17
- #http://zsh.sourceforge.net/Guide/zshguide06.html#l147
18
- setopt glob bareglobqual nullglob rcexpandparam extendedglob automenu
19
- unsetopt allexport aliases errexit octalzeroes ksharrays cshnullglob
20
-
21
- #get bookmarks
22
- local -a bookmarks sites search_terms
23
- search_terms=()
24
-
25
- #filter out 'booker' and already-selected bookmark IDs (numeric with underscores)
26
- for arg in "$@"; do
27
- if [[ "$arg" != "booker" && ! "$arg" =~ ^[0-9_]+$ ]]; then
28
- search_terms+=("$arg")
29
- fi
30
- done
31
-
32
- #join search terms and get matching bookmarks
33
- local search="${search_terms[*]}"
34
- sites=`booker --complete $search`
35
- bookmarks=("${(f)${sites}}")
36
-
37
- #terminal colors
38
- BLUE=`echo -e "\033[4;34m"`
39
- RESET=`echo -e "\033[0;0m"`
40
-
41
- #parse parts, color
42
- local -a ids links
43
- for bookmark in ${bookmarks[@]}; do
44
- local -a split
45
- split=("${(@s/:/)bookmark}")
46
- ids+=( $split[1] )
47
- links+=("$split[2] $BLUE$split[3]$RESET")
48
- done
49
-
50
- #finally, add completions
51
- compstate[insert]=menu
52
- compadd -U -l -X 'found bookmarks:' -d links -a ids
53
- }
54
-
55
-
56
- _booker() {
57
- compstate[insert]=menu
58
- local curcontext state line
59
- typeset -A opt_args
60
-
61
- _arguments -C \
62
- '(-b)-b[do bookmark completion]'\
63
- '(--bookmark)--bookmark[do bookmark completion]'\
64
- '(-c)-c[show bookmark completions]'\
65
- '(--complete)--complete[show bookmark completions]'\
66
- '(-i)-i[perform installations (all, bookmarks, completion, config, safari)]'\
67
- '(--install)--install[perform installations (all, bookmarks, completion, config, safari)]'\
68
- '(-s)-s[search google for...]'\
69
- '(--search)--search[search google for...]'\
70
- '(-h)-h[show booker help]'\
71
- '(--help)--help[show booker help]'\
72
- '(-v)-v[show version]'\
73
- '(--version)--version[show version]'\
74
- '*::bookmarks:->bookmarks'
75
-
76
- # Handle the bookmarks state for repeated completions
77
- case $state in
78
- bookmarks)
79
- _booker_bookmarks $words
80
- ;;
81
- esac
82
- }
83
-
84
-
85
- _booker
86
- EOS
87
-
88
- ZSH_SCRIPT = <<~EOS
89
- # load zsh booker completions
90
- install_loc=~/.oh-my-zsh/completions
91
- script=_booker
92
-
93
- install:
94
- mkdir -vp $(install_loc)
95
- cp -v $(script) $(install_loc)/
96
-
97
- uninstall:
98
- rm -v $(install_loc)/$(script)
99
- EOS