omnizip 0.3.50 → 0.3.52

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6aa49d6ab2a370eda775a80206da80220936025ece39c3e121471815b309adbd
4
- data.tar.gz: 60ab5eb1c7bcd3243f71aed658a85e84b2b37a02d061bf09156f0dd82c18d536
3
+ metadata.gz: 8002bf03f99715929f352dfb92a91a10d02f6dac7152006a6cb87ca20b3647ae
4
+ data.tar.gz: b095258b0b0af358ae9c0a21482a6f8b29de1620bb2162d64dd8794f29eb8f59
5
5
  SHA512:
6
- metadata.gz: 8e4cc8c3b167c07112ad5f45f3a870eb24b8ffd946cc7f9fe6c4b10bfe376e7efc5a0c975287027646d9bc7aef3f419e67eb1bd40e38824eb3e93219a3f78603
7
- data.tar.gz: 7fe55f7e975c40eff0c2109167a0e23fa99f11949489a29f7555632a340b3ea16983091f09bfa6cd948e3351501fd66fb438d3ad90a40953ef514721fc0138f0
6
+ metadata.gz: 527be0108ee0fa63b6e7ebafdeaafd05430a17c08e0d59b0f731db7a3b76401da96daec205947c7c3b5baa07aba886515959513ef3d4b31438889761219e958d
7
+ data.tar.gz: e1f8665f545fc81a4459cbdffbb9e5d5aca219a177a5b2d2ac3bd47c589a2ceb11b02d8cfdc748ac39381417f12682bf53cf14105dcbcd3d04bd59b77d7f9e87
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.51] - 2026-09-01
11
+
12
+ ### Changed
13
+ - Profile resolution lives in one place: `Omnizip::Profile` gains
14
+ `resolve`, `apply`, and `first_file_among`, and the convenience
15
+ methods plus the `archive create` command collapse onto them. The
16
+ two prior copies had drifted — the command only accepted `"auto"`
17
+ strings and could not pass a `CompressionProfile` through.
18
+ - Both direct converter strategies (ZIP↔7z) now extract through
19
+ `Omnizip.extract_archive` and write through a shared
20
+ `ConversionStrategy#repack_tree` on the Archive facade — they were
21
+ the last library code instantiating format readers/writers
22
+ directly, and the 7z→ZIP path required `#open` against the
23
+ reader-always-usable invariant.
24
+ - `create_rar` routes through the RAR handler (unrar-verified) with
25
+ the generic writer block interface; its stale "requires RAR
26
+ license" docstring is gone. `decompress_file`'s three-arm case
27
+ with magic `:xz`/`:zstandard` markers becomes a
28
+ `SINGLE_FILE_DECOMPRESSORS` table mirroring the compressor table.
29
+
30
+ ### Fixed
31
+ - macOS CI failed at setup from 2026-09-01: Homebrew disabled the
32
+ `rar` cask (Gatekeeper check), breaking `brew install rar`. The
33
+ workflow now installs it separately and non-fatally, like the
34
+ Windows branch already did for winrar; RAR interop specs skip
35
+ when unrar is absent.
36
+
37
+ ## [0.3.50] - 2026-08-31
38
+
39
+ ### Added
40
+ - The conversion matrix is closed: `Omnizip::Converter.convert` now
41
+ converts any routed source format (ZIP, 7z, TAR, RAR, cpio, ISO,
42
+ RPM, XAR, MSI) into any writable target format (ZIP, 7z, TAR,
43
+ RAR) through a new extract-and-repack strategy built on the
44
+ handler registry. The documented RAR→ZIP and TAR→7z pairs
45
+ previously had no strategy behind them — `Converter.supported?`
46
+ answered false for conversions the README promised. The direct
47
+ ZIP↔7z strategies keep precedence, so entry-copying conversions
48
+ don't take the repack detour.
49
+
10
50
  ## [0.3.49] - 2026-08-31
11
51
 
12
52
  ### Added
@@ -31,7 +31,7 @@ module Omnizip
31
31
  entries.map do |e|
32
32
  { name: e.name, size: e.size, directory: e.is_dir,
33
33
  compressed_size: (e.compressed_size if e.compressed_size&.positive?),
34
- mtime: e.mtime }
34
+ mtime: e.mtime, crc: e.crc }
35
35
  end
36
36
  else
37
37
  entries.map(&:name)
@@ -44,19 +44,27 @@ module Omnizip
44
44
  ].join("\n")
45
45
  end
46
46
 
47
- # Format file size in human-readable format.
47
+ # Format file size in human-readable format. This is THE byte
48
+ # formatter — commands, the CLI, and display-oriented models
49
+ # delegate here instead of carrying private copies.
48
50
  #
49
- # @param bytes [Integer] Size in bytes
50
- # @return [String] Formatted size
51
+ # @param bytes [Integer, nil] Size in bytes
52
+ # @return [String] Formatted size ("512 B", "1.5 KB", "2.0 MB")
51
53
  def format_size(bytes)
54
+ return "0 B" if bytes.nil? || bytes.zero?
55
+
52
56
  units = %w[B KB MB GB TB]
53
- return "0 B" if bytes.zero?
57
+ size = bytes.to_f
58
+ unit_index = 0
59
+
60
+ while size >= 1024.0 && unit_index < units.size - 1
61
+ size /= 1024.0
62
+ unit_index += 1
63
+ end
54
64
 
55
- exp = (Math.log(bytes) / Math.log(1024)).floor
56
- exp = [exp, units.length - 1].min
65
+ return format("%d B", bytes) if unit_index.zero?
57
66
 
58
- size = (bytes.to_f / (1024**exp)).round(2)
59
- "#{size} #{units[exp]}"
67
+ format("%.1f %s", size, units[unit_index])
60
68
  end
61
69
 
62
70
  # Format error message for display.
data/lib/omnizip/cli.rb CHANGED
@@ -60,13 +60,7 @@ module Omnizip
60
60
  # @param bytes [Integer] the byte count
61
61
  # @return [String] human-readable size (e.g. "1.5 MB")
62
62
  def format_bytes(bytes)
63
- return "0 B" if bytes.zero?
64
-
65
- units = %w[B KB MB GB TB]
66
- exp = (Math.log(bytes) / Math.log(1024)).to_i
67
- exp = [exp, units.size - 1].min
68
-
69
- "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
63
+ Omnizip::CliOutputFormatter.format_size(bytes)
70
64
  end
71
65
  end
72
66
 
@@ -47,11 +47,7 @@ module Omnizip
47
47
  validate_inputs(output_file, inputs)
48
48
 
49
49
  # Apply profile settings if specified
50
- opts = options.dup
51
- if opts[:profile]
52
- first_file = find_first_file(inputs)
53
- opts = apply_profile(first_file, opts)
54
- end
50
+ opts = Omnizip::Profile.apply(options, paths: inputs)
55
51
 
56
52
  format = (opts[:format] || detect_format(output_file)).to_sym
57
53
  handler_opts = handler_options(format, opts)
@@ -205,49 +201,7 @@ module Omnizip
205
201
  end
206
202
 
207
203
  def format_bytes(bytes)
208
- units = %w[B KB MB GB]
209
- size = bytes.to_f
210
- unit_idx = 0
211
-
212
- while size >= 1024 && unit_idx < units.length - 1
213
- size /= 1024.0
214
- unit_idx += 1
215
- end
216
-
217
- format("%.2f %s", size, units[unit_idx])
218
- end
219
-
220
- def apply_profile(file_path, options)
221
- profile_spec = options.delete(:profile)
222
- return options unless profile_spec
223
-
224
- # Get the profile
225
- profile = case profile_spec
226
- when "auto"
227
- file_path ? Omnizip::Profile.detect(file_path) : Omnizip::Profile.get(:balanced)
228
- else
229
- Omnizip::Profile.get(profile_spec.to_sym) || Omnizip::Profile.get(:balanced)
230
- end
231
-
232
- # Apply profile to options
233
- profile.apply_to(options)
234
- end
235
-
236
- def find_first_file(inputs)
237
- inputs.each do |input|
238
- return input if File.file?(input)
239
-
240
- # Check directories for first file
241
- if File.directory?(input)
242
- Dir.foreach(input) do |entry|
243
- next if [".", ".."].include?(entry)
244
-
245
- full_path = File.join(input, entry)
246
- return full_path if File.file?(full_path)
247
- end
248
- end
249
- end
250
- nil
204
+ Omnizip::CliOutputFormatter.format_size(bytes)
251
205
  end
252
206
 
253
207
  def parse_volume_size(size_str)
@@ -135,17 +135,10 @@ module Omnizip
135
135
  end
136
136
 
137
137
  def extract_with_patterns(archive_file, output_dir, verbose)
138
- # Determine archive type and open appropriately
139
- archive = case File.extname(archive_file).downcase
140
- when ".zip"
141
- Omnizip::Zip::File.open(archive_file)
142
- when ".rar"
143
- Formats::Rar::Reader.new(archive_file).open
144
- when ".tar"
145
- Formats::Tar::Reader.new(archive_file).read
146
- else
147
- Formats::SevenZip::Reader.new(archive_file).open
148
- end
138
+ # Route through the facade: the extension routes pick each
139
+ # format's reader, and the reader session holds no open
140
+ # resources to close
141
+ archive = Omnizip::Archive.open(archive_file)
149
142
 
150
143
  # Build filter chain
151
144
  filter = Extraction::FilterChain.new
@@ -193,8 +186,6 @@ module Omnizip
193
186
  end
194
187
 
195
188
  extracted.size
196
- ensure
197
- archive.close if archive.is_a?(Omnizip::Zip::File)
198
189
  end
199
190
 
200
191
  def extract_gzip(archive_file, output_dir, verbose)
@@ -157,24 +157,7 @@ module Omnizip
157
157
  end
158
158
 
159
159
  def format_bytes(bytes)
160
- return "0 B" if bytes.zero?
161
-
162
- units = %w[B KB MB GB]
163
- size = bytes.to_f
164
- unit_idx = 0
165
-
166
- while size >= 1024 && unit_idx < units.length - 1
167
- size /= 1024.0
168
- unit_idx += 1
169
- end
170
-
171
- if size < 10
172
- format("%.2f %s", size, units[unit_idx])
173
- elsif size < 100
174
- format("%.1f %s", size, units[unit_idx])
175
- else
176
- format("%.0f %s", size, units[unit_idx])
177
- end
160
+ Omnizip::CliOutputFormatter.format_size(bytes)
178
161
  end
179
162
 
180
163
  # Helper methods to handle different entry types. Each delegates
@@ -54,7 +54,8 @@ module Omnizip
54
54
  %i[comment set_mtime chmod set_attribute].none? { |key| options[key] }
55
55
  end
56
56
 
57
- # Read-only view for 7z archives; the edit path is zip-only
57
+ # Read-only view for 7z archives through the handler registry;
58
+ # the edit path is zip-only
58
59
  def show_seven_zip_metadata(archive_path, pattern)
59
60
  if options[:comment] || options[:set_mtime] ||
60
61
  options[:chmod] || options[:set_attribute]
@@ -62,37 +63,37 @@ module Omnizip
62
63
  "Metadata editing is only supported for ZIP archives"
63
64
  end
64
65
 
65
- reader = Omnizip::Formats::SevenZip::Reader.new(archive_path)
66
- reader.open
66
+ entries = Omnizip.list_archive(archive_path, details: true)
67
67
 
68
68
  if pattern
69
- entries = reader.list_files.select do |e|
70
- File.fnmatch(pattern, e.name)
69
+ entries = entries.select do |entry|
70
+ File.fnmatch(pattern, entry[:name])
71
71
  end
72
72
  if entries.empty?
73
73
  warn "No entries match pattern: #{pattern}"
74
74
  return
75
75
  end
76
76
 
77
- entries.each do |entry|
78
- puts "Entry: #{entry.name}"
79
- puts " Type: #{entry.is_dir ? 'Directory' : 'File'}"
80
- puts " Size: #{format_size(entry.size)}"
81
- # Per-entry packed sizes are not tracked by the parser
82
- if entry.compressed_size&.positive?
83
- puts " Compressed: #{format_size(entry.compressed_size)}"
84
- end
85
- puts " Modified: #{entry.mtime}" if entry.mtime
86
- puts " CRC32: 0x#{entry.crc.to_s(16).upcase}" if entry.crc
87
- end
77
+ entries.each { |entry| show_seven_zip_entry(entry) }
88
78
  else
89
- entries = reader.list_files
90
- files = entries.reject(&:is_dir)
79
+ files = entries.reject { |entry| entry[:directory] }
91
80
  puts "Archive: #{archive_path}"
92
81
  puts "Entries: #{entries.size} " \
93
82
  "(#{files.size} files, #{entries.size - files.size} dirs)"
94
- puts "Total size: #{format_size(files.sum(&:size))}"
83
+ puts "Total size: #{format_size(files.sum { |entry| entry[:size] })}"
84
+ end
85
+ end
86
+
87
+ def show_seven_zip_entry(entry)
88
+ puts "Entry: #{entry[:name]}"
89
+ puts " Type: #{entry[:directory] ? 'Directory' : 'File'}"
90
+ puts " Size: #{format_size(entry[:size])}"
91
+ # Per-entry packed sizes are not tracked by the parser
92
+ if entry[:compressed_size]&.positive?
93
+ puts " Compressed: #{format_size(entry[:compressed_size])}"
95
94
  end
95
+ puts " Modified: #{entry[:mtime]}" if entry[:mtime]
96
+ puts " CRC32: 0x#{entry[:crc].to_s(16).upcase}" if entry[:crc]
96
97
  end
97
98
 
98
99
  def show_metadata(archive, pattern)
@@ -199,13 +200,7 @@ module Omnizip
199
200
  end
200
201
 
201
202
  def format_size(bytes)
202
- return "0 B" if bytes.zero?
203
-
204
- units = %w[B KB MB GB TB]
205
- exp = (Math.log(bytes) / Math.log(1024)).to_i
206
- exp = [exp, units.size - 1].min
207
-
208
- "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
203
+ Omnizip::CliOutputFormatter.format_size(bytes)
209
204
  end
210
205
  end
211
206
  end
@@ -107,13 +107,7 @@ module Omnizip
107
107
  # @param bytes [Integer] Size in bytes
108
108
  # @return [String] Formatted size
109
109
  def format_size(bytes)
110
- if bytes < 1024
111
- "#{bytes} B"
112
- elsif bytes < 1024 * 1024
113
- "#{(bytes / 1024.0).round(1)} KB"
114
- else
115
- "#{(bytes / (1024.0 * 1024)).round(1)} MB"
116
- end
110
+ Omnizip::CliOutputFormatter.format_size(bytes)
117
111
  end
118
112
  end
119
113
  end
@@ -25,7 +25,7 @@ module Omnizip
25
25
  raise ArgumentError, "Input is a directory: #{input_path}"
26
26
  end
27
27
 
28
- options = apply_profile(input_path, options) if options[:profile]
28
+ options = Omnizip::Profile.apply(options, paths: input_path)
29
29
 
30
30
  if options[:chunked]
31
31
  return Omnizip::Chunked.compress_file(input_path, output_path, **options)
@@ -57,30 +57,14 @@ module Omnizip
57
57
  def decompress_file(compressed_path, output_path)
58
58
  require_archive!(compressed_path)
59
59
 
60
- format_class = DECOMPRESSORS[::File.extname(compressed_path).downcase]
61
- unless format_class
60
+ decompressor = SINGLE_FILE_DECOMPRESSORS[::File.extname(compressed_path).downcase]
61
+ unless decompressor
62
62
  raise Omnizip::UnsupportedFormatError,
63
- "decompress_file supports #{DECOMPRESSORS.keys.join(', ')}; " \
63
+ "decompress_file supports #{SINGLE_FILE_DECOMPRESSORS.keys.join(', ')}; " \
64
64
  "use extract_archive for archive formats"
65
65
  end
66
66
 
67
- case format_class
68
- when :xz
69
- ::File.binwrite(output_path, Formats::Xz.decompress(compressed_path))
70
- when :zstandard
71
- zstd = Algorithms::Zstandard.new
72
- ::File.open(compressed_path, "rb") do |input_io|
73
- ::File.open(output_path, "wb") do |output_io|
74
- zstd.decompress(input_io, output_io)
75
- end
76
- end
77
- else
78
- ::File.open(compressed_path, "rb") do |input_io|
79
- ::File.open(output_path, "wb") do |output_io|
80
- format_class.decompress_stream(input_io, output_io)
81
- end
82
- end
83
- end
67
+ decompressor.call(compressed_path, output_path)
84
68
  output_path
85
69
  end
86
70
 
@@ -112,10 +96,7 @@ module Omnizip
112
96
  "individually"
113
97
  end
114
98
 
115
- if options[:profile]
116
- first_file = find_first_file(input_dir)
117
- apply_profile(first_file, options)
118
- end
99
+ options = Omnizip::Profile.apply(options, paths: input_dir)
119
100
 
120
101
  Omnizip::ArchiveHandler.for(resolve_archive_format(output_path, format)).create(output_path, **options) do |archive|
121
102
  add_directory_contents(archive, input_dir, "", recursive: recursive)
@@ -212,11 +193,17 @@ module Omnizip
212
193
  archive_path
213
194
  end
214
195
 
215
- # Create a RAR archive (requires RAR license see NotLicensedError).
196
+ # Create a RAR archive through the RAR handler. RAR5 unless
197
+ # +version: 4+; the block yields the same generic writer
198
+ # interface as +Archive.create+ (+add+/+add_data+/+add_directory+).
199
+ #
200
+ # @param archive_path [String] Path to output archive
201
+ # @param options [Hash] RAR writer options
202
+ # @return [String, Array<String>] Path(s) of the created archive
216
203
  # rubocop:disable-next Naming/BlockForwarding, Style/ArgumentsForwarding -- Ruby 3.0 compatibility
217
204
  def create_rar(archive_path, **options, &block)
218
205
  options[:version] ||= 5
219
- Omnizip::Formats::Rar.create(archive_path, options, &block)
206
+ Omnizip::ArchiveHandler.for(:rar).create(archive_path, **options, &block)
220
207
  end
221
208
 
222
209
  # Extension -> single-file compressor map. Each lambda takes
@@ -249,18 +236,50 @@ module Omnizip
249
236
  ".msi" => :ole,
250
237
  }.freeze
251
238
 
252
- # Extension -> single-file decompressor (stream interface).
253
- # The Gzip/Bzip2File/Xz classes take path or stream; use the
254
- # ones exposing decompress_stream for symmetry.
255
- # Xz exposes a path-based decompress rather than a stream one,
256
- # so it is marked specially here.
257
- DECOMPRESSORS = {
258
- ".gz" => Formats::Gzip,
259
- ".bz2" => Formats::Bzip2File,
260
- ".xz" => :xz,
261
- ".lzma" => Formats::LzmaAlone,
262
- ".lz" => Formats::Lzip,
263
- ".zst" => :zstandard,
239
+ # Extension -> single-file decompressor. Each lambda takes
240
+ # (compressed_path, output_path) and writes the decompressed
241
+ # bytes; these formats are streams, not archives, so they bypass
242
+ # ArchiveHandler (mirrors SINGLE_FILE_COMPRESSORS). Xz exposes a
243
+ # path-based decompress rather than a stream one.
244
+ SINGLE_FILE_DECOMPRESSORS = {
245
+ ".gz" => lambda do |compressed, output|
246
+ ::File.open(compressed, "rb") do |input_io|
247
+ ::File.open(output, "wb") do |output_io|
248
+ Omnizip::Formats::Gzip.decompress_stream(input_io, output_io)
249
+ end
250
+ end
251
+ end,
252
+ ".bz2" => lambda do |compressed, output|
253
+ ::File.open(compressed, "rb") do |input_io|
254
+ ::File.open(output, "wb") do |output_io|
255
+ Omnizip::Formats::Bzip2File.decompress_stream(input_io, output_io)
256
+ end
257
+ end
258
+ end,
259
+ ".xz" => lambda do |compressed, output|
260
+ ::File.binwrite(output, Omnizip::Formats::Xz.decompress(compressed))
261
+ end,
262
+ ".lzma" => lambda do |compressed, output|
263
+ ::File.open(compressed, "rb") do |input_io|
264
+ ::File.open(output, "wb") do |output_io|
265
+ Omnizip::Formats::LzmaAlone.decompress_stream(input_io, output_io)
266
+ end
267
+ end
268
+ end,
269
+ ".lz" => lambda do |compressed, output|
270
+ ::File.open(compressed, "rb") do |input_io|
271
+ ::File.open(output, "wb") do |output_io|
272
+ Omnizip::Formats::Lzip.decompress_stream(input_io, output_io)
273
+ end
274
+ end
275
+ end,
276
+ ".zst" => lambda do |compressed, output|
277
+ ::File.open(compressed, "rb") do |input_io|
278
+ ::File.open(output, "wb") do |output_io|
279
+ Omnizip::Algorithms::Zstandard.new.decompress(input_io, output_io)
280
+ end
281
+ end
282
+ end,
264
283
  }.freeze
265
284
 
266
285
  SINGLE_FILE_COMPRESSORS = {
@@ -335,40 +354,6 @@ module Omnizip
335
354
  raise Errno::ENOENT, "Archive not found: #{archive_path}"
336
355
  end
337
356
 
338
- # Apply compression profile to options.
339
- def apply_profile(file_path, options)
340
- profile_spec = options.delete(:profile)
341
- return options unless profile_spec
342
-
343
- profile = case profile_spec
344
- when :auto
345
- file_path ? Omnizip::Profile.detect(file_path) : Omnizip::Profile.get(:balanced)
346
- when Symbol
347
- Omnizip::Profile.get(profile_spec) || Omnizip::Profile.get(:balanced)
348
- when Omnizip::Profile::CompressionProfile
349
- profile_spec
350
- else
351
- Omnizip::Profile.get(:balanced)
352
- end
353
-
354
- profile.apply_to(options)
355
- end
356
-
357
- def find_first_file(dir_path)
358
- Dir.foreach(dir_path) do |entry|
359
- next if [".", ".."].include?(entry)
360
-
361
- full_path = ::File.join(dir_path, entry)
362
- return full_path if ::File.file?(full_path)
363
-
364
- if ::File.directory?(full_path)
365
- result = find_first_file(full_path)
366
- return result if result
367
- end
368
- end
369
- nil
370
- end
371
-
372
357
  # Recursively add directory contents to an archive. The +archive+
373
358
  # argument is whatever the handler's +#create+ block yields (e.g.
374
359
  # +Omnizip::Zip::File+ or +Omnizip::Formats::Tar::Writer+) and must
@@ -60,19 +60,26 @@ module Omnizip
60
60
  @warnings
61
61
  end
62
62
 
63
- # Detect format from file extension
64
- # @param path [String] File path
65
- # @return [Symbol] Format
66
- def detect_format(path)
67
- ext = File.extname(path).downcase
68
- case ext
69
- when ".zip"
70
- :zip
71
- when ".7z"
72
- :seven_zip
73
- else
74
- raise ArgumentError, "Unknown format for file: #{path}"
63
+ # Repack the extracted tree under +extracted_dir+ into a new
64
+ # archive at +target_path+ through the Archive facade (the
65
+ # format is resolved from the target's extension). Directory
66
+ # entries are implied by file paths, matching every strategy's
67
+ # treatment. Returns the number of file entries written.
68
+ #
69
+ # @param extracted_dir [String] Directory of extracted entries
70
+ # @param write_options [Hash] Format writer options
71
+ # @return [Integer] Number of file entries written
72
+ def repack_tree(extracted_dir, **write_options)
73
+ count = 0
74
+ Omnizip::Archive.create(target_path, **write_options) do |archive|
75
+ Dir.glob(File.join(extracted_dir, "**", "*")).each do |path|
76
+ next if File.directory?(path)
77
+
78
+ archive.add_file(path, path.delete_prefix("#{extracted_dir}/"))
79
+ count += 1
80
+ end
75
81
  end
82
+ count
76
83
  end
77
84
 
78
85
  # Create conversion result
@@ -96,24 +103,6 @@ module Omnizip
96
103
  warnings: warnings,
97
104
  )
98
105
  end
99
-
100
- # Check if metadata is compatible between formats
101
- # @param entry [Entry] Entry to check
102
- # @return [Boolean] True if fully compatible
103
- def metadata_compatible?(_entry)
104
- # ZIP supports most metadata
105
- # 7z has limited metadata support
106
- case [source_format, target_format]
107
- when %i[zip seven_zip]
108
- # Some metadata loss (comments, extra fields)
109
- false
110
- when %i[seven_zip zip]
111
- # Can preserve most 7z metadata in ZIP
112
- true
113
- else
114
- true
115
- end
116
- end
117
106
  end
118
107
  end
119
108
  end
@@ -34,18 +34,8 @@ module Omnizip
34
34
 
35
35
  Dir.mktmpdir("omnizip_convert_repack") do |tmp|
36
36
  extracted = Omnizip.extract_archive(source_path, tmp)
37
-
38
- Omnizip::Archive.create(target_path,
39
- format: self.class.target_format_for(target_path)) do |b|
40
- Dir.glob(File.join(tmp, "**", "*")).each do |path|
41
- next if File.directory?(path)
42
-
43
- b.add_file(path, path.delete_prefix("#{tmp}/"))
44
- end
45
- end
46
-
47
- entry_count = extracted.size
48
- create_result(start_time, entry_count)
37
+ repack_tree(tmp)
38
+ create_result(start_time, extracted.size)
49
39
  end
50
40
  end
51
41
 
@@ -11,20 +11,9 @@ module Omnizip
11
11
  def convert
12
12
  start_time = Time.now
13
13
 
14
- reader = Omnizip::Formats::SevenZip::Reader.new(source_path)
15
- reader.open
16
- entry_count = reader.list_files.size
17
-
18
- Dir.mktmpdir("omnizip_convert") do |tmp|
19
- reader.extract_all(tmp)
20
-
21
- writer = Omnizip::Formats::Zip::Writer.new(target_path)
22
- Dir.glob(File.join(tmp, "**", "*")).each do |path|
23
- next if File.directory?(path)
24
-
25
- writer.add_file(path, path.delete_prefix("#{tmp}/"))
26
- end
27
- writer.write
14
+ entry_count = Dir.mktmpdir("omnizip_convert") do |tmp|
15
+ Omnizip.extract_archive(source_path, tmp)
16
+ repack_tree(tmp)
28
17
  end
29
18
 
30
19
  create_result(start_time, entry_count)
@@ -10,22 +10,11 @@ module Omnizip
10
10
  # @return [ConversionResult] Conversion result
11
11
  def convert
12
12
  start_time = Time.now
13
- entry_count = 0
14
13
 
15
- Dir.mktmpdir("omnizip_convert_zip") do |tmp|
16
- # Extract the ZIP through the native reader; directories are
17
- # implied by extracted file paths
18
- Omnizip::Formats::Zip.extract(source_path, tmp)
19
-
20
- writer = Omnizip::Formats::SevenZip::Writer.new(target_path,
21
- writer_options)
22
- Dir.glob(File.join(tmp, "**", "*")).each do |path|
23
- next if File.directory?(path)
24
-
25
- entry_count += 1
26
- writer.add_file(path, path.delete_prefix("#{tmp}/"))
27
- end
28
- writer.write
14
+ entry_count = Dir.mktmpdir("omnizip_convert_zip") do |tmp|
15
+ # Directories are implied by extracted file paths
16
+ Omnizip.extract_archive(source_path, tmp)
17
+ repack_tree(tmp, **writer_options)
29
18
  end
30
19
 
31
20
  create_result(start_time, entry_count)
@@ -33,7 +33,10 @@ module Omnizip
33
33
  FileUtils.mkdir_p(dest)
34
34
  extracted = []
35
35
 
36
- entries_to_extract = list_matches
36
+ # Directory entries carry no content to extract
37
+ entries_to_extract = list_matches.reject do |entry|
38
+ entry_filename(entry).end_with?("/")
39
+ end
37
40
  total = entries_to_extract.size
38
41
  current = 0
39
42
 
@@ -162,7 +165,7 @@ module Omnizip
162
165
  entry.get_input_stream.read
163
166
  # allowed: some archives read entry content from the archive
164
167
  elsif @archive.respond_to?(:read)
165
- @archive.read(entry)
168
+ @archive.read(entry_filename(entry))
166
169
  else
167
170
  raise Error, "Cannot read entry content"
168
171
  end
@@ -191,10 +194,19 @@ module Omnizip
191
194
 
192
195
  # Get filename from entry
193
196
  #
194
- # @param entry [#entry_name] Entry (Omnizip::Entry or compatible)
197
+ # @param entry [#entry_name, #name] Entry (legacy Omnizip::Entry,
198
+ # Archive::Entry from a reader session, or compatible)
195
199
  # @return [String] Filename
196
200
  def entry_filename(entry)
197
- entry.entry_name || entry.to_s
201
+ # allowed: entries come from a caller-supplied archive
202
+ if entry.respond_to?(:entry_name)
203
+ entry.entry_name
204
+ # allowed: reader-session entries expose name, not entry_name
205
+ elsif entry.respond_to?(:name)
206
+ entry.name
207
+ else
208
+ entry.to_s
209
+ end
198
210
  end
199
211
 
200
212
  # Update progress tracker
@@ -122,13 +122,7 @@ module Omnizip
122
122
  end
123
123
 
124
124
  def format_size(bytes)
125
- return "0 B" if bytes.zero?
126
-
127
- units = %w[B KB MB GB TB]
128
- exp = (Math.log(bytes) / Math.log(1024)).to_i
129
- exp = [exp, units.size - 1].min
130
-
131
- "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
125
+ Omnizip::CliOutputFormatter.format_size(bytes)
132
126
  end
133
127
  end
134
128
  end
@@ -161,40 +161,6 @@ progress: nil)
161
161
  Digest::MD5.digest("#{Time.now.to_f}#{rand}")
162
162
  end
163
163
 
164
- # Analyze file and calculate hashes
165
- #
166
- # @param file_path [String] Path to file
167
- # @return [FileInfo] File information
168
- def analyze_file(file_path)
169
- File.open(file_path, "rb") do |io|
170
- file_size = io.size
171
-
172
- # Calculate hash of first 16KB
173
- first_16k = io.read(16384) || ""
174
- hash_16k = Digest::MD5.digest(first_16k)
175
-
176
- # Calculate full file hash
177
- io.rewind
178
- hash_full = Digest::MD5.file(file_path).digest
179
-
180
- # Generate file ID
181
- file_id = Digest::MD5.digest("#{File.basename(file_path)}#{file_size}")
182
-
183
- # Read file blocks
184
- io.rewind
185
- blocks = read_file_blocks(io)
186
-
187
- FileInfo.new(
188
- path: file_path,
189
- file_id: file_id,
190
- hash_16k: hash_16k,
191
- hash_full: hash_full,
192
- size: file_size,
193
- blocks: blocks,
194
- )
195
- end
196
- end
197
-
198
164
  # Read file data into blocks
199
165
  #
200
166
  # @param io [IO] File IO object
@@ -69,6 +69,11 @@ module Omnizip
69
69
  # @param par2_file [String] Path to .par2 index file
70
70
  # @raise [ArgumentError] if file doesn't exist
71
71
  def initialize(par2_file)
72
+ if par2_file.nil?
73
+ raise ArgumentError,
74
+ "PAR2 file is nil — no index .par2 was produced for this set"
75
+ end
76
+
72
77
  raise ArgumentError, "PAR2 file not found: #{par2_file}" unless
73
78
  File.exist?(par2_file)
74
79
 
@@ -108,6 +108,62 @@ module Omnizip
108
108
  detector.detect(file_path, options)
109
109
  end
110
110
 
111
+ # Resolve a profile specification into a concrete profile.
112
+ # +:auto+ (or "auto") samples +first_file+ through detection and
113
+ # falls back to +:balanced+ without one; a CompressionProfile
114
+ # passes through unchanged; anything else names a registered
115
+ # profile, falling back to +:balanced+ when unknown.
116
+ #
117
+ # @param spec [Symbol, String, CompressionProfile] Profile spec
118
+ # @param first_file [String, nil] Sampling file for :auto
119
+ # @return [CompressionProfile] Resolved profile
120
+ def resolve(spec, first_file: nil)
121
+ case spec
122
+ when :auto, "auto"
123
+ first_file ? detect(first_file) : registry.get(:balanced)
124
+ when CompressionProfile
125
+ spec
126
+ else
127
+ registry.get(spec.to_sym) || registry.get(:balanced)
128
+ end
129
+ end
130
+
131
+ # Resolve and apply the +:profile+ entry of +options+ into
132
+ # concrete compression settings. +:auto+ profiles sample the
133
+ # first readable file at or below +paths+. Returns a new
134
+ # options hash; +options+ itself is left untouched.
135
+ #
136
+ # @param options [Hash] Compression options carrying :profile
137
+ # @param paths [String, Array<String>] Inputs to sample
138
+ # @return [Hash] Options with the profile applied and removed
139
+ def apply(options, paths: [])
140
+ options = options.dup
141
+ spec = options.delete(:profile)
142
+ return options unless spec
143
+
144
+ resolve(spec, first_file: first_file_among(paths)).apply_to(options)
145
+ end
146
+
147
+ # The first regular file at or below any of +paths+ — the
148
+ # sampling target for +:auto+ profiles — or nil when nothing
149
+ # readable exists.
150
+ #
151
+ # @param paths [String, Array<String>] Files or directories
152
+ # @return [String, nil] Path of the first file found
153
+ def first_file_among(paths)
154
+ Array(paths).each do |path|
155
+ next unless ::File.exist?(path)
156
+
157
+ return path if ::File.file?(path)
158
+
159
+ if ::File.directory?(path)
160
+ found = first_file_within(path)
161
+ return found if found
162
+ end
163
+ end
164
+ nil
165
+ end
166
+
111
167
  # Get the profile detector
112
168
  #
113
169
  # @return [ProfileDetector] The detector instance
@@ -125,6 +181,22 @@ module Omnizip
125
181
 
126
182
  private
127
183
 
184
+ # Depth-first search for the first regular file under +dir+.
185
+ def first_file_within(dir)
186
+ ::Dir.foreach(dir) do |entry|
187
+ next if [".", ".."].include?(entry)
188
+
189
+ full_path = ::File.join(dir, entry)
190
+ return full_path if ::File.file?(full_path)
191
+
192
+ if ::File.directory?(full_path)
193
+ found = first_file_within(full_path)
194
+ return found if found
195
+ end
196
+ end
197
+ nil
198
+ end
199
+
128
200
  # Register all built-in profiles
129
201
  #
130
202
  # @param registry [ProfileRegistry] Registry to populate
@@ -145,18 +145,7 @@ module Omnizip
145
145
  end
146
146
 
147
147
  def format_bytes(bytes)
148
- return "0 B" if bytes.nil? || bytes.zero?
149
-
150
- units = %w[B KB MB GB]
151
- size = bytes.to_f
152
- unit_index = 0
153
-
154
- while size >= 1024.0 && unit_index < units.size - 1
155
- size /= 1024.0
156
- unit_index += 1
157
- end
158
-
159
- format("%.2f %s", size, units[unit_index])
148
+ Omnizip::CliOutputFormatter.format_size(bytes)
160
149
  end
161
150
 
162
151
  def truncate(string, max_length)
@@ -186,18 +186,7 @@ module Omnizip
186
186
  end
187
187
 
188
188
  def format_bytes(bytes)
189
- return "0 B" if bytes.zero?
190
-
191
- units = %w[B KB MB GB]
192
- size = bytes.to_f
193
- unit_index = 0
194
-
195
- while size >= 1024.0 && unit_index < units.size - 1
196
- size /= 1024.0
197
- unit_index += 1
198
- end
199
-
200
- format("%.2f %s", size, units[unit_index])
189
+ Omnizip::CliOutputFormatter.format_size(bytes)
201
190
  end
202
191
  end
203
192
  end
@@ -79,37 +79,9 @@ module Omnizip
79
79
  private
80
80
 
81
81
  def extract_to_temp(temp_dir)
82
- # Detect archive format and extract
83
- case detect_format
84
- when :zip
85
- extract_zip(temp_dir)
86
- when :seven_zip
87
- extract_7z(temp_dir)
88
- else
89
- raise Omnizip::UnsupportedFormatError,
90
- "Unknown archive format: #{@archive_path}"
91
- end
92
- end
93
-
94
- def extract_zip(dest)
95
- Omnizip::Zip::File.open(@archive_path) do |zip|
96
- zip.each do |entry|
97
- entry_path = File.join(dest, entry.name)
98
-
99
- if entry.directory?
100
- FileUtils.mkdir_p(entry_path)
101
- else
102
- FileUtils.mkdir_p(File.dirname(entry_path))
103
- zip.extract(entry, entry_path)
104
- end
105
- end
106
- end
107
- end
108
-
109
- def extract_7z(dest)
110
- # Placeholder for 7z extraction
111
- # Would use Omnizip::SevenZip::File when available
112
- raise NotImplementedError, "7z extraction not yet implemented"
82
+ # Every routed format extracts through the handler registry,
83
+ # same as the convenience API
84
+ Omnizip.extract_archive(@archive_path, temp_dir)
113
85
  end
114
86
 
115
87
  def move_to_destination(temp_dir)
@@ -147,18 +119,6 @@ module Omnizip
147
119
  end
148
120
  count
149
121
  end
150
-
151
- def detect_format
152
- File.open(@archive_path, "rb") do |f|
153
- magic = f.read(4)
154
- case magic
155
- when "PK\x03\x04"
156
- :zip
157
- when "7z\xBC\xAF"
158
- :seven_zip
159
- end
160
- end
161
- end
162
122
  end
163
123
  end
164
124
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnizip
4
- VERSION = "0.3.50"
4
+ VERSION = "0.3.52"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnizip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.50
4
+ version: 0.3.52
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-31 00:00:00.000000000 Z
11
+ date: 2026-09-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64