rakpak 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "formats"
5
+ require_relative "plan"
6
+
7
+ module Rakpak
8
+ class Unpack
9
+ # `codec` is a TAR_CODECS id, so packing's binary probing decides readability.
10
+ Shape = Struct.new(:ext, :kind, :codec, keyword_init: true) do
11
+ def tool = kind == :zip ? "unzip" : "tar"
12
+
13
+ def bin = codec && Rakpak.tar_codec(codec)&.bin
14
+ end
15
+
16
+ # Longest extension first, so notes.tar.gz never reads as a bare .gz.
17
+ SHAPES = [
18
+ Shape.new(ext: ".tar", kind: :tar),
19
+ Shape.new(ext: ".tar.gz", kind: :tar, codec: :gzip),
20
+ Shape.new(ext: ".tar.zst", kind: :tar, codec: :zstd),
21
+ Shape.new(ext: ".tar.xz", kind: :tar, codec: :xz),
22
+ Shape.new(ext: ".tar.bz2", kind: :tar, codec: :bzip2),
23
+ Shape.new(ext: ".tar.lz4", kind: :tar, codec: :lz4),
24
+ Shape.new(ext: ".tar.br", kind: :tar, codec: :brotli),
25
+ Shape.new(ext: ".tgz", kind: :tar, codec: :gzip),
26
+ Shape.new(ext: ".tzst", kind: :tar, codec: :zstd),
27
+ Shape.new(ext: ".txz", kind: :tar, codec: :xz),
28
+ Shape.new(ext: ".tbz2", kind: :tar, codec: :bzip2),
29
+ Shape.new(ext: ".tbz", kind: :tar, codec: :bzip2),
30
+ Shape.new(ext: ".zip", kind: :zip),
31
+ Shape.new(ext: ".gz", kind: :single, codec: :gzip),
32
+ Shape.new(ext: ".zst", kind: :single, codec: :zstd),
33
+ Shape.new(ext: ".xz", kind: :single, codec: :xz),
34
+ Shape.new(ext: ".bz2", kind: :single, codec: :bzip2),
35
+ Shape.new(ext: ".lz4", kind: :single, codec: :lz4),
36
+ Shape.new(ext: ".br", kind: :single, codec: :brotli)
37
+ ].sort_by { |s| -s.ext.length }.freeze
38
+
39
+ def self.format(path)
40
+ name = File.basename(path.to_s).downcase
41
+ SHAPES.find { |s| name.end_with?(s.ext) && name.length > s.ext.length }
42
+ end
43
+
44
+ def self.archive?(path) = !format(path).nil?
45
+
46
+ # A name reducing to "", "." or ".." would resolve to the parent, so keep the whole basename.
47
+ def self.strip_ext(path)
48
+ name = File.basename(path.to_s)
49
+ shape = format(path)
50
+ stripped = shape ? name[0...-shape.ext.length] : name
51
+ ["", ".", ".."].include?(stripped) ? name : stripped
52
+ end
53
+
54
+ def self.default_subdir(path)
55
+ format(path)&.kind == :single ? nil : strip_ext(path)
56
+ end
57
+
58
+ def self.member_name(path) = strip_ext(path)
59
+
60
+ attr_reader :archive, :shape
61
+ attr_accessor :dest
62
+
63
+ def initialize(archive:, dest:)
64
+ @archive = File.expand_path(archive)
65
+ @dest = File.expand_path(dest)
66
+ @shape = Unpack.format(@archive)
67
+ end
68
+
69
+ def kind = @shape&.kind
70
+ def single? = kind == :single
71
+
72
+ # Job chdirs here, so it must exist before the first step runs.
73
+ def base = @dest
74
+
75
+ def outputs = [single? ? File.join(@dest, Unpack.member_name(@archive)) : @dest]
76
+ def output = outputs.first
77
+ def clobbers_output? = single?
78
+
79
+ # Output is opened before the archive is read, so write here and rename over on success.
80
+ def scratch = File.join(@dest, ".#{Unpack.member_name(@archive)}.part")
81
+
82
+ def prepare
83
+ @made = []
84
+ dir = @dest
85
+ until File.directory?(dir) || dir == "/"
86
+ @made << dir
87
+ dir = File.dirname(dir)
88
+ end
89
+ FileUtils.mkdir_p(@dest)
90
+ end
91
+
92
+ def commit
93
+ File.rename(scratch, output) if single? && File.exist?(scratch)
94
+ end
95
+
96
+ # Remove only folders we created that stayed empty.
97
+ def rollback
98
+ (@made || []).each do |dir|
99
+ Dir.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
100
+ rescue StandardError
101
+ nil
102
+ end
103
+ end
104
+
105
+ # Counting members would mean decompressing the whole archive first.
106
+ def total_members(_sizer) = nil
107
+
108
+ def gerund = "unpacking"
109
+
110
+ def outcome
111
+ return "#{File.basename(output)} #{Text.bytes(file_size(output))}" if single?
112
+
113
+ "unpacked into #{File.basename(@dest)}/"
114
+ end
115
+
116
+ def report_note = single? ? Text.bytes(file_size(output)) : "unpacked"
117
+
118
+ def file_size(path)
119
+ File.size(path)
120
+ rescue StandardError
121
+ nil
122
+ end
123
+
124
+ def tar_argv
125
+ argv = ["tar", "-x", "-v"]
126
+ # GNU tar appends -d when reading (brotli refuses a repeated -d); bsdtar runs the string as given.
127
+ argv += ["--use-compress-program", Tools.tar_flavor == :bsd ? "#{@shape.bin} -d" : @shape.bin] if @shape.bin
128
+ argv + ["-f", @archive, "-C", @dest]
129
+ end
130
+
131
+ def zip_argv = ["unzip", "-o", @archive, "-d", @dest]
132
+
133
+ def single_argv = [@shape.bin, "-dc", @archive]
134
+
135
+ # [label, argv, expects_verbose_output, stdout_path]
136
+ def steps
137
+ case kind
138
+ when :tar then [["tar", tar_argv, true, nil]]
139
+ when :zip then [["unzip", zip_argv, true, nil]]
140
+ when :single then [[@shape.bin, single_argv, false, scratch]]
141
+ else []
142
+ end
143
+ end
144
+
145
+ def preview
146
+ steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
147
+ end
148
+
149
+ def missing?(bin) = !Tools.available?(bin)
150
+
151
+ # GNU tar and bsdtar can hand the stream to a compressor; busybox cannot.
152
+ def tar_pipes? = Tools.tar_pipes?
153
+
154
+ def tools = [@shape&.kind == :single ? nil : @shape&.tool, @shape&.bin].compact
155
+
156
+ def problems
157
+ return ["not an archive rakpak knows how to open: #{File.basename(@archive)}"] if @shape.nil?
158
+
159
+ errs = []
160
+ if !File.exist?(@archive) then errs << "no such file: #{@archive}"
161
+ elsif !File.file?(@archive) then errs << "not a file: #{@archive}"
162
+ elsif !File.readable?(@archive) then errs << "not readable: #{@archive}"
163
+ end
164
+ tools.each { |bin| errs << "#{bin} not installed" if missing?(bin) }
165
+ if kind == :tar && @shape.bin && !tar_pipes?
166
+ errs << "this tar cannot pipe through #{@shape.bin}"
167
+ end
168
+ # mkdir_p would raise EEXIST partway through the run; say it up front.
169
+ errs << "not a folder: #{@dest}" if File.exist?(@dest) && !File.directory?(@dest)
170
+ # tar needs to list the folder to avoid clobbering.
171
+ if File.directory?(@dest) && !File.readable?(@dest)
172
+ errs << "destination is not readable: #{@dest}"
173
+ end
174
+ parent = existing_parent(@dest)
175
+ errs << "destination is not writable: #{parent}" unless File.writable?(parent)
176
+ errs.uniq
177
+ end
178
+
179
+ def existing_parent(dir)
180
+ dir = File.dirname(dir) until File.directory?(dir) || dir == "/"
181
+ dir
182
+ end
183
+
184
+ def warnings
185
+ warn = []
186
+ if single?
187
+ warn << "#{Unpack.member_name(@archive)} already exists and will be replaced" if File.exist?(output)
188
+ elsif File.directory?(@dest) && !empty_dir?(@dest)
189
+ warn << "#{File.basename(@dest)} already has files in it; matching names will be replaced"
190
+ end
191
+ warn
192
+ end
193
+
194
+ # Must not raise: the confirm screen calls this while drawing.
195
+ def empty_dir?(dir)
196
+ Dir.children(dir).empty?
197
+ rescue StandardError
198
+ true
199
+ end
200
+ end
201
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/rakpak.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rakpak/version"
4
+ require_relative "rakpak/unpack"
4
5
  require_relative "rakpak/app"
5
6
 
6
7
  module Rakpak
@@ -9,36 +10,36 @@ module Rakpak
9
10
 
10
11
  usage: rakpak [folder]
11
12
  rakpak -p path [path ...]
13
+ rakpak -d archive [archive ...]
12
14
 
13
15
  folder start browsing there (default: your home folder)
14
16
  -p, --pack tag the given files or folders and go straight to the
15
17
  archive prompts, starting with the kind of compression
18
+ -d, --depack unpack the archives here and now, no browsing: each one
19
+ into a folder of its own name in the current folder
16
20
  -h, --help this text
17
21
  -v, --version print the version
18
22
 
19
23
  space tag / untag p pack what is tagged
20
- h l ← → navigate / filter . hidden
21
- enter enter folder T review tags
22
- ? all keys q quit
24
+ h l ← → navigate u unpack what the cursor is on
25
+ enter enter folder / filter . hidden
26
+ ? all keys T review tags q quit
23
27
  USAGE
24
28
 
25
- Options = Struct.new(:dir, :pack, :action, keyword_init: true)
29
+ Options = Struct.new(:dir, :pack, :unpack, :action, keyword_init: true)
26
30
 
27
- # A folder as a person would type it: ~, ~/x, $HOME/x, ${HOME}/x, or an
28
- # absolute or relative path.
29
31
  def self.expand_dir(text)
30
32
  text = text.to_s.strip
31
33
  text = text.gsub(/\$\{(\w+)\}|\$(\w+)/) { ENV.fetch(::Regexp.last_match(1) || ::Regexp.last_match(2), "") }
32
34
  text = "~" if text.empty?
33
35
  File.expand_path(text)
34
36
  rescue ArgumentError
35
- text # ~nosuchuser and the like; the caller reports it is not a folder
37
+ text
36
38
  end
37
39
 
38
- # Returns Options. `action` is :browse, :help or :version. Raises
39
- # ArgumentError on anything it does not understand.
40
40
  def self.parse(argv)
41
41
  pack_mode = false
42
+ depack_mode = false
42
43
  positional = []
43
44
  rest = argv.dup
44
45
  until rest.empty?
@@ -47,12 +48,28 @@ module Rakpak
47
48
  when "-h", "--help" then return Options.new(action: :help)
48
49
  when "-v", "--version" then return Options.new(action: :version)
49
50
  when "-p", "--pack" then pack_mode = true
51
+ when "-d", "--depack", "--unpack" then depack_mode = true
50
52
  when "--" then positional.concat(rest) && rest.clear
51
53
  when /\A-./ then raise ArgumentError, "unknown option: #{arg}"
52
54
  else positional << arg
53
55
  end
54
56
  end
55
57
 
58
+ raise ArgumentError, "-p and -d do different things; pick one" if pack_mode && depack_mode
59
+
60
+ if depack_mode
61
+ raise ArgumentError, "-d needs at least one archive" if positional.empty?
62
+
63
+ paths = positional.map { |p| File.expand_path(p) }
64
+ paths.each do |p|
65
+ raise ArgumentError, "no such file: #{p}" unless File.exist?(p)
66
+ unless File.file?(p) && Unpack.archive?(p)
67
+ raise ArgumentError, "not an archive rakpak knows how to open: #{File.basename(p)}"
68
+ end
69
+ end
70
+ return Options.new(action: :unpack, unpack: paths, pack: [])
71
+ end
72
+
56
73
  if pack_mode
57
74
  raise ArgumentError, "-p needs at least one path" if positional.empty?
58
75
 
@@ -60,20 +77,70 @@ module Rakpak
60
77
  paths.each do |p|
61
78
  raise ArgumentError, "no such file or folder: #{p}" unless File.exist?(p) || File.symlink?(p)
62
79
  end
63
- # Open the browser on the folder holding the first target, with that
64
- # target under the cursor, so the archive lands next to it.
65
- Options.new(action: :browse, dir: File.dirname(paths.first), pack: paths)
80
+ Options.new(action: :browse, dir: File.dirname(paths.first), pack: paths, unpack: [])
66
81
  else
67
82
  raise ArgumentError, "expected one folder, got #{positional.size}" if positional.size > 1
68
83
 
69
84
  dir = File.expand_path(positional.first || Dir.home)
70
85
  raise ArgumentError, "not a folder: #{dir}" unless File.directory?(dir)
71
86
 
72
- Options.new(action: :browse, dir: dir, pack: [])
87
+ Options.new(action: :browse, dir: dir, pack: [], unpack: [])
88
+ end
89
+ end
90
+
91
+ def self.unpack_all(archives, dir: Dir.pwd, out: $stdout)
92
+ status = 0
93
+ archives.each do |archive|
94
+ sub = Unpack.default_subdir(archive)
95
+ plan = Unpack.new(archive: archive, dest: sub ? File.join(dir, sub) : dir)
96
+ problems = plan.problems
97
+ if problems.any?
98
+ problems.each { |m| warn "rakpak: #{m}" }
99
+ status = 1
100
+ next
101
+ end
102
+ out.puts "#{Text.plain(File.basename(archive))} → #{Text.plain(Text.tilde(plan.dest))}"
103
+ plan.warnings.each { |w| out.puts " #{w}" }
104
+ # The job writes to stderr; flush so the streams stay in order.
105
+ out.flush
106
+ status = 1 unless run_headless(plan, out)
107
+ end
108
+ status
109
+ rescue Interrupt
110
+ 130
111
+ end
112
+
113
+ # The extractor runs in its own process group, so ctrl-c never reaches it;
114
+ # catching Interrupt here is what stops tar carrying on without us.
115
+ def self.run_headless(plan, out)
116
+ job = Job.new(plan).start
117
+ live = out.respond_to?(:tty?) && out.tty?
118
+ begin
119
+ while job.running?
120
+ job.wait(0.1)
121
+ next unless live
122
+
123
+ out.print "\r #{job.file_count} entries · #{Text.duration(job.elapsed)}\e[K"
124
+ out.flush
125
+ end
126
+ rescue Interrupt
127
+ out.print "\r\e[K" if live
128
+ job.cancel
129
+ job.wait(5)
130
+ warn "rakpak: cancelled"
131
+ raise
132
+ end
133
+ out.print "\r\e[K" if live
134
+ unless job.ok?
135
+ warn "rakpak: #{job.error}"
136
+ # Member names come from inside the archive; defang before printing.
137
+ job.tail(4).each { |line| warn " #{Text.plain(line)}" unless line.start_with?("▸") }
138
+ return false
73
139
  end
140
+ out.puts " #{job.file_count} entries · #{Text.duration(job.elapsed)} · #{plan.report_note}"
141
+ true
74
142
  end
75
143
 
76
- # Exit status.
77
144
  def self.start(argv = ARGV)
78
145
  begin
79
146
  opts = parse(argv)
@@ -89,6 +156,8 @@ module Rakpak
89
156
  when :version
90
157
  puts "rakpak #{VERSION}"
91
158
  return 0
159
+ when :unpack
160
+ return unpack_all(opts.unpack)
92
161
  end
93
162
  unless $stdout.tty? && $stdin.tty?
94
163
  warn "rakpak: needs an interactive terminal"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rakpak
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Wilson
@@ -11,7 +11,8 @@ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: A terminal file browser for building archives. Walk around, tag files
13
13
  and folders wherever they live, then pack them into one compressed tarball, plain
14
- tarball or zip. No dependencies beyond Ruby and the archive tools on your machine.
14
+ tarball or zip. Unpacks them again too, from the browser or straight from the command
15
+ line. No dependencies beyond Ruby and the archive tools on your machine.
15
16
  email:
16
17
  - ryan@ryanwilson.io
17
18
  executables:
@@ -36,14 +37,15 @@ files:
36
37
  - lib/rakpak/term.rb
37
38
  - lib/rakpak/text.rb
38
39
  - lib/rakpak/theme.rb
40
+ - lib/rakpak/unpack.rb
39
41
  - lib/rakpak/version.rb
40
- homepage: https://github.com/rywils/rakpak
42
+ homepage: https://github.com/eof0/rakpak
41
43
  licenses:
42
44
  - MIT
43
45
  metadata:
44
- homepage_uri: https://github.com/rywils/rakpak
45
- source_code_uri: https://github.com/rywils/rakpak
46
- bug_tracker_uri: https://github.com/rywils/rakpak/issues
46
+ homepage_uri: https://github.com/eof0/rakpak
47
+ source_code_uri: https://github.com/eof0/rakpak
48
+ bug_tracker_uri: https://github.com/eof0/rakpak/issues
47
49
  rubygems_mfa_required: 'true'
48
50
  rdoc_options: []
49
51
  require_paths:
@@ -61,6 +63,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
63
  requirements: []
62
64
  rubygems_version: 3.6.9
63
65
  specification_version: 4
64
- summary: Tag files anywhere on your filesystem and archive them all at once from the
65
- terminal.
66
+ summary: Tag files anywhere on your filesystem, archive them all at once, and unpack
67
+ again, from the terminal.
66
68
  test_files: []