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.
- checksums.yaml +4 -4
- data/README.md +63 -9
- data/lib/rakpak/app.rb +153 -61
- data/lib/rakpak/browser.rb +89 -38
- data/lib/rakpak/dir_cache.rb +1 -3
- data/lib/rakpak/entry.rb +1 -4
- data/lib/rakpak/formats.rb +2 -16
- data/lib/rakpak/job.rb +50 -28
- data/lib/rakpak/modal.rb +72 -25
- data/lib/rakpak/plan.rb +96 -46
- data/lib/rakpak/screen.rb +2 -10
- data/lib/rakpak/sizer.rb +2 -6
- data/lib/rakpak/term.rb +3 -11
- data/lib/rakpak/text.rb +3 -12
- data/lib/rakpak/theme.rb +1 -2
- data/lib/rakpak/unpack.rb +201 -0
- data/lib/rakpak/version.rb +1 -1
- data/lib/rakpak.rb +83 -14
- metadata +10 -8
|
@@ -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
|
data/lib/rakpak/version.rb
CHANGED
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
|
|
21
|
-
enter enter folder
|
|
22
|
-
? all keys q
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
42
|
+
homepage: https://github.com/eof0/rakpak
|
|
41
43
|
licenses:
|
|
42
44
|
- MIT
|
|
43
45
|
metadata:
|
|
44
|
-
homepage_uri: https://github.com/
|
|
45
|
-
source_code_uri: https://github.com/
|
|
46
|
-
bug_tracker_uri: https://github.com/
|
|
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
|
|
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: []
|