omnizip 0.3.50 → 0.3.51

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: 4b268c5b315ddcfff3347f6843e30e7ca180b1b3bae14b21a7a721bd2ccf3f6e
4
+ data.tar.gz: 2d150420264453af6e1ba5114b32a31b1a67c12a020f41435a037747dec2bfde
5
5
  SHA512:
6
- metadata.gz: 8e4cc8c3b167c07112ad5f45f3a870eb24b8ffd946cc7f9fe6c4b10bfe376e7efc5a0c975287027646d9bc7aef3f419e67eb1bd40e38824eb3e93219a3f78603
7
- data.tar.gz: 7fe55f7e975c40eff0c2109167a0e23fa99f11949489a29f7555632a340b3ea16983091f09bfa6cd948e3351501fd66fb438d3ad90a40953ef514721fc0138f0
6
+ metadata.gz: '053812e04bf42b6fe5335d57a5183c90b19a3ed8b414ee57ac9a83831a04f15e7225ecc79d29bc42e78e692bf7fe6f60b88ba234a3fc92eb0e781e7a357acd14'
7
+ data.tar.gz: dd2e32bd6ea8732be35a2f6c7325ed23b922860c051caf7666df93edd7f7da1342bdc1f6c9928346a404a7a1c707c509bd74d1b281ddd07cdc4a591c67e8a05d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.50] - 2026-08-31
11
+
12
+ ### Added
13
+ - The conversion matrix is closed: `Omnizip::Converter.convert` now
14
+ converts any routed source format (ZIP, 7z, TAR, RAR, cpio, ISO,
15
+ RPM, XAR, MSI) into any writable target format (ZIP, 7z, TAR,
16
+ RAR) through a new extract-and-repack strategy built on the
17
+ handler registry. The documented RAR→ZIP and TAR→7z pairs
18
+ previously had no strategy behind them — `Converter.supported?`
19
+ answered false for conversions the README promised. The direct
20
+ ZIP↔7z strategies keep precedence, so entry-copying conversions
21
+ don't take the repack detour.
22
+
10
23
  ## [0.3.49] - 2026-08-31
11
24
 
12
25
  ### Added
@@ -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)
@@ -217,39 +213,6 @@ module Omnizip
217
213
  format("%.2f %s", size, units[unit_idx])
218
214
  end
219
215
 
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
251
- end
252
-
253
216
  def parse_volume_size(size_str)
254
217
  return nil if size_str.nil? || size_str.empty?
255
218
 
@@ -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)
@@ -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
@@ -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.51"
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.51
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