usefuldb 0.0.12 → 0.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.
@@ -0,0 +1,563 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "usefuldb/json_encoder"
5
+ require "usefuldb/utilities"
6
+
7
+ module UsefulDB
8
+ class CLI
9
+ COMMANDS = %w[search list add remove rm show count export import help].freeze
10
+ ImportExport = UsefulDB::ImportExport
11
+
12
+ def self.run(argv, log:)
13
+ global, remaining = parse_global_options(argv)
14
+ configure_logger(log, global)
15
+
16
+ if remaining.empty?
17
+ print_help
18
+ return 0
19
+ end
20
+
21
+ if command?(remaining.first)
22
+ command = remaining.shift
23
+
24
+ case command
25
+ when "help"
26
+ print_help(remaining.first)
27
+ when "search"
28
+ run_search(remaining, global, log)
29
+ when "list"
30
+ run_list(remaining, global, log)
31
+ when "add"
32
+ run_add(remaining, global, log)
33
+ when "remove", "rm"
34
+ run_remove(remaining, global, log)
35
+ when "show"
36
+ run_show(remaining, global, log)
37
+ when "count"
38
+ run_count(global, log)
39
+ when "export"
40
+ run_export(remaining, global, log)
41
+ when "import"
42
+ run_import(remaining, global, log)
43
+ end
44
+ else
45
+ unknown = remaining.first
46
+ raise OptionParser::ParseError, "Unknown command: #{unknown}. Use `usefuldb search` to find entries."
47
+ end
48
+
49
+ 0
50
+ rescue EntryInDB, EmptyDB, KeyOutOfBounds, EntryNotFound, ImportError => e
51
+ warn e.message unless global[:quiet]
52
+ 1
53
+ rescue OptionParser::ParseError => e
54
+ warn e.message unless global[:quiet]
55
+ warn "Run `usefuldb help` for usage." unless global[:quiet]
56
+ 1
57
+ end
58
+
59
+ def self.command?(name)
60
+ COMMANDS.include?(name)
61
+ end
62
+
63
+ def self.parse_global_options(argv)
64
+ global = {
65
+ db: nil,
66
+ quiet: false,
67
+ verbose: false
68
+ }
69
+ remaining = argv.dup
70
+
71
+ while (arg = remaining.first)
72
+ case arg
73
+ when "--db"
74
+ remaining.shift
75
+ global[:db] = remaining.shift or raise OptionParser::ParseError, "missing argument: --db"
76
+ when "-q", "--quiet"
77
+ remaining.shift
78
+ global[:quiet] = true
79
+ when "-v", "--verbose"
80
+ remaining.shift
81
+ global[:verbose] = true
82
+ when "--version"
83
+ remaining.shift
84
+ puts UsefulDB::Version.to_s
85
+ exit 0
86
+ when "-h", "--help"
87
+ remaining.shift
88
+ print_help
89
+ exit 0
90
+ when "--"
91
+ remaining.shift
92
+ break
93
+ else
94
+ break
95
+ end
96
+ end
97
+
98
+ [global, remaining]
99
+ end
100
+
101
+ def self.configure_logger(log, global)
102
+ if global[:quiet]
103
+ log.level = Logger::ERROR
104
+ elsif global[:verbose]
105
+ log.level = Logger::DEBUG
106
+ else
107
+ log.level = Logger::ERROR
108
+ end
109
+ end
110
+
111
+ def self.partition_argv(argv, value_flags: [])
112
+ options_argv = []
113
+ positional = []
114
+ index = 0
115
+
116
+ while index < argv.length
117
+ token = argv[index]
118
+
119
+ if token == "--"
120
+ positional.concat(argv[(index + 1)..])
121
+ break
122
+ elsif token == "-"
123
+ positional << token
124
+ index += 1
125
+ elsif token.start_with?("-")
126
+ options_argv << token
127
+ index += 1
128
+
129
+ if value_flags.include?(token) && index < argv.length && !argv[index].start_with?("-")
130
+ options_argv << argv[index]
131
+ index += 1
132
+ end
133
+ else
134
+ positional << token
135
+ index += 1
136
+ end
137
+ end
138
+
139
+ [options_argv, positional]
140
+ end
141
+
142
+ def self.load_options(global)
143
+ options = {}
144
+ options[:db] = global[:db] if global[:db]
145
+ options
146
+ end
147
+
148
+ def self.load_db!(log, global)
149
+ UsefulDB.setup(log) unless global[:db]
150
+ UsefulDB.dbLoad(log, load_options(global))
151
+ end
152
+
153
+ def self.run_search(argv, global, log)
154
+ options = {
155
+ match: :all,
156
+ format: :human,
157
+ ids: false
158
+ }
159
+
160
+ parser = OptionParser.new do |opts|
161
+ opts.banner = "Usage: usefuldb search [options] [tags...]"
162
+ opts.on("--any", "Match entries with any tag (OR)") { options[:match] = :any }
163
+ opts.on("--json", "Print results as JSON") { options[:format] = :json }
164
+ opts.on("--value-only", "Print only entry values") { options[:format] = :value_only }
165
+ opts.on("--ids", "Include entry ids in human output") { options[:ids] = true }
166
+ opts.on("-h", "--help", "Show this help") do
167
+ puts opts
168
+ exit 0
169
+ end
170
+ end
171
+
172
+ option_argv, tags = partition_argv(argv)
173
+ parser.parse!(option_argv)
174
+
175
+ load_db!(log, global)
176
+ results = UsefulDB::Utils.search_entries(tags, match: options[:match])
177
+ print_entries(results, format: options[:format], ids: options[:ids])
178
+ end
179
+
180
+ def self.run_list(argv, global, log)
181
+ options = {
182
+ format: :human,
183
+ tags_only: false
184
+ }
185
+
186
+ parser = OptionParser.new do |opts|
187
+ opts.banner = "Usage: usefuldb list [options]"
188
+ opts.on("--json", "Print results as JSON") { options[:format] = :json }
189
+ opts.on("--tags-only", "Print unique tags") { options[:tags_only] = true }
190
+ opts.on("-h", "--help", "Show this help") do
191
+ puts opts
192
+ exit 0
193
+ end
194
+ end
195
+
196
+ parser.order!(argv)
197
+
198
+ load_db!(log, global)
199
+
200
+ if options[:tags_only]
201
+ tags = UsefulDB::Utils.all_tags
202
+ if options[:format] == :json
203
+ puts UsefulDB::JSONEncoder.generate(tags)
204
+ else
205
+ tags.each { |tag| puts tag }
206
+ end
207
+ return
208
+ end
209
+
210
+ print_entries(UsefulDB::Utils.entries, format: options[:format], ids: true)
211
+ end
212
+
213
+ def self.run_add(argv, global, log)
214
+ options = {
215
+ tags: nil,
216
+ value: nil,
217
+ description: nil
218
+ }
219
+
220
+ parser = OptionParser.new do |opts|
221
+ opts.banner = "Usage: usefuldb add [options]"
222
+ opts.on("--tags TAGS", "Comma-separated search tags") { |value| options[:tags] = value }
223
+ opts.on("--value VALUE", "Stored command, URL, or text") { |value| options[:value] = value }
224
+ opts.on("--description TEXT", "Entry description") { |value| options[:description] = value }
225
+ opts.on("-h", "--help", "Show this help") do
226
+ puts opts
227
+ exit 0
228
+ end
229
+ end
230
+
231
+ parser.order!(argv)
232
+
233
+ load_db!(log, global)
234
+
235
+ tags = options[:tags]
236
+ value = options[:value]
237
+ description = options[:description]
238
+
239
+ if tags.nil?
240
+ $stdout.print "Tags (comma-separated): "
241
+ tags = $stdin.gets.to_s.strip
242
+ end
243
+
244
+ if value.nil?
245
+ $stdout.print "Value: "
246
+ value = $stdin.gets.to_s.strip
247
+ end
248
+
249
+ if description.nil? && $stdin.tty?
250
+ $stdout.print "Description (optional): "
251
+ description = $stdin.gets.to_s.strip
252
+ end
253
+
254
+ normalized_tags = UsefulDB::Utils.normalize_tags(tags)
255
+ raise OptionParser::ParseError, "At least one tag is required" if normalized_tags.empty?
256
+ raise OptionParser::ParseError, "Value is required" if value.to_s.strip.empty?
257
+
258
+ entry = {
259
+ "tag" => normalized_tags,
260
+ "value" => value.strip,
261
+ "description" => description.to_s
262
+ }
263
+
264
+ UsefulDB.add(entry, log)
265
+ UsefulDB.dbSave(log, load_options(global))
266
+
267
+ new_id = UsefulDB::Utils.count(log) - 1
268
+ puts "Added entry [#{new_id}]." unless global[:quiet]
269
+ end
270
+
271
+ def self.run_remove(argv, global, log)
272
+ options = {
273
+ tags: nil,
274
+ value: nil
275
+ }
276
+ id = nil
277
+
278
+ parser = OptionParser.new do |opts|
279
+ opts.banner = "Usage: usefuldb remove <id> [options]"
280
+ opts.on("--tags TAGS", "Match entry tags when removing by value") { |value| options[:tags] = value }
281
+ opts.on("--value VALUE", "Match entry value when removing by attributes") { |value| options[:value] = value }
282
+ opts.on("-h", "--help", "Show this help") do
283
+ puts opts
284
+ exit 0
285
+ end
286
+ end
287
+
288
+ option_argv, positional = partition_argv(argv, value_flags: ["--tags", "--value"])
289
+ parser.parse!(option_argv)
290
+ id = positional.first.to_i if positional.first&.match?(/\A\d+\z/)
291
+
292
+ load_db!(log, global)
293
+
294
+ if id.nil?
295
+ raise OptionParser::ParseError, "Entry id is required" if options[:value].nil?
296
+
297
+ normalized_tags = UsefulDB::Utils.normalize_tags(options[:tags] || [])
298
+ id = UsefulDB::Utils.find_entry_id(tags: normalized_tags, value: options[:value])
299
+ raise EntryNotFound, "No entry matched the given tags and value" if id.nil?
300
+ end
301
+
302
+ removed = UsefulDB::Utils.get_entry(id)
303
+ UsefulDB.remove(id, log)
304
+ UsefulDB.dbSave(log, load_options(global))
305
+
306
+ puts "Removed entry [#{id}]: #{removed['value']}" unless global[:quiet]
307
+ end
308
+
309
+ def self.run_show(argv, global, log)
310
+ json = false
311
+
312
+ parser = OptionParser.new do |opts|
313
+ opts.banner = "Usage: usefuldb show <id> [options]"
314
+ opts.on("--json", "Print entry as JSON") { json = true }
315
+ opts.on("-h", "--help", "Show this help") do
316
+ puts opts
317
+ exit 0
318
+ end
319
+ end
320
+
321
+ option_argv, positional = partition_argv(argv)
322
+ parser.parse!(option_argv)
323
+
324
+ id = positional.first
325
+ raise OptionParser::ParseError, "Entry id is required" if id.nil?
326
+
327
+ load_db!(log, global)
328
+ entry = UsefulDB::Utils.get_entry(id.to_i)
329
+
330
+ if json
331
+ puts UsefulDB::JSONEncoder.generate(entry)
332
+ else
333
+ print_entries([entry], format: :human, ids: true)
334
+ end
335
+ end
336
+
337
+ def self.run_count(global, log)
338
+ load_db!(log, global)
339
+ puts UsefulDB::Utils.count(log)
340
+ end
341
+
342
+ def self.run_export(argv, global, log)
343
+ options = {
344
+ output: nil,
345
+ format: nil
346
+ }
347
+
348
+ parser = OptionParser.new do |opts|
349
+ opts.banner = "Usage: usefuldb export [options] [file]"
350
+ opts.on("-o", "--output FILE", "Write export to FILE (- for stdout)") { |value| options[:output] = value }
351
+ opts.on("--format FORMAT", ImportExport::FORMATS.map(&:to_s), "Export format (yaml or json)") do |value|
352
+ options[:format] = value.to_sym
353
+ end
354
+ opts.on("-h", "--help", "Show this help") do
355
+ puts opts
356
+ exit 0
357
+ end
358
+ end
359
+
360
+ option_argv, positional = partition_argv(argv, value_flags: ["-o", "--output", "--format"])
361
+ parser.parse!(option_argv)
362
+
363
+ output = options[:output] || positional.first
364
+ raise OptionParser::ParseError, "Output file is required (use - for stdout)" if output.nil?
365
+
366
+ format = options[:format]
367
+ format ||= UsefulDB::ImportExport.detect_format(output) if output != "-"
368
+ format ||= :yaml
369
+
370
+ load_db!(log, global)
371
+ content = UsefulDB::ImportExport.export_content(UsefulDB::Utils.export_data, format: format)
372
+ UsefulDB::ImportExport.write_export(output, content)
373
+
374
+ unless global[:quiet] || output == "-"
375
+ puts "Exported #{UsefulDB::Utils.count(log)} entries to #{output}."
376
+ end
377
+ end
378
+
379
+ def self.run_import(argv, global, log)
380
+ options = {
381
+ input: nil,
382
+ format: nil,
383
+ mode: :merge
384
+ }
385
+
386
+ parser = OptionParser.new do |opts|
387
+ opts.banner = "Usage: usefuldb import [options] [file]"
388
+ opts.on("-i", "--input FILE", "Read import from FILE (- for stdin)") { |value| options[:input] = value }
389
+ opts.on("--format FORMAT", ImportExport::FORMATS.map(&:to_s), "Import format (yaml or json)") do |value|
390
+ options[:format] = value.to_sym
391
+ end
392
+ opts.on("--merge", "Merge entries into the current database (default)") { options[:mode] = :merge }
393
+ opts.on("--replace", "Replace the current database with the import") { options[:mode] = :replace }
394
+ opts.on("-h", "--help", "Show this help") do
395
+ puts opts
396
+ exit 0
397
+ end
398
+ end
399
+
400
+ option_argv, positional = partition_argv(argv, value_flags: ["-i", "--input", "--format"])
401
+ parser.parse!(option_argv)
402
+
403
+ input = options[:input] || positional.first
404
+ raise OptionParser::ParseError, "Input file is required (use - for stdin)" if input.nil?
405
+
406
+ prepare_db_for_import!(log, global, replace: options[:mode] == :replace)
407
+
408
+ imported = UsefulDB::ImportExport.parse_file(input, format: options[:format])
409
+ result = UsefulDB::ImportExport.import!(imported, mode: options[:mode], log: log)
410
+ UsefulDB.dbSave(log, load_options(global))
411
+
412
+ return if global[:quiet]
413
+
414
+ if result[:mode] == :replace
415
+ puts "Replaced database with #{result[:total]} entries."
416
+ else
417
+ puts "Imported #{result[:added]} entries (#{result[:skipped]} skipped as duplicates). Database now has #{result[:total]} entries."
418
+ end
419
+ end
420
+
421
+ def self.prepare_db_for_import!(log, global, replace:)
422
+ db_options = load_options(global)
423
+
424
+ if global[:db]
425
+ UsefulDB::Utils.ensure_db!(log, db_options)
426
+ elsif replace && !File.exist?(UsefulDB::Utils.db_path(db_options))
427
+ UsefulDB::Utils.ensure_db!(log, db_options)
428
+ else
429
+ UsefulDB.setup(log) unless global[:db]
430
+ UsefulDB.dbLoad(log, db_options)
431
+ end
432
+ end
433
+
434
+ def self.print_entries(entries, format:, ids: false)
435
+ case format
436
+ when :json
437
+ puts UsefulDB::JSONEncoder.generate(entries)
438
+ when :value_only
439
+ entries.each { |entry| puts entry["value"] }
440
+ else
441
+ if entries.empty?
442
+ puts "No matching entries."
443
+ return
444
+ end
445
+
446
+ entries.each do |entry|
447
+ prefix = ids ? "[#{entry['id']}] " : ""
448
+ puts "#{prefix}#{entry['value']}"
449
+ puts " tags: #{entry['tag'].join(', ')}"
450
+ puts " #{entry['description']}" unless entry['description'].to_s.empty?
451
+ puts
452
+ end
453
+ end
454
+ end
455
+
456
+ def self.print_help(command = nil)
457
+ case command
458
+ when "search"
459
+ puts <<~HELP
460
+ Usage: usefuldb search [options] [tags...]
461
+
462
+ Options:
463
+ --any Match entries with any tag (OR)
464
+ --json Print results as JSON
465
+ --value-only Print only entry values
466
+ --ids Include entry ids in human output
467
+ -h, --help Show this help
468
+ HELP
469
+ when "list"
470
+ puts <<~HELP
471
+ Usage: usefuldb list [options]
472
+
473
+ Options:
474
+ --json Print results as JSON
475
+ --tags-only Print unique tags
476
+ -h, --help Show this help
477
+ HELP
478
+ when "add"
479
+ puts <<~HELP
480
+ Usage: usefuldb add [options]
481
+
482
+ Options:
483
+ --tags TAGS Comma-separated search tags
484
+ --value VALUE Stored command, URL, or text
485
+ --description TEXT Entry description
486
+ -h, --help Show this help
487
+ HELP
488
+ when "remove", "rm"
489
+ puts <<~HELP
490
+ Usage: usefuldb remove <id> [options]
491
+
492
+ Options:
493
+ --tags TAGS Match entry tags when removing by value
494
+ --value VALUE Match entry value when removing by attributes
495
+ -h, --help Show this help
496
+ HELP
497
+ when "show"
498
+ puts <<~HELP
499
+ Usage: usefuldb show <id> [options]
500
+
501
+ Options:
502
+ --json Print entry as JSON
503
+ -h, --help Show this help
504
+ HELP
505
+ when "export"
506
+ puts <<~HELP
507
+ Usage: usefuldb export [options] [file]
508
+
509
+ Options:
510
+ -o, --output FILE Write export to FILE (- for stdout)
511
+ --format FORMAT Export format: yaml or json
512
+ -h, --help Show this help
513
+ HELP
514
+ when "import"
515
+ puts <<~HELP
516
+ Usage: usefuldb import [options] [file]
517
+
518
+ Options:
519
+ -i, --input FILE Read import from FILE (- for stdin)
520
+ --format FORMAT Import format: yaml or json
521
+ --merge Merge entries into the current database (default)
522
+ --replace Replace the current database with the import
523
+ -h, --help Show this help
524
+ HELP
525
+ else
526
+ puts <<~HELP
527
+ Usage: usefuldb [global options] <command> [arguments]
528
+
529
+ A simple command and URL database searchable by tag.
530
+
531
+ Commands:
532
+ search [tags...] Find entries by tag
533
+ list List all entries
534
+ add Add an entry
535
+ remove <id> Remove an entry by id
536
+ show <id> Show a single entry
537
+ count Print entry count
538
+ export [file] Export the database to YAML or JSON
539
+ import [file] Import a database export
540
+ help [command] Show help
541
+
542
+ Global options:
543
+ --db PATH Database file (default: ~/.usefuldb/db.yaml)
544
+ -q, --quiet Suppress non-essential output
545
+ -v, --verbose Enable debug logging
546
+ --version Print version
547
+ -h, --help Show this help
548
+
549
+ Examples:
550
+ usefuldb search git push
551
+ usefuldb search git commit --value-only
552
+ usefuldb add --tags git,commit --value "git commit -m 'msg'" --description "Commit changes"
553
+ usefuldb list --json
554
+ usefuldb show 42
555
+ usefuldb remove 42
556
+ usefuldb export backup.yaml
557
+ usefuldb import backup.yaml --merge
558
+ usefuldb export -o - --format json | jq '.db | length'
559
+ HELP
560
+ end
561
+ end
562
+ end
563
+ end
@@ -1,15 +1,9 @@
1
- require 'rubygems'
2
- require 'usefuldb'
1
+ # frozen_string_literal: true
3
2
 
4
3
  module UsefulDB
5
-
6
- class EntryInDB < Exception
7
- end
8
-
9
- class EmptyDB < Exception
10
- end
11
-
12
- class KeyOutOfBounds < Exception
13
- end
14
-
15
- end
4
+ class EntryInDB < StandardError; end
5
+ class EmptyDB < StandardError; end
6
+ class KeyOutOfBounds < StandardError; end
7
+ class EntryNotFound < StandardError; end
8
+ class ImportError < StandardError; end
9
+ end