cabriolet 0.1.2 → 0.2.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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +703 -38
  3. data/lib/cabriolet/algorithm_factory.rb +250 -0
  4. data/lib/cabriolet/base_compressor.rb +206 -0
  5. data/lib/cabriolet/binary/bitstream.rb +167 -16
  6. data/lib/cabriolet/binary/bitstream_writer.rb +150 -21
  7. data/lib/cabriolet/binary/chm_structures.rb +2 -2
  8. data/lib/cabriolet/binary/hlp_structures.rb +258 -37
  9. data/lib/cabriolet/binary/lit_structures.rb +231 -65
  10. data/lib/cabriolet/binary/oab_structures.rb +17 -1
  11. data/lib/cabriolet/cab/command_handler.rb +226 -0
  12. data/lib/cabriolet/cab/compressor.rb +108 -84
  13. data/lib/cabriolet/cab/decompressor.rb +16 -20
  14. data/lib/cabriolet/cab/extractor.rb +142 -66
  15. data/lib/cabriolet/cab/file_compression_work.rb +52 -0
  16. data/lib/cabriolet/cab/file_compression_worker.rb +89 -0
  17. data/lib/cabriolet/checksum.rb +49 -0
  18. data/lib/cabriolet/chm/command_handler.rb +227 -0
  19. data/lib/cabriolet/chm/compressor.rb +7 -3
  20. data/lib/cabriolet/chm/decompressor.rb +39 -21
  21. data/lib/cabriolet/chm/parser.rb +5 -2
  22. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  23. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  24. data/lib/cabriolet/cli/command_registry.rb +83 -0
  25. data/lib/cabriolet/cli.rb +356 -607
  26. data/lib/cabriolet/collections/file_collection.rb +175 -0
  27. data/lib/cabriolet/compressors/base.rb +1 -1
  28. data/lib/cabriolet/compressors/lzx.rb +241 -54
  29. data/lib/cabriolet/compressors/mszip.rb +35 -3
  30. data/lib/cabriolet/compressors/quantum.rb +36 -95
  31. data/lib/cabriolet/decompressors/base.rb +1 -1
  32. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  33. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  34. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  35. data/lib/cabriolet/decompressors/quantum.rb +83 -53
  36. data/lib/cabriolet/errors.rb +3 -0
  37. data/lib/cabriolet/extraction/base_extractor.rb +88 -0
  38. data/lib/cabriolet/extraction/extractor.rb +171 -0
  39. data/lib/cabriolet/extraction/file_extraction_work.rb +60 -0
  40. data/lib/cabriolet/extraction/file_extraction_worker.rb +106 -0
  41. data/lib/cabriolet/file_entry.rb +156 -0
  42. data/lib/cabriolet/file_manager.rb +144 -0
  43. data/lib/cabriolet/format_base.rb +79 -0
  44. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  45. data/lib/cabriolet/hlp/compressor.rb +28 -238
  46. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  47. data/lib/cabriolet/hlp/parser.rb +52 -101
  48. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  49. data/lib/cabriolet/hlp/quickhelp/compressor.rb +151 -0
  50. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  51. data/lib/cabriolet/hlp/quickhelp/file_writer.rb +125 -0
  52. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  53. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  54. data/lib/cabriolet/hlp/quickhelp/offset_calculator.rb +61 -0
  55. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  56. data/lib/cabriolet/hlp/quickhelp/structure_builder.rb +93 -0
  57. data/lib/cabriolet/hlp/quickhelp/topic_builder.rb +52 -0
  58. data/lib/cabriolet/hlp/quickhelp/topic_compressor.rb +83 -0
  59. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  60. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  61. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  62. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  63. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  64. data/lib/cabriolet/huffman/encoder.rb +15 -12
  65. data/lib/cabriolet/huffman/tree.rb +85 -1
  66. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  67. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  68. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  69. data/lib/cabriolet/lit/command_handler.rb +221 -0
  70. data/lib/cabriolet/lit/compressor.rb +119 -168
  71. data/lib/cabriolet/lit/content_encoder.rb +76 -0
  72. data/lib/cabriolet/lit/content_type_detector.rb +50 -0
  73. data/lib/cabriolet/lit/decompressor.rb +518 -152
  74. data/lib/cabriolet/lit/directory_builder.rb +153 -0
  75. data/lib/cabriolet/lit/guid_generator.rb +16 -0
  76. data/lib/cabriolet/lit/header_writer.rb +124 -0
  77. data/lib/cabriolet/lit/parser.rb +670 -0
  78. data/lib/cabriolet/lit/piece_builder.rb +74 -0
  79. data/lib/cabriolet/lit/structure_builder.rb +252 -0
  80. data/lib/cabriolet/models/hlp_file.rb +130 -29
  81. data/lib/cabriolet/models/hlp_header.rb +105 -17
  82. data/lib/cabriolet/models/lit_header.rb +212 -25
  83. data/lib/cabriolet/models/szdd_header.rb +10 -2
  84. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  85. data/lib/cabriolet/oab/command_handler.rb +257 -0
  86. data/lib/cabriolet/oab/compressor.rb +17 -8
  87. data/lib/cabriolet/oab/decompressor.rb +41 -10
  88. data/lib/cabriolet/offset_calculator.rb +81 -0
  89. data/lib/cabriolet/plugin.rb +233 -0
  90. data/lib/cabriolet/plugin_manager.rb +453 -0
  91. data/lib/cabriolet/plugin_validator.rb +422 -0
  92. data/lib/cabriolet/quantum_shared.rb +105 -0
  93. data/lib/cabriolet/system/io_system.rb +3 -0
  94. data/lib/cabriolet/system/memory_handle.rb +17 -4
  95. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  96. data/lib/cabriolet/szdd/compressor.rb +15 -11
  97. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  98. data/lib/cabriolet/version.rb +1 -1
  99. data/lib/cabriolet.rb +181 -20
  100. metadata +69 -4
  101. data/lib/cabriolet/auto.rb +0 -173
  102. data/lib/cabriolet/parallel.rb +0 -333
data/lib/cabriolet/cli.rb CHANGED
@@ -2,88 +2,130 @@
2
2
 
3
3
  require "thor"
4
4
 
5
+ require_relative "cli/command_registry"
6
+ require_relative "cli/command_dispatcher"
7
+
8
+ # Register all format handlers with the command registry
9
+ require_relative "cab/command_handler"
10
+ require_relative "chm/command_handler"
11
+ require_relative "szdd/command_handler"
12
+ require_relative "kwaj/command_handler"
13
+ require_relative "hlp/command_handler"
14
+ require_relative "lit/command_handler"
15
+ require_relative "oab/command_handler"
16
+
17
+ Cabriolet::Commands::CommandRegistry.register_format(:cab, Cabriolet::CAB::CommandHandler)
18
+ Cabriolet::Commands::CommandRegistry.register_format(:chm, Cabriolet::CHM::CommandHandler)
19
+ Cabriolet::Commands::CommandRegistry.register_format(:szdd, Cabriolet::SZDD::CommandHandler)
20
+ Cabriolet::Commands::CommandRegistry.register_format(:kwaj, Cabriolet::KWAJ::CommandHandler)
21
+ Cabriolet::Commands::CommandRegistry.register_format(:hlp, Cabriolet::HLP::CommandHandler)
22
+ Cabriolet::Commands::CommandRegistry.register_format(:lit, Cabriolet::LIT::CommandHandler)
23
+ Cabriolet::Commands::CommandRegistry.register_format(:oab, Cabriolet::OAB::CommandHandler)
24
+
5
25
  module Cabriolet
6
- # CLI provides command-line interface for Cabriolet
26
+ # CLI provides unified command-line interface for Cabriolet
27
+ #
28
+ # The CLI uses auto-detection to determine the format of input files,
29
+ # then dispatches commands to the appropriate format handler.
30
+ # A --format option allows manual override when needed.
31
+ #
32
+ # Legacy format-specific commands are maintained for backward compatibility.
33
+ #
7
34
  class CLI < Thor
8
35
  def self.exit_on_failure?
9
36
  true
10
37
  end
11
38
 
12
- desc "list FILE", "List contents of CAB file"
13
- option :verbose, type: :boolean, aliases: "-v",
14
- desc: "Enable verbose output"
15
- def list(file)
16
- setup_verbose(options[:verbose])
39
+ # Global option for format override
40
+ class_option :format, type: :string, enum: %w[cab chm szdd kwaj hlp lit oab],
41
+ desc: "Force format (overrides auto-detection)"
17
42
 
18
- decompressor = CAB::Decompressor.new
19
- cabinet = decompressor.open(file)
43
+ # Global option for verbose output
44
+ class_option :verbose, type: :boolean, aliases: "-v",
45
+ desc: "Enable verbose output"
20
46
 
21
- puts "Cabinet: #{cabinet.filename}"
22
- puts "Set ID: #{cabinet.set_id}, Index: #{cabinet.set_index}"
23
- puts "Folders: #{cabinet.folder_count}, Files: #{cabinet.file_count}"
24
- puts "\nFiles:"
47
+ # ==========================================================================
48
+ # Unified Commands (auto-detect format)
49
+ # ==========================================================================
25
50
 
26
- cabinet.files.each do |f|
27
- puts " #{f.filename} (#{f.length} bytes)"
28
- end
29
- rescue Error => e
30
- abort "Error: #{e.message}"
51
+ desc "list FILE", "List contents of archive file (auto-detects format)"
52
+ option :format, type: :string, hide: true # Deprecated, use global --format
53
+ def list(file)
54
+ run_dispatcher(:list, file, **options)
31
55
  end
32
56
 
33
- desc "extract FILE [OUTPUT_DIR]", "Extract files from CAB"
34
- option :output, type: :string, aliases: "-o", desc: "Output directory"
35
- option :verbose, type: :boolean, aliases: "-v",
36
- desc: "Enable verbose output"
57
+ desc "extract FILE [OUTPUT_DIR]",
58
+ "Extract files from archive (auto-detects format)"
59
+ option :output, type: :string, aliases: "-o",
60
+ desc: "Output file/directory path"
37
61
  option :salvage, type: :boolean,
38
- desc: "Enable salvage mode for corrupted files"
62
+ desc: "Enable salvage mode for corrupted files (CAB only)"
63
+ option :base_file, type: :string,
64
+ desc: "Base file for incremental patches (OAB only)"
65
+ option :use_manifest, type: :boolean,
66
+ desc: "Use manifest for filenames (LIT only)"
67
+ option :format, type: :string, hide: true # Deprecated, use global --format
39
68
  def extract(file, output_dir = nil)
40
- setup_verbose(options[:verbose])
41
- output_dir ||= options[:output] || "."
69
+ run_dispatcher(:extract, file, output_dir, **options)
70
+ end
42
71
 
43
- decompressor = CAB::Decompressor.new
44
- decompressor.salvage = options[:salvage] if options[:salvage]
72
+ desc "create OUTPUT FILES...",
73
+ "Create archive file (auto-detects format from extension)"
74
+ option :compression, type: :string, enum: %w[none mszip lzx quantum],
75
+ desc: "Compression type (CAB only)"
76
+ option :format, type: :string, enum: %w[cab chm szdd kwaj hlp lit oab],
77
+ desc: "Output format (default: auto-detect from OUTPUT extension)"
78
+ option :window_bits, type: :numeric, desc: "LZX window size for CHM (15-21)"
79
+ option :missing_char, type: :string, desc: "Missing character for SZDD"
80
+ option :szdd_format, type: :string, enum: %w[normal qbasic],
81
+ desc: "SZDD format variant (default: normal)"
82
+ option :kwaj_compression, type: :string, enum: %w[none xor szdd mszip],
83
+ desc: "KWAJ compression method (default: szdd)"
84
+ option :include_length, type: :boolean,
85
+ desc: "Include length in KWAJ header"
86
+ option :kwaj_filename, type: :string,
87
+ desc: "Original filename for KWAJ header"
88
+ option :extra_data, type: :string, desc: "Extra data for KWAJ header"
89
+ option :hlp_format, type: :string, enum: %w[quickhelp winhelp],
90
+ desc: "HLP format variant (default: quickhelp)"
91
+ option :language_id, type: :string,
92
+ desc: "Language ID for LIT (e.g., 0x409)"
93
+ option :lit_version, type: :numeric, desc: "LIT format version (default: 1)"
94
+ option :block_size, type: :numeric, desc: "Block size for OAB compression"
95
+ option :compress, type: :boolean, default: true,
96
+ desc: "Compress files in HLP/LIT"
97
+ def create(output, *files)
98
+ # Normalize options for create command
99
+ create_options = normalize_create_options(options)
45
100
 
46
- cabinet = decompressor.open(file)
47
- count = decompressor.extract_all(cabinet, output_dir)
101
+ # Detect format from output extension if not specified
102
+ format = detect_format_from_output(output, create_options[:format])
48
103
 
49
- puts "Extracted #{count} file(s) to #{output_dir}"
50
- rescue Error => e
51
- abort "Error: #{e.message}"
104
+ # Set the format in options for the dispatcher
105
+ create_options[:format] = format
106
+
107
+ run_dispatcher(:create, output, files, **create_options)
52
108
  end
53
109
 
54
- desc "info FILE", "Show detailed CAB file information"
55
- option :verbose, type: :boolean, aliases: "-v",
56
- desc: "Enable verbose output"
110
+ desc "info FILE",
111
+ "Show detailed archive file information (auto-detects format)"
112
+ option :format, type: :string, hide: true # Deprecated, use global --format
57
113
  def info(file)
58
- setup_verbose(options[:verbose])
59
-
60
- decompressor = CAB::Decompressor.new
61
- cabinet = decompressor.open(file)
62
-
63
- display_cabinet_info(cabinet)
64
- rescue Error => e
65
- abort "Error: #{e.message}"
114
+ run_dispatcher(:info, file, **options)
66
115
  end
67
116
 
68
- desc "test FILE", "Test CAB file integrity"
69
- option :verbose, type: :boolean, aliases: "-v",
70
- desc: "Enable verbose output"
117
+ desc "test FILE", "Test archive file integrity (auto-detects format)"
118
+ option :format, type: :string, hide: true # Deprecated, use global --format
71
119
  def test(file)
72
- setup_verbose(options[:verbose])
73
-
74
- decompressor = CAB::Decompressor.new
75
- cabinet = decompressor.open(file)
76
-
77
- puts "Testing #{cabinet.filename}..."
78
- # TODO: Implement integrity testing
79
- puts "OK: All #{cabinet.file_count} files passed integrity check"
80
- rescue Error => e
81
- abort "Error: #{e.message}"
120
+ run_dispatcher(:test, file, **options)
82
121
  end
83
122
 
123
+ # ==========================================================================
124
+ # Legacy Commands (maintained for backward compatibility)
125
+ # ==========================================================================
126
+
127
+ # CAB-specific legacy commands
84
128
  desc "search FILE", "Search for embedded CAB files"
85
- option :verbose, type: :boolean, aliases: "-v",
86
- desc: "Enable verbose output"
87
129
  def search(file)
88
130
  setup_verbose(options[:verbose])
89
131
 
@@ -107,579 +149,265 @@ module Cabriolet
107
149
  abort "Error: #{e.message}"
108
150
  end
109
151
 
110
- desc "create OUTPUT FILES...", "Create a CAB file from source files"
111
- option :compression, type: :string, enum: %w[none mszip lzx quantum],
112
- default: "mszip", desc: "Compression type"
113
- option :verbose, type: :boolean, aliases: "-v",
114
- desc: "Enable verbose output"
115
- def create(output, *files)
116
- setup_verbose(options[:verbose])
117
-
118
- raise ArgumentError, "No files specified" if files.empty?
119
-
120
- files.each do |f|
121
- raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
122
- end
123
-
124
- compressor = CAB::Compressor.new
125
- files.each { |f| compressor.add_file(f) }
126
-
127
- puts "Creating #{output} with #{files.size} file(s) (#{options[:compression]} compression)" if options[:verbose]
128
- bytes = compressor.generate(output,
129
- compression: options[:compression].to_sym)
130
- puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
131
- rescue Error => e
132
- abort "Error: #{e.message}"
133
- end
134
-
135
- # CHM commands
136
- desc "chm-list FILE", "List contents of CHM file"
152
+ # CHM legacy commands (aliases to unified commands with format override)
153
+ desc "chm-list FILE",
154
+ "List contents of CHM file (legacy, use: list --format chm FILE)"
137
155
  option :verbose, type: :boolean, aliases: "-v",
138
156
  desc: "Enable verbose output"
139
157
  def chm_list(file)
140
- setup_verbose(options[:verbose])
141
-
142
- decompressor = CHM::Decompressor.new
143
- chm = decompressor.open(file)
144
-
145
- puts "CHM File: #{chm.filename}"
146
- puts "Version: #{chm.version}"
147
- puts "Language: #{chm.language}"
148
- puts "Chunks: #{chm.num_chunks}, Chunk Size: #{chm.chunk_size}"
149
- puts "\nFiles:"
150
-
151
- chm.all_files.each do |f|
152
- section_name = f.section.id.zero? ? "Uncompressed" : "MSCompressed"
153
- puts " #{f.filename} (#{f.length} bytes, #{section_name})"
154
- end
155
-
156
- decompressor.close
157
- rescue Error => e
158
- abort "Error: #{e.message}"
158
+ run_with_format(:list, :chm, file, verbose: options[:verbose])
159
159
  end
160
160
 
161
- desc "chm-extract FILE [OUTPUT_DIR]", "Extract files from CHM"
161
+ desc "chm-extract FILE [OUTPUT_DIR]",
162
+ "Extract files from CHM (legacy, use: extract --format chm FILE)"
162
163
  option :output, type: :string, aliases: "-o", desc: "Output directory"
163
164
  option :verbose, type: :boolean, aliases: "-v",
164
165
  desc: "Enable verbose output"
165
166
  def chm_extract(file, output_dir = nil)
166
- setup_verbose(options[:verbose])
167
- output_dir ||= options[:output] || "."
168
-
169
- decompressor = CHM::Decompressor.new
170
- chm = decompressor.open(file)
171
-
172
- require "fileutils"
173
- FileUtils.mkdir_p(output_dir)
174
-
175
- count = 0
176
- chm.all_files.each do |f|
177
- next if f.system_file?
178
-
179
- output_path = File.join(output_dir, f.filename)
180
- output_subdir = File.dirname(output_path)
181
- FileUtils.mkdir_p(output_subdir)
182
-
183
- puts "Extracting: #{f.filename}" if options[:verbose]
184
- decompressor.extract(f, output_path)
185
- count += 1
186
- end
187
-
188
- decompressor.close
189
- puts "Extracted #{count} file(s) to #{output_dir}"
190
- rescue Error => e
191
- abort "Error: #{e.message}"
167
+ opts = { verbose: options[:verbose], output: options[:output] }
168
+ run_with_format(:extract, :chm, file, output_dir, **opts)
192
169
  end
193
170
 
194
- desc "chm-info FILE", "Show detailed CHM file information"
171
+ desc "chm-info FILE",
172
+ "Show CHM file information (legacy, use: info --format chm FILE)"
195
173
  option :verbose, type: :boolean, aliases: "-v",
196
174
  desc: "Enable verbose output"
197
175
  def chm_info(file)
198
- setup_verbose(options[:verbose])
199
-
200
- decompressor = CHM::Decompressor.new
201
- chm = decompressor.open(file)
202
-
203
- display_chm_info(chm)
204
- decompressor.close
205
- rescue Error => e
206
- abort "Error: #{e.message}"
207
-
208
- desc "chm-create OUTPUT FILES...", "Create a CHM file from HTML files"
209
- option :window_bits, type: :numeric, default: 16,
210
- desc: "LZX window size (15-21)"
211
- option :verbose, type: :boolean, aliases: "-v",
212
- desc: "Enable verbose output"
213
- def chm_create(output, *files)
214
- setup_verbose(options[:verbose])
215
-
216
- raise ArgumentError, "No files specified" if files.empty?
217
-
218
- files.each do |f|
219
- raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
220
- end
221
-
222
- compressor = CHM::Compressor.new
223
- files.each do |f|
224
- # Default to compressed section for .html, uncompressed for images
225
- section = f.end_with?(".html", ".htm") ? :compressed : :uncompressed
226
- compressor.add_file(f, "/#{File.basename(f)}", section: section)
227
- end
176
+ run_with_format(:info, :chm, file, verbose: options[:verbose])
177
+ end
228
178
 
229
- if options[:verbose]
230
- puts "Creating #{output} with #{files.size} file(s) (window_bits: #{options[:window_bits]})"
231
- end
232
- bytes = compressor.generate(output, window_bits: options[:window_bits])
233
- puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
234
- rescue Error => e
235
- abort "Error: #{e.message}"
236
- end
179
+ desc "chm-create OUTPUT FILES...",
180
+ "Create CHM file (legacy, use: create --format chm OUTPUT FILES...)"
181
+ option :window_bits, type: :numeric, default: 16,
182
+ desc: "LZX window size (15-21)"
183
+ option :verbose, type: :boolean, aliases: "-v",
184
+ desc: "Enable verbose output"
185
+ def chm_create(output, *files)
186
+ opts = { verbose: options[:verbose], window_bits: options[:window_bits],
187
+ format: :chm }
188
+ run_dispatcher(:create, output, files, **opts)
237
189
  end
238
190
 
239
- # SZDD commands
191
+ # SZDD legacy commands
240
192
  desc "expand FILE [OUTPUT]",
241
- "Expand SZDD compressed file (like MS-DOS EXPAND.EXE)"
193
+ "Expand SZDD file (legacy, use: extract --format szdd FILE OUTPUT)"
242
194
  option :output, type: :string, aliases: "-o", desc: "Output file path"
243
195
  option :verbose, type: :boolean, aliases: "-v",
244
196
  desc: "Enable verbose output"
245
197
  def expand(file, output = nil)
246
- setup_verbose(options[:verbose])
247
- output ||= options[:output]
248
-
249
- decompressor = SZDD::Decompressor.new
250
- header = decompressor.open(file)
251
-
252
- # Auto-detect output name if not provided
253
- output ||= decompressor.auto_output_filename(file, header)
254
-
255
- puts "Expanding #{file} -> #{output}" if options[:verbose]
256
- bytes = decompressor.extract(header, output)
257
- decompressor.close(header)
258
-
259
- puts "Expanded #{file} to #{output} (#{bytes} bytes)"
260
- rescue Error => e
261
- abort "Error: #{e.message}"
198
+ # Use positional output if provided, otherwise use option
199
+ final_output = output || options[:output]
200
+ opts = { verbose: options[:verbose], output: final_output, format: :szdd }
201
+ run_dispatcher(:extract, file, final_output, **opts)
262
202
  end
263
203
 
264
- desc "szdd-info FILE", "Show SZDD file information"
204
+ desc "szdd-info FILE",
205
+ "Show SZDD file information (legacy, use: info --format szdd FILE)"
265
206
  option :verbose, type: :boolean, aliases: "-v",
266
207
  desc: "Enable verbose output"
267
208
  def szdd_info(file)
268
- setup_verbose(options[:verbose])
269
-
270
- decompressor = SZDD::Decompressor.new
271
- header = decompressor.open(file)
272
-
273
- puts "SZDD File Information"
274
- puts "=" * 50
275
- puts "Filename: #{file}"
276
- puts "Format: #{header.format.to_s.upcase}"
277
- puts "Uncompressed size: #{header.length} bytes"
278
- if header.missing_char
279
- puts "Missing character: '#{header.missing_char}'"
280
- puts "Suggested filename: #{header.suggested_filename(File.basename(file))}"
281
- end
282
-
283
- decompressor.close(header)
284
- rescue Error => e
285
- abort "Error: #{e.message}"
209
+ run_with_format(:info, :szdd, file, verbose: options[:verbose])
286
210
  end
287
211
 
288
212
  desc "compress FILE [OUTPUT]",
289
- "Compress file to SZDD format (like MS-DOS COMPRESS.EXE)"
213
+ "Compress to SZDD format (legacy, use: create --format szdd OUTPUT FILE)"
290
214
  option :output, type: :string, aliases: "-o", desc: "Output file path"
291
- option :missing_char, type: :string,
292
- desc: "Missing character for filename reconstruction"
215
+ option :missing_char, type: :string, desc: "Missing character for filename"
293
216
  option :format, type: :string, enum: %w[normal qbasic], default: "normal",
294
217
  desc: "SZDD format (normal or qbasic)"
295
218
  option :verbose, type: :boolean, aliases: "-v",
296
219
  desc: "Enable verbose output"
297
220
  def compress(file, output = nil)
298
- setup_verbose(options[:verbose])
299
- output ||= options[:output]
300
-
301
- # Auto-generate output name: file.txt -> file.tx_
221
+ # SZDD format option refers to SZDD variant (normal/qbasic), not file format
222
+ # File format is always :szdd, variant is passed as szdd_format
223
+ szdd_variant = options[:format] || "normal"
224
+
225
+ opts = {
226
+ verbose: options[:verbose],
227
+ output: options[:output],
228
+ format: :szdd, # Always SZDD format
229
+ szdd_format: szdd_variant.to_sym, # Pass variant as szdd_format
230
+ missing_char: options[:missing_char],
231
+ }
232
+ # SZDD convention: auto-generate output name
233
+ output ||= opts[:output]
302
234
  if output.nil?
303
- output = file.sub(/\.([^.])$/, "._")
304
- # If no extension or single char extension, just append _
305
- output = "#{file}_" if output == file
306
- end
307
-
308
- compressor = SZDD::Compressor.new
309
-
310
- puts "Compressing #{file} -> #{output}" if options[:verbose]
311
-
312
- compress_options = { format: options[:format].to_sym }
313
- if options[:missing_char]
314
- compress_options[:missing_char] =
315
- options[:missing_char]
235
+ ext = File.extname(file)
236
+ # SZDD format: last character of extension replaced with underscore
237
+ # e.g., file.txt -> file.tx_, file.c -> file.c_
238
+ if ext.length.between?(2, 4) # .c, .txt, .html, etc.
239
+ base = File.basename(file, ext)
240
+ output = "#{base}#{ext.chomp(ext[-1])}_"
241
+ else
242
+ output = "#{file}_"
243
+ end
244
+ opts[:output] = output
316
245
  end
317
-
318
- bytes = compressor.compress(file, output, **compress_options)
319
-
320
- puts "Compressed #{file} to #{output} (#{bytes} bytes)"
321
- rescue Error => e
322
- abort "Error: #{e.message}"
246
+ run_dispatcher(:create, output, [file], **opts)
323
247
  end
324
248
 
325
- # KWAJ commands
326
- desc "kwaj-extract FILE [OUTPUT]", "Extract KWAJ compressed file"
249
+ # KWAJ legacy commands
250
+ desc "kwaj-extract FILE [OUTPUT]",
251
+ "Extract KWAJ file (legacy, use: extract --format kwaj FILE OUTPUT)"
327
252
  option :output, type: :string, aliases: "-o", desc: "Output file path"
328
253
  option :verbose, type: :boolean, aliases: "-v",
329
254
  desc: "Enable verbose output"
330
255
  def kwaj_extract(file, output = nil)
331
- setup_verbose(options[:verbose])
332
- output ||= options[:output]
333
-
334
- decompressor = KWAJ::Decompressor.new
335
- header = decompressor.open(file)
336
-
337
- # Auto-detect output name if not provided
338
- output ||= decompressor.auto_output_filename(file, header)
339
-
340
- puts "Extracting #{file} -> #{output}" if options[:verbose]
341
- bytes = decompressor.extract(header, file, output)
342
- decompressor.close(header)
343
-
344
- puts "Extracted #{file} to #{output} (#{bytes} bytes)"
345
- rescue Error => e
346
- abort "Error: #{e.message}"
256
+ # Use positional output if provided, otherwise use option
257
+ output_file = output || options[:output]
258
+ opts = { verbose: options[:verbose], output: output_file, format: :kwaj }
259
+ run_dispatcher(:extract, file, nil, **opts)
347
260
  end
348
261
 
349
- desc "kwaj-info FILE", "Show KWAJ file information"
262
+ desc "kwaj-info FILE",
263
+ "Show KWAJ file information (legacy, use: info --format kwaj FILE)"
350
264
  option :verbose, type: :boolean, aliases: "-v",
351
265
  desc: "Enable verbose output"
352
266
  def kwaj_info(file)
353
- setup_verbose(options[:verbose])
354
-
355
- decompressor = KWAJ::Decompressor.new
356
- header = decompressor.open(file)
357
-
358
- puts "KWAJ File Information"
359
- puts "=" * 50
360
- puts "Filename: #{file}"
361
- puts "Compression: #{header.compression_name}"
362
- puts "Data offset: #{header.data_offset} bytes"
363
- puts "Uncompressed size: #{header.length || 'unknown'} bytes"
364
- puts "Original filename: #{header.filename}" if header.filename
365
- if header.extra && !header.extra.empty?
366
- puts "Extra data: #{header.extra_length} bytes"
367
- puts " #{header.extra}"
368
- end
369
-
370
- decompressor.close(header)
371
- rescue Error => e
372
- abort "Error: #{e.message}"
267
+ run_with_format(:info, :kwaj, file, verbose: options[:verbose])
373
268
  end
374
269
 
375
- desc "kwaj-compress FILE [OUTPUT]", "Compress file to KWAJ format"
270
+ desc "kwaj-compress FILE [OUTPUT]",
271
+ "Compress to KWAJ format (legacy, use: create --format kwaj OUTPUT FILE)"
376
272
  option :output, type: :string, aliases: "-o", desc: "Output file path"
377
- option :compression, type: :string, enum: %w[none xor szdd mszip],
378
- default: "szdd", desc: "Compression method"
379
- option :include_length, type: :boolean,
380
- desc: "Include uncompressed length in header"
381
- option :filename, type: :string,
382
- desc: "Original filename to embed in header"
383
- option :extra_data, type: :string, desc: "Extra data to include in header"
273
+ option :compression, type: :string, enum: %w[none xor szdd mszip], default: "szdd",
274
+ desc: "Compression method"
275
+ option :include_length, type: :boolean, desc: "Include uncompressed length"
276
+ option :filename, type: :string, desc: "Original filename to embed"
277
+ option :extra_data, type: :string, desc: "Extra data to include"
384
278
  option :verbose, type: :boolean, aliases: "-v",
385
279
  desc: "Enable verbose output"
386
280
  def kwaj_compress(file, output = nil)
387
- setup_verbose(options[:verbose])
388
- output ||= options[:output] || "#{file}.kwj"
389
-
390
- compressor = KWAJ::Compressor.new
391
-
392
- puts "Compressing #{file} -> #{output} (#{options[:compression]} compression)" if options[:verbose]
393
-
394
- compress_options = { compression: options[:compression].to_sym }
395
- if options[:include_length]
396
- compress_options[:include_length] =
397
- options[:include_length]
398
- end
399
- compress_options[:filename] = options[:filename] if options[:filename]
400
- if options[:extra_data]
401
- compress_options[:extra_data] =
402
- options[:extra_data]
403
- end
404
-
405
- bytes = compressor.compress(file, output, **compress_options)
406
-
407
- puts "Compressed #{file} to #{output} (#{bytes} bytes, #{options[:compression]} compression)"
408
- rescue Error => e
409
- abort "Error: #{e.message}"
281
+ # Use positional output if provided, otherwise use option or default
282
+ output_file = output || options[:output] || "#{file}.kwj"
283
+ opts = {
284
+ verbose: options[:verbose],
285
+ output: output_file,
286
+ format: :kwaj,
287
+ compression: options[:compression],
288
+ include_length: options[:include_length],
289
+ filename: options[:filename],
290
+ extra_data: options[:extra_data],
291
+ }
292
+ run_dispatcher(:create, opts[:output], [file], **opts)
410
293
  end
411
294
 
412
- # HLP commands
413
- desc "hlp-extract FILE [OUTPUT_DIR]", "Extract HLP file"
295
+ # HLP legacy commands
296
+ desc "hlp-extract FILE [OUTPUT_DIR]",
297
+ "Extract HLP file (legacy, use: extract --format hlp FILE)"
414
298
  option :output, type: :string, aliases: "-o", desc: "Output directory"
415
299
  option :verbose, type: :boolean, aliases: "-v",
416
300
  desc: "Enable verbose output"
417
301
  def hlp_extract(file, output_dir = nil)
418
- setup_verbose(options[:verbose])
419
- output_dir ||= options[:output] || "."
420
-
421
- decompressor = HLP::Decompressor.new
422
- header = decompressor.open(file)
423
-
424
- require "fileutils"
425
- FileUtils.mkdir_p(output_dir)
426
-
427
- puts "Extracting #{header.files.size} files from #{file}" if options[:verbose]
428
- count = decompressor.extract_all(header, output_dir)
429
-
430
- decompressor.close(header)
431
- puts "Extracted #{count} file(s) to #{output_dir}"
432
- rescue Error => e
433
- abort "Error: #{e.message}"
302
+ opts = { verbose: options[:verbose], output: options[:output],
303
+ format: :hlp }
304
+ run_dispatcher(:extract, file, output_dir, **opts)
434
305
  end
435
306
 
436
- desc "hlp-create OUTPUT FILES...", "Create HLP file"
437
- option :compress, type: :boolean, default: true,
438
- desc: "Compress files (LZSS MODE_MSHELP)"
307
+ desc "hlp-info FILE",
308
+ "Show HLP file information (legacy, use: info --format hlp FILE)"
439
309
  option :verbose, type: :boolean, aliases: "-v",
440
310
  desc: "Enable verbose output"
441
- def hlp_create(output, *files)
442
- setup_verbose(options[:verbose])
443
-
444
- raise ArgumentError, "No files specified" if files.empty?
445
-
446
- files.each do |f|
447
- raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
448
- end
449
-
450
- compressor = HLP::Compressor.new
451
- files.each do |f|
452
- compressor.add_file(f, File.basename(f), compress: options[:compress])
453
- end
454
-
455
- puts "Creating #{output} with #{files.size} file(s)" if options[:verbose]
456
- bytes = compressor.generate(output)
457
- puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
458
- rescue Error => e
459
- abort "Error: #{e.message}"
311
+ def hlp_info(file)
312
+ run_with_format(:info, :hlp, file, verbose: options[:verbose])
460
313
  end
461
314
 
462
- desc "hlp-info FILE", "Show HLP file information"
315
+ desc "hlp-create OUTPUT FILES...",
316
+ "Create HLP file (legacy, use: create --format hlp OUTPUT FILES...)"
317
+ option :compress, type: :boolean, default: true, desc: "Compress files"
318
+ option :format, type: :string, enum: %w[quickhelp winhelp], default: "quickhelp",
319
+ desc: "HLP format variant"
463
320
  option :verbose, type: :boolean, aliases: "-v",
464
321
  desc: "Enable verbose output"
465
- def hlp_info(file)
466
- setup_verbose(options[:verbose])
467
-
468
- decompressor = HLP::Decompressor.new
469
- header = decompressor.open(file)
470
-
471
- puts "HLP File Information"
472
- puts "=" * 50
473
- puts "Filename: #{file}"
474
- puts "Version: #{header.version}"
475
- puts "Files: #{header.files.size}"
476
- puts ""
477
- puts "Files:"
478
- header.files.each do |f|
479
- compression = f.compressed? ? "LZSS" : "none"
480
- puts " #{f.filename}"
481
- puts " Uncompressed: #{f.length} bytes"
482
- puts " Compressed: #{f.compressed_length} bytes (#{compression})"
483
- end
484
-
485
- decompressor.close(header)
486
- rescue Error => e
487
- abort "Error: #{e.message}"
322
+ def hlp_create(output, *files)
323
+ # Ensure format is set (default from Thor option or :hlp)
324
+ format = options[:format] || :hlp
325
+
326
+ opts = {
327
+ verbose: options[:verbose],
328
+ compress: options[:compress],
329
+ format: format,
330
+ hlp_format: options[:format],
331
+ }
332
+ run_dispatcher(:create, output, files, **opts)
488
333
  end
489
334
 
490
- # LIT commands
491
- desc "lit-extract FILE [OUTPUT_DIR]", "Extract LIT eBook file"
335
+ # LIT legacy commands
336
+ desc "lit-extract FILE [OUTPUT_DIR]",
337
+ "Extract LIT file (legacy, use: extract --format lit FILE)"
492
338
  option :output, type: :string, aliases: "-o", desc: "Output directory"
493
339
  option :verbose, type: :boolean, aliases: "-v",
494
340
  desc: "Enable verbose output"
495
341
  def lit_extract(file, output_dir = nil)
496
- setup_verbose(options[:verbose])
497
- output_dir ||= options[:output] || "."
498
-
499
- decompressor = LIT::Decompressor.new
500
- header = decompressor.open(file)
501
-
502
- abort "Error: LIT file is DRM-encrypted. Decryption not yet supported." if header.encrypted?
503
-
504
- require "fileutils"
505
- FileUtils.mkdir_p(output_dir)
506
-
507
- puts "Extracting #{header.files.size} files from #{file}" if options[:verbose]
508
- count = decompressor.extract_all(header, output_dir)
509
-
510
- decompressor.close(header)
511
- puts "Extracted #{count} file(s) to #{output_dir}"
512
- rescue Error => e
513
- abort "Error: #{e.message}"
514
- rescue NotImplementedError => e
515
- abort "Error: #{e.message}"
342
+ opts = { verbose: options[:verbose], output: options[:output],
343
+ format: :lit }
344
+ run_dispatcher(:extract, file, output_dir, **opts)
516
345
  end
517
346
 
518
- desc "lit-create OUTPUT FILES...", "Create LIT eBook file"
519
- option :compress, type: :boolean, default: true,
520
- desc: "Compress files with LZX"
347
+ desc "lit-info FILE",
348
+ "Show LIT file information (legacy, use: info --format lit FILE)"
521
349
  option :verbose, type: :boolean, aliases: "-v",
522
350
  desc: "Enable verbose output"
523
- def lit_create(output, *files)
524
- setup_verbose(options[:verbose])
525
-
526
- raise ArgumentError, "No files specified" if files.empty?
527
-
528
- files.each do |f|
529
- raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
530
- end
531
-
532
- compressor = LIT::Compressor.new
533
- files.each do |f|
534
- compressor.add_file(f, File.basename(f), compress: options[:compress])
535
- end
536
-
537
- puts "Creating #{output} with #{files.size} file(s)" if options[:verbose]
538
- bytes = compressor.generate(output)
539
- puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
540
- rescue Error => e
541
- abort "Error: #{e.message}"
542
- rescue NotImplementedError => e
543
- abort "Error: #{e.message}"
351
+ def lit_info(file)
352
+ run_with_format(:info, :lit, file, verbose: options[:verbose])
544
353
  end
545
354
 
546
- desc "lit-info FILE", "Show LIT file information"
355
+ desc "lit-create OUTPUT FILES...",
356
+ "Create LIT file (legacy, use: create --format lit OUTPUT FILES...)"
357
+ option :compress, type: :boolean, default: true,
358
+ desc: "Compress files with LZX"
359
+ option :language_id, type: :string,
360
+ desc: "Language ID (e.g., 0x409 for English)"
547
361
  option :verbose, type: :boolean, aliases: "-v",
548
362
  desc: "Enable verbose output"
549
- def lit_info(file)
550
- setup_verbose(options[:verbose])
551
-
552
- decompressor = LIT::Decompressor.new
553
- header = decompressor.open(file)
554
-
555
- puts "LIT File Information"
556
- puts "=" * 50
557
- puts "Filename: #{file}"
558
- puts "Version: #{header.version}"
559
- puts "Encrypted: #{header.encrypted? ? 'Yes (DES)' : 'No'}"
560
- puts "Files: #{header.files.size}"
561
- puts ""
562
- puts "Files:"
563
- header.files.each do |f|
564
- compression = f.compressed? ? "LZX" : "none"
565
- encryption = f.encrypted? ? " [encrypted]" : ""
566
- puts " #{f.filename}"
567
- puts " Size: #{f.length} bytes"
568
- puts " Compression: #{compression}#{encryption}"
569
- end
570
-
571
- decompressor.close(header)
572
- rescue Error => e
573
- abort "Error: #{e.message}"
574
- rescue NotImplementedError => e
575
- abort "Error: #{e.message}"
363
+ def lit_create(output, *files)
364
+ opts = {
365
+ verbose: options[:verbose],
366
+ compress: options[:compress],
367
+ format: :lit,
368
+ language_id: parse_language_id(options[:language_id]),
369
+ }
370
+ run_dispatcher(:create, output, files, **opts)
576
371
  end
577
372
 
578
- # OAB commands
579
- desc "oab-extract INPUT OUTPUT", "Extract OAB (Outlook Address Book) file"
373
+ # OAB legacy commands
374
+ desc "oab-extract INPUT OUTPUT",
375
+ "Extract OAB file (legacy, use: extract --format oab INPUT --output OUTPUT)"
580
376
  option :base, type: :string, desc: "Base file for incremental patch"
581
377
  option :verbose, type: :boolean, aliases: "-v",
582
378
  desc: "Enable verbose output"
583
379
  def oab_extract(input, output)
584
- setup_verbose(options[:verbose])
585
-
586
- decompressor = OAB::Decompressor.new
587
-
588
- if options[:base]
589
- puts "Applying patch: #{input} + #{options[:base]} -> #{output}" if options[:verbose]
590
- bytes = decompressor.decompress_incremental(input, options[:base],
591
- output)
592
- puts "Applied patch: #{input} + #{options[:base]} -> #{output} (#{bytes} bytes)"
593
- else
594
- puts "Extracting: #{input} -> #{output}" if options[:verbose]
595
- bytes = decompressor.decompress(input, output)
596
- puts "Extracted #{input} -> #{output} (#{bytes} bytes)"
597
- end
598
- rescue Error => e
599
- abort "Error: #{e.message}"
380
+ opts = {
381
+ verbose: options[:verbose],
382
+ output: output,
383
+ format: :oab,
384
+ base_file: options[:base],
385
+ }
386
+ run_dispatcher(:extract, input, nil, **opts)
600
387
  end
601
388
 
602
- desc "oab-create INPUT OUTPUT", "Create compressed OAB file"
603
- option :base, type: :string, desc: "Base file for incremental patch"
604
- option :block_size, type: :numeric, default: 32_768,
605
- desc: "Block size (default: 32KB)"
389
+ desc "oab-info FILE",
390
+ "Show OAB file information (legacy, use: info --format oab FILE)"
606
391
  option :verbose, type: :boolean, aliases: "-v",
607
392
  desc: "Enable verbose output"
608
- def oab_create(input, output)
609
- setup_verbose(options[:verbose])
610
-
611
- compressor = OAB::Compressor.new
612
-
613
- if options[:base]
614
- puts "Creating patch: #{input} (base: #{options[:base]}) -> #{output}" if options[:verbose]
615
- bytes = compressor.compress_incremental(input, options[:base], output,
616
- block_size: options[:block_size])
617
- puts "Created patch: #{output} (#{bytes} bytes)"
618
- else
619
- puts "Compressing: #{input} -> #{output}" if options[:verbose]
620
- bytes = compressor.compress(input, output,
621
- block_size: options[:block_size])
622
- puts "Created #{output} (#{bytes} bytes)"
623
- end
624
- rescue Error => e
625
- abort "Error: #{e.message}"
393
+ def oab_info(file)
394
+ run_with_format(:info, :oab, file, verbose: options[:verbose])
626
395
  end
627
396
 
628
- desc "oab-info FILE", "Show OAB file information"
397
+ desc "oab-create INPUT OUTPUT",
398
+ "Create OAB file (legacy, use: create --format oab OUTPUT INPUT)"
399
+ option :base, type: :string, desc: "Base file for incremental patch"
400
+ option :block_size, type: :numeric, default: 32_768, desc: "Block size"
629
401
  option :verbose, type: :boolean, aliases: "-v",
630
402
  desc: "Enable verbose output"
631
- def oab_info(file)
632
- setup_verbose(options[:verbose])
633
-
634
- # Read and parse header
635
- io_system = System::IOSystem.new
636
- handle = io_system.open(file, Constants::MODE_READ)
637
-
638
- begin
639
- header_data = io_system.read(handle, 28) # Read full patch header size
640
- io_system.close(handle)
641
-
642
- # Try to parse as full header first
643
- if header_data.length >= 16
644
- full_header = Binary::OABStructures::FullHeader.read(header_data[0,
645
- 16])
646
-
647
- if full_header.valid?
648
- puts "OAB File Information (Full)"
649
- puts "=" * 50
650
- puts "Filename: #{file}"
651
- puts "Version: #{full_header.version_hi}.#{full_header.version_lo}"
652
- puts "Block size: #{full_header.block_max} bytes"
653
- puts "Target size: #{full_header.target_size} bytes"
654
- elsif header_data.length >= 28
655
- # Try as patch header
656
- patch_header = Binary::OABStructures::PatchHeader.read(header_data)
657
-
658
- if patch_header.valid?
659
- puts "OAB File Information (Patch)"
660
- puts "=" * 50
661
- puts "Filename: #{file}"
662
- puts "Version: #{patch_header.version_hi}.#{patch_header.version_lo}"
663
- puts "Block size: #{patch_header.block_max} bytes"
664
- puts "Source size: #{patch_header.source_size} bytes"
665
- puts "Target size: #{patch_header.target_size} bytes"
666
- puts "Source CRC: 0x#{patch_header.source_crc.to_s(16)}"
667
- puts "Target CRC: 0x#{patch_header.target_crc.to_s(16)}"
668
- else
669
- abort "Error: Not a valid OAB file"
670
- end
671
- else
672
- abort "Error: Not a valid OAB file"
673
- end
674
- else
675
- abort "Error: File too small to be OAB"
676
- end
677
- rescue StandardError => e
678
- io_system.close(handle) if handle
679
- abort "Error: #{e.message}"
680
- end
681
- rescue Error => e
682
- abort "Error: #{e.message}"
403
+ def oab_create(input, output)
404
+ opts = {
405
+ verbose: options[:verbose],
406
+ format: :oab,
407
+ block_size: options[:block_size],
408
+ base_file: options[:base],
409
+ }
410
+ run_dispatcher(:create, output, [input], **opts)
683
411
  end
684
412
 
685
413
  desc "version", "Show version information"
@@ -689,88 +417,109 @@ module Cabriolet
689
417
 
690
418
  private
691
419
 
692
- def setup_verbose(verbose)
693
- Cabriolet.verbose = verbose
420
+ # Run command with unified dispatcher
421
+ #
422
+ # @param command [Symbol] Command to execute
423
+ # @param file [String] File path
424
+ # @param args [Array] Additional arguments
425
+ def run_dispatcher(command, file, *args, **options)
426
+ setup_verbose(options[:verbose])
427
+
428
+ dispatcher = Commands::CommandDispatcher.new(**options)
429
+ dispatcher.dispatch(command, file, *args, **options)
694
430
  end
695
431
 
696
- def display_cabinet_info(cabinet)
697
- puts "Cabinet Information"
698
- puts "=" * 50
699
- puts "Filename: #{cabinet.filename}"
700
- puts "Set ID: #{cabinet.set_id}"
701
- puts "Set Index: #{cabinet.set_index}"
702
- puts "Size: #{cabinet.length} bytes"
703
- puts "Folders: #{cabinet.folder_count}"
704
- puts "Files: #{cabinet.file_count}"
705
- puts ""
706
-
707
- puts "Folders:"
708
- cabinet.folders.each_with_index do |folder, idx|
709
- puts " [#{idx}] #{folder.compression_name} (#{folder.num_blocks} blocks)"
710
- end
711
- puts ""
712
-
713
- puts "Files:"
714
- cabinet.files.each do |f|
715
- puts " #{f.filename}"
716
- puts " Size: #{f.length} bytes"
717
- puts " Modified: #{f.modification_time}" if f.modification_time
718
- puts " Attributes: #{file_attributes(f)}"
719
- end
432
+ # Run command with explicit format override
433
+ #
434
+ # @param command [Symbol] Command to execute
435
+ # @param format [Symbol] Format to force
436
+ # @param file [String] File path
437
+ # @param args [Array] Additional arguments
438
+ def run_with_format(command, format, file, *args, **options)
439
+ setup_verbose(options[:verbose])
440
+ options[:format] = format.to_s
441
+
442
+ dispatcher = Commands::CommandDispatcher.new(**options)
443
+ dispatcher.dispatch(command, file, *args, **options)
720
444
  end
721
445
 
722
- def file_attributes(file)
723
- attrs = []
724
- attrs << "readonly" if file.readonly?
725
- attrs << "hidden" if file.hidden?
726
- attrs << "system" if file.system?
727
- attrs << "archive" if file.archived?
728
- attrs << "executable" if file.executable?
729
- attrs.empty? ? "none" : attrs.join(", ")
730
- end
731
-
732
- def display_chm_info(chm)
733
- puts "CHM File Information"
734
- puts "=" * 50
735
- puts "Filename: #{chm.filename}"
736
- puts "Version: #{chm.version}"
737
- puts "Language ID: #{chm.language}"
738
- puts "Timestamp: #{chm.timestamp}"
739
- puts "Size: #{chm.length} bytes"
740
- puts ""
741
- puts "Directory:"
742
- puts " Offset: #{chm.dir_offset}"
743
- puts " Chunks: #{chm.num_chunks}"
744
- puts " Chunk Size: #{chm.chunk_size}"
745
- puts " First PMGL: #{chm.first_pmgl}"
746
- puts " Last PMGL: #{chm.last_pmgl}"
747
- puts ""
748
- puts "Sections:"
749
- puts " Section 0 (Uncompressed): offset #{chm.sec0.offset}"
750
- puts " Section 1 (MSCompressed): LZX compression"
751
- puts ""
752
-
753
- regular_files = chm.all_files
754
- system_files = chm.all_sysfiles
755
-
756
- puts "Files: #{regular_files.length} regular, #{system_files.length} system"
757
- puts ""
758
- puts "Regular Files:"
759
- regular_files.each do |f|
760
- section_name = f.section.id.zero? ? "Sec0" : "Sec1"
761
- puts " #{f.filename}"
762
- puts " Size: #{f.length} bytes (#{section_name})"
446
+ # Detect format from output file extension
447
+ #
448
+ # @param output [String] Output file path
449
+ # @param manual_format [String, nil] Manually specified format
450
+ # @return [Symbol] Detected format symbol
451
+ def detect_format_from_output(output, manual_format)
452
+ return manual_format.to_sym if manual_format
453
+
454
+ ext = File.extname(output).downcase
455
+ format_map = {
456
+ ".cab" => :cab,
457
+ ".chm" => :chm,
458
+ ".hlp" => :hlp,
459
+ ".lit" => :lit,
460
+ ".oab" => :oab,
461
+ "._" => :szdd, # SZDD ends with underscore
462
+ ".kwj" => :kwaj,
463
+ }
464
+
465
+ # Handle SZDD specially (ends with _)
466
+ if output.end_with?("_")
467
+ return :szdd
763
468
  end
764
469
 
765
- return unless system_files.any?
470
+ format_map[ext] || :cab # Default to CAB
471
+ end
766
472
 
767
- puts ""
768
- puts "System Files:"
769
- system_files.each do |f|
770
- section_name = f.section.id.zero? ? "Sec0" : "Sec1"
771
- puts " #{f.filename}"
772
- puts " Size: #{f.length} bytes (#{section_name})"
473
+ # Normalize create options for different formats
474
+ #
475
+ # @param options [Hash] Raw options from Thor
476
+ # @return [Hash] Normalized options
477
+ def normalize_create_options(options)
478
+ normalized = {}
479
+ options.each do |key, value|
480
+ next if value.nil?
481
+
482
+ case key.to_s
483
+ when "szdd_format"
484
+ normalized[:szdd_format] = value.to_sym
485
+ when "kwaj_compression"
486
+ normalized[:compression] = value
487
+ when "kwaj_filename"
488
+ normalized[:filename] = value
489
+ when "hlp_format"
490
+ normalized[:hlp_format] = value.to_sym
491
+ when "language_id"
492
+ normalized[:language_id] = parse_language_id(value)
493
+ when "lit_version"
494
+ normalized[:version] = value
495
+ when "compress"
496
+ # Keep as-is for HLP/LIT
497
+ normalized[:compress] = value
498
+ else
499
+ normalized[key.to_sym] = value
500
+ end
773
501
  end
502
+ normalized
503
+ end
504
+
505
+ # Parse language ID from string
506
+ #
507
+ # @param value [String, Integer, nil] Language ID value
508
+ # @return [Integer] Parsed language ID
509
+ def parse_language_id(value)
510
+ return 0x409 if value.nil? # Default to English
511
+
512
+ if value.is_a?(Integer)
513
+ value
514
+ elsif value.start_with?("0x")
515
+ value.to_i(16)
516
+ else
517
+ value.to_i
518
+ end
519
+ end
520
+
521
+ def setup_verbose(verbose)
522
+ Cabriolet.verbose = verbose
774
523
  end
775
524
  end
776
525
  end