rom-distillery 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,234 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+
3
+ require 'optparse'
4
+ require 'json'
5
+ require 'tty/screen'
6
+ require 'tty/logger'
7
+ require 'tty/spinner'
8
+ require 'tty/spinner/multi'
9
+ require 'tty/progressbar'
10
+
11
+ require_relative 'storage'
12
+ require_relative 'datfile'
13
+ require_relative 'refinements'
14
+
15
+
16
+ if !defined?(::Version)
17
+ Version = Distillery::VERSION
18
+ end
19
+
20
+
21
+ module Distillery
22
+
23
+ class CLI
24
+ using Distillery::StringY
25
+
26
+ # List of available output mode
27
+ OUTPUT_MODE = [ :text, :fancy, :json ]
28
+
29
+
30
+ # @!visibility private
31
+ @@subcommands = {}
32
+
33
+
34
+ # Execute the CLI
35
+ def self.run(argv = ARGV)
36
+ self.new.parse(argv)
37
+ end
38
+
39
+
40
+ # Register a new (sub)command into the CLI
41
+ #
42
+ # @param name [Symbol]
43
+ # @param description [String]
44
+ # @param optpartser [OptionParser]
45
+ #
46
+ # @yieldparam argv [Array<String>]
47
+ # @yieldparam into: [Object]
48
+ # @yieldreturn Array<Object> # Subcommand parameters
49
+ #
50
+ def self.subcommand(name, description, optparser=nil, &exec)
51
+ @@subcommands[name] = [ description, optparser, exec ]
52
+ end
53
+
54
+
55
+ # Global option parser
56
+ #
57
+ GlobalParser = OptionParser.new do |opts|
58
+ opts.banner = "Usage: #{opts.program_name} [options] CMD [opts] [args]"
59
+
60
+ opts.separator ""
61
+ opts.separator "Options:"
62
+ opts.on "-h", "--help", "Show this message" do
63
+ puts opts
64
+ puts ""
65
+ puts "Commands:"
66
+ @@subcommands.each {|name, (desc, *) |
67
+ puts " %-12s %s" % [ name, desc ]
68
+ }
69
+ puts ""
70
+ puts "See '#{opts.program_name} CMD --help'" \
71
+ " for more information on a specific command"
72
+ puts ""
73
+ exit
74
+ end
75
+
76
+ opts.on "-V", "--version", "Show version" do
77
+ puts opts.ver()
78
+ exit
79
+ end
80
+
81
+ opts.separator ""
82
+ opts.separator "Global options:"
83
+ opts.on "-o", "--output=FILE", "Output file"
84
+ opts.on "-m", "--output-mode=MODE", OUTPUT_MODE,
85
+ "Output mode (#{OUTPUT_MODE.first})",
86
+ " Value: #{OUTPUT_MODE.join(', ')}"
87
+ opts.on "-d", "--dat=FILE", "DAT file"
88
+ opts.on "-I", "--index=FILE", "Index file"
89
+ opts.on "-D", "--destdir=DIR", "Destination directory"
90
+ opts.on "-f", "--force", "Force operation"
91
+ opts.on '-p', '--[no-]progress', "Show progress"
92
+ opts.on '-v', '--[no-]verbose', "Run verbosely"
93
+ end
94
+
95
+
96
+ # Program name
97
+ PROGNAME = GlobalParser.program_name
98
+
99
+ def initialize
100
+ @verbose = true
101
+ @progress = true
102
+ @output_mode = OUTPUT_MODE.first
103
+ @io = $stdout
104
+ end
105
+
106
+
107
+ # Parse command line arguments
108
+ #
109
+ #
110
+ def parse(argv)
111
+ # Parsed option holder
112
+ opts = {}
113
+
114
+ # Parse global options
115
+ GlobalParser.order!(argv, into: opts)
116
+
117
+ # Check for subcommand
118
+ subcommand = argv.shift&.to_sym
119
+ if subcommand.nil?
120
+ warn "subcommand missing"
121
+ exit
122
+ end
123
+ if !@@subcommands.include?(subcommand)
124
+ warn "subcommand \'#{subcommand}\' is not recognised"
125
+ exit
126
+ end
127
+
128
+ # Process our options
129
+ if opts.include?(:output)
130
+ @io = File.open(opts[:output],
131
+ File::CREAT|File::TRUNC|File::WRONLY)
132
+ end
133
+ if opts.include?(:verbose)
134
+ @verbose = opts[:verbose]
135
+ end
136
+ if opts.include?(:progress)
137
+ @progress = opts[:progress]
138
+ end
139
+ if opts.include?(:'output-mode')
140
+ @output_mode = opts[:'output-mode']
141
+ end
142
+
143
+ # Sanitize
144
+ if (@ouput_mode == :fancy) && !@io.tty?
145
+ @output_mode = :text
146
+ end
147
+
148
+ # Parse command, and build arguments call
149
+ _, optparser, argbuilder = @@subcommands[subcommand]
150
+ optparser.order!(argv, into: opts) if optparser
151
+ args = argbuilder.call(argv, **opts)
152
+
153
+ # Call subcommand
154
+ self.method(subcommand).call(*args)
155
+ rescue OptionParser::InvalidArgument => e
156
+ warn "#{PROGNAME}: #{e}"
157
+ end
158
+
159
+
160
+ # Create DAT from file
161
+ #
162
+ # @param file [String] dat file
163
+ # @param verbose [Boolean] be verbose
164
+ #
165
+ # @return [DatFile]
166
+ #
167
+ def make_dat(file, verbose: @verbose, progress: @progress)
168
+ dat = DatFile.new(file)
169
+ if verbose
170
+ $stderr.puts "DAT = #{dat.version}"
171
+ end
172
+ dat
173
+ end
174
+
175
+
176
+
177
+ # Potential ROM from directory.
178
+ # @see Vault.from_dir for details
179
+ #
180
+ # @param romdirs [Array<String>] path to rom directoris
181
+ # @param depth [Integer,nil] exploration depth
182
+ #
183
+ # @yieldparam file [String] file being processed
184
+ # @yieldparam dir: [String] directory relative to
185
+ #
186
+ def from_romdirs(romdirs, depth: nil, &block)
187
+ romdirs.each {|dir|
188
+ Vault.from_dir(dir, depth: depth, &block)
189
+ }
190
+ end
191
+
192
+
193
+ # Create Storage from ROMs directories
194
+ #
195
+ # @param romdirs [Array<String>] array of ROMs directories
196
+ # @param verbose [Boolean] be verbose
197
+ #
198
+ # @return [Storage]
199
+ #
200
+ def make_storage(romdirs, depth: nil,
201
+ verbose: @verbose, progress: @progress)
202
+ vault = Vault::new
203
+ block = ->(file, dir:) { vault.add_from_file(file, dir) }
204
+
205
+ if progress
206
+ TTY::Spinner.new("[:spinner] :file", :hide_cursor => true,
207
+ :clear => true)
208
+ .run('Done!') {|spinner|
209
+ from_romdirs(romdirs, depth: depth) {|file, dir:|
210
+ width = TTY::Screen.width - 8
211
+ spinner.update(:file => file.ellipsize(width, :middle))
212
+ block.call(file, dir: dir)
213
+ }
214
+ }
215
+ else
216
+ from_romdirs(romdirs, depth: depth, &block)
217
+ end
218
+
219
+ Storage::new(vault)
220
+ end
221
+
222
+ end
223
+ end
224
+
225
+
226
+ require_relative 'cli/check'
227
+ require_relative 'cli/validate'
228
+ require_relative 'cli/index'
229
+ require_relative 'cli/rename'
230
+ require_relative 'cli/rebuild'
231
+ require_relative 'cli/repack'
232
+ require_relative 'cli/overlap'
233
+ require_relative 'cli/header'
234
+ require_relative 'cli/clean'
@@ -0,0 +1,100 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+
3
+ module Distillery
4
+ class CLI
5
+
6
+ # Check that the ROM directories form an exact match of the DAT file
7
+ #
8
+ # @param datfile [String] DAT file
9
+ # @param romdirs [Array<String>] ROMs directories
10
+ #
11
+ # @return [self]
12
+ #
13
+ def check(datfile, romdirs, revert: false)
14
+ dat = make_dat(datfile)
15
+ storage = make_storage(romdirs)
16
+
17
+ missing = dat.roms - storage.roms
18
+ extra = storage.roms - dat.roms
19
+ included = dat.roms & storage.roms
20
+
21
+ printer = proc {|entry, subentries|
22
+ @io.puts "- #{entry}"
23
+ Array(subentries).each {|entry| @io.puts " . #{entry}" }
24
+ }
25
+
26
+ # Warn about presence of headered ROM
27
+ if storage.headered
28
+ warn "===> Headered ROM"
29
+ end
30
+
31
+
32
+ # Show included ROMs
33
+ if revert
34
+ if included.empty?
35
+ @io.puts "==> No rom included"
36
+ else
37
+ @io.puts "==> Included roms (#{included.size}):"
38
+ included.dump(comptact: true, &printer)
39
+ end
40
+
41
+ # Show mssing and extra ROMs
42
+ else
43
+ if ! missing.empty?
44
+ @io.puts "==> Missing roms (#{missing.size}):"
45
+ missing.dump(compact: true, &printer)
46
+ end
47
+ @io.puts if !missing.empty? && !extra.empty?
48
+ if ! extra.empty?
49
+ @io.puts "==> Extra roms (#{extra.size}):"
50
+ extra.dump(compact: true, &printer)
51
+ end
52
+
53
+ end
54
+
55
+ # Have we a perfect match ?
56
+ if missing.empty? && extra.empty?
57
+ @io.puts "==> PERFECT"
58
+ end
59
+
60
+ self
61
+ end
62
+
63
+
64
+ # -----------------------------------------------------------------
65
+
66
+
67
+ # Parser for check command
68
+ CheckParser = OptionParser.new do |opts|
69
+ opts.banner = "Usage: #{PROGNAME} check [options] ROMDIR..."
70
+
71
+ opts.separator ""
72
+ opts.separator "Check ROMs status, and display missing or extra files."
73
+ opts.separator ""
74
+ opts.separator "Options:"
75
+ opts.on '-r', '--revert', "Display present files instead"
76
+ opts.separator ""
77
+ end
78
+
79
+
80
+ # Register check command
81
+ subcommand :check, "Check ROM status",
82
+ CheckParser do |argv, **opts|
83
+ opts[:romdirs] = argv
84
+ if opts[:dat].nil? && (opts[:romdirs].size >= 1)
85
+ opts[:dat] = File.join(opts[:romdirs].first, '.dat')
86
+ end
87
+ if opts[:dat].nil?
88
+ warn "missing datfile"
89
+ exit
90
+ end
91
+ if opts[:romdirs].empty?
92
+ warn "missing ROM directory"
93
+ exit
94
+ end
95
+
96
+ [ opts[:dat], opts[:romdirs], revert: opts[:revert] || false ]
97
+ end
98
+ end
99
+
100
+ end
@@ -0,0 +1,60 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+
3
+ module Distillery
4
+
5
+ class CLI
6
+
7
+ def clean(datfile, romdirs, savedir: nil)
8
+ dat = make_dat(datfile)
9
+ storage = make_storage(romdirs)
10
+ extra = storage.roms - dat.roms
11
+
12
+ extra.save(savedir) if savedir
13
+ extra.each {|rom| rom.delete! }
14
+ end
15
+
16
+
17
+ # -----------------------------------------------------------------
18
+
19
+ # Parser for clean command
20
+ CleanParser = OptionParser.new do |opts|
21
+ opts.banner = "Usage: #{PROGNAME} clean [options] ROMDIR..."
22
+
23
+ opts.separator ""
24
+ opts.separator "Remove content not referenced in DAT file"
25
+ opts.separator ""
26
+ opts.separator "Options:"
27
+ opts.on '-s', '--summarize', "Summarize results"
28
+ opts.separator ""
29
+
30
+ end
31
+
32
+ # Register clean command
33
+ subcommand :clean, "Remove content not referenced in DAT file",
34
+ CleanParser do |argv, **opts|
35
+ opts[:romdirs] = ARGV
36
+ if opts[:dat].nil? && (opts[:romdirs].size == 1)
37
+ opts[:dat] = File.join(opts[:romdirs].first, '.dat')
38
+ end
39
+ if opts[:savedir].nil? && (opts[:romdirs].size == 1)
40
+ opts[:savedir] = File.join(opts[:romdirs].first, '.trash')
41
+ end
42
+
43
+ if opts[:dat].nil?
44
+ warn "missing datfile"
45
+ exit
46
+ end
47
+ if opts[:romdirs].empty?
48
+ warn "missing ROM directory"
49
+ exit
50
+ end
51
+ if opts[:savedir].empty?
52
+ warn "missing save directory"
53
+ exit
54
+ end
55
+
56
+ [ opts[:dat], opts[:romdirs], savedir: opts[:savedir] ]
57
+ end
58
+ end
59
+
60
+ end
@@ -0,0 +1,61 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+
3
+ module Distillery
4
+ class CLI
5
+
6
+ # Save ROM header in a specified directory
7
+ #
8
+ # @param hdrdir [String] Directory for saving headers
9
+ # @param romdirs [Array<String>] ROMs directories
10
+ #
11
+ # @return [self]
12
+ #
13
+ def header(hdrdir, romdirs)
14
+ storage = make_storage(romdirs)
15
+ storage.roms.select {|rom| rom.headered? }.each {|rom|
16
+ file = File.join(hdrdir, rom.fshash)
17
+ header = rom.header
18
+ if File.exists?(file)
19
+ if header != File.binread(file)
20
+ warn "different header exists : #{rom.fshash}"
21
+ end
22
+ next
23
+ end
24
+ File.write(file, header)
25
+ }
26
+
27
+ self
28
+ end
29
+
30
+
31
+ # -----------------------------------------------------------------
32
+
33
+
34
+ # Parser for header command
35
+ HeaderParser = OptionParser.new do |opts|
36
+ opts.banner = "Usage: #{PROGNAME} index ROMDIR..."
37
+
38
+ opts.separator ""
39
+ opts.separator "Extract ROM embedded header"
40
+ opts.separator ""
41
+ opts.separator "Options:"
42
+ opts.separator ""
43
+ end
44
+
45
+
46
+ # Register header command
47
+ subcommand :header, "Extract ROM embedded header",
48
+ HeaderParser do |argv, **opts|
49
+ opts[:romdirs] = ARGV
50
+ if opts[:destdir].nil? && (opts[:romdirs].size == 1)
51
+ opts[:destdir] = File.join(opts[:romdirs].first, '.header')
52
+ end
53
+ if opts[:romdirs].empty?
54
+ warn "missing ROM directory"
55
+ exit
56
+ end
57
+
58
+ [ opts[:destdir], opts[:romdirs] ]
59
+ end
60
+ end
61
+ end