rom-distillery 0.1

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,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