rakpak 1.1.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 +11 -5
- data/lib/rakpak/app.rb +53 -50
- data/lib/rakpak/browser.rb +86 -36
- 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 +12 -41
- data/lib/rakpak/modal.rb +70 -23
- data/lib/rakpak/plan.rb +70 -55
- 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 +10 -47
- data/lib/rakpak/version.rb +1 -1
- data/lib/rakpak.rb +5 -23
- metadata +1 -1
data/lib/rakpak/plan.rb
CHANGED
|
@@ -4,19 +4,12 @@ require_relative "formats"
|
|
|
4
4
|
require_relative "text"
|
|
5
5
|
|
|
6
6
|
module Rakpak
|
|
7
|
-
#
|
|
8
|
-
# command. Nothing here shells out; commands are spawned without a shell,
|
|
9
|
-
# so spaces and quotes in filenames are never a hazard.
|
|
10
|
-
#
|
|
11
|
-
# Three shapes of output, one file each:
|
|
12
|
-
# :both tar, then compress name.tar.gz, name.tar.zst, ...
|
|
13
|
-
# :tar plain tar, no compression name.tar
|
|
14
|
-
# :zip compression only name.zip, or name.txt.gz for one file
|
|
7
|
+
# :both tar then compress, :tar plain tar, :zip compress only (name.zip, or name.txt.gz for one file)
|
|
15
8
|
class Plan
|
|
16
9
|
TARGETS = %i[both tar zip].freeze
|
|
17
10
|
|
|
18
|
-
attr_reader :paths
|
|
19
|
-
attr_accessor :outdir, :basename, :target
|
|
11
|
+
attr_reader :paths, :excluded
|
|
12
|
+
attr_accessor :outdir, :basename, :target, :excludes
|
|
20
13
|
attr_accessor :tar_codec, :tar_level, :tar_flags, :compressor, :comp_level, :zip_flags
|
|
21
14
|
|
|
22
15
|
def initialize(paths:, outdir:, basename: "archive", target: :both)
|
|
@@ -24,7 +17,8 @@ module Rakpak
|
|
|
24
17
|
@outdir = outdir
|
|
25
18
|
@basename = basename
|
|
26
19
|
@target = target
|
|
27
|
-
|
|
20
|
+
@excludes = []
|
|
21
|
+
@excluded = []
|
|
28
22
|
@tar_codec = TAR_CODECS.find { |c| c.id == :gzip && c.available? } || tar_codec_fallback
|
|
29
23
|
@tar_level = @tar_codec.default
|
|
30
24
|
@tar_flags = Rakpak.tar_flags
|
|
@@ -33,12 +27,8 @@ module Rakpak
|
|
|
33
27
|
@zip_flags = Rakpak.zip_flags
|
|
34
28
|
end
|
|
35
29
|
|
|
36
|
-
# Drop anything already covered by another selection: tagging ~/docs and
|
|
37
|
-
# then ~/docs/notes would otherwise store notes twice, silently.
|
|
38
30
|
def self.prune(paths)
|
|
39
|
-
# Sorting by
|
|
40
|
-
# ancestor ("a/b" before "a-x"), so one pass with a stack of accepted
|
|
41
|
-
# ancestors is enough, however many paths there are.
|
|
31
|
+
# Sorting by components puts descendants right after their ancestor ("a/b" before "a-x").
|
|
42
32
|
sorted = paths.map { |p| File.expand_path(p) }.uniq.sort_by { |p| p.split("/") }
|
|
43
33
|
kept = []
|
|
44
34
|
stack = []
|
|
@@ -56,15 +46,29 @@ module Rakpak
|
|
|
56
46
|
path.start_with?(dir == "/" ? "/" : "#{dir}/")
|
|
57
47
|
end
|
|
58
48
|
|
|
59
|
-
|
|
60
|
-
|
|
49
|
+
def self.parse_excludes(text)
|
|
50
|
+
slash = "/".ord
|
|
51
|
+
text.split(",").map do |name|
|
|
52
|
+
name = name.strip
|
|
53
|
+
finish = name.bytesize
|
|
54
|
+
finish -= 1 while finish.positive? && name.getbyte(finish - 1) == slash
|
|
55
|
+
name.byteslice(0, finish)
|
|
56
|
+
end.reject(&:empty?).uniq
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Marked paths are exact, so glob characters in them must not match.
|
|
60
|
+
def self.literal(path) = path.gsub(/[\\*?\[\]]/) { |c| "\\#{c}" }
|
|
61
|
+
|
|
62
|
+
# Only marks strictly inside a packed folder count; a packed item itself is untagged instead.
|
|
63
|
+
def excluded=(list)
|
|
64
|
+
@excluded = list.map { |p| File.expand_path(p) }.uniq.sort
|
|
65
|
+
.select { |x| @paths.any? { |p| Plan.inside?(x, p) } }
|
|
66
|
+
end
|
|
67
|
+
|
|
61
68
|
def tar_codec_fallback
|
|
62
69
|
TAR_CODECS.find { |c| c.bin && c.available? } || Rakpak.tar_codec(:none)
|
|
63
70
|
end
|
|
64
71
|
|
|
65
|
-
# One format's knobs behind a uniform face, so the option form does not
|
|
66
|
-
# need to know whether it is editing tar or compressor settings. Picking
|
|
67
|
-
# a codec resets the level to that codec's default.
|
|
68
72
|
class Side
|
|
69
73
|
attr_reader :options, :flags
|
|
70
74
|
|
|
@@ -83,7 +87,7 @@ module Rakpak
|
|
|
83
87
|
@plan.public_send("#{@level_attr}=", v)
|
|
84
88
|
end
|
|
85
89
|
|
|
86
|
-
# [label, id, enabled, why] rows
|
|
90
|
+
# [label, id, enabled, why] rows.
|
|
87
91
|
def choices = @plan.public_send(@choices)
|
|
88
92
|
|
|
89
93
|
def codec=(id)
|
|
@@ -103,13 +107,10 @@ module Rakpak
|
|
|
103
107
|
flags: @zip_flags, choices: :compress_choices)
|
|
104
108
|
end
|
|
105
109
|
|
|
106
|
-
# "both" means compressed, so "none" is not on offer there.
|
|
107
110
|
def tar_choices
|
|
108
111
|
TAR_CODECS.reject { |c| c.id == :none }.map { |c| [c.label, c.id, c.available?, c.why_not] }
|
|
109
112
|
end
|
|
110
113
|
|
|
111
|
-
# Single-file compressors only make sense for exactly one file; zip can
|
|
112
|
-
# take anything.
|
|
113
114
|
def compress_choices
|
|
114
115
|
COMPRESSORS.map do |c|
|
|
115
116
|
ok = c.available? && (c.container? || single_file?)
|
|
@@ -120,13 +121,8 @@ module Rakpak
|
|
|
120
121
|
|
|
121
122
|
def single_file? = @paths.size == 1 && File.file?(@paths.first)
|
|
122
123
|
|
|
123
|
-
# True when the output is a bare compressed file (notes.txt.gz), where
|
|
124
|
-
# the original name should be kept whole.
|
|
125
124
|
def single_compress? = @target == :zip && !@compressor.container?
|
|
126
125
|
|
|
127
|
-
# Deepest directory containing every tagged path. Members are stored
|
|
128
|
-
# relative to it, so the archive has a sane shape no matter how far
|
|
129
|
-
# apart the selections were.
|
|
130
126
|
def base
|
|
131
127
|
@base ||= begin
|
|
132
128
|
dirs = @paths.map { |p| File.dirname(p) }
|
|
@@ -142,11 +138,11 @@ module Rakpak
|
|
|
142
138
|
end
|
|
143
139
|
end
|
|
144
140
|
|
|
145
|
-
def members
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
141
|
+
def members = @paths.map { |p| relative(p) }
|
|
142
|
+
|
|
143
|
+
def relative(path)
|
|
144
|
+
rel = path.delete_prefix(base == "/" ? "/" : "#{base}/")
|
|
145
|
+
rel.empty? ? File.basename(path) : rel
|
|
150
146
|
end
|
|
151
147
|
|
|
152
148
|
def ext
|
|
@@ -160,21 +156,16 @@ module Rakpak
|
|
|
160
156
|
def output = File.join(@outdir, ensure_ext(@basename, ext))
|
|
161
157
|
def outputs = [output]
|
|
162
158
|
|
|
163
|
-
# Nothing to make ready: the destination folder was checked, not created,
|
|
164
|
-
# and every step writes straight to its final name.
|
|
165
159
|
def prepare; end
|
|
166
160
|
def commit; end
|
|
167
161
|
def rollback; end
|
|
168
162
|
|
|
169
|
-
# What the job view calls this work while it runs.
|
|
170
163
|
def gerund = "archiving"
|
|
171
164
|
|
|
172
|
-
# One line for the notice when it finishes.
|
|
173
165
|
def outcome
|
|
174
166
|
outputs.map { |o| "#{File.basename(o)} #{Text.bytes(file_size(o))}" }.join(" · ")
|
|
175
167
|
end
|
|
176
168
|
|
|
177
|
-
# What follows the path on the line printed to the shell afterwards.
|
|
178
169
|
def report_note = Text.bytes(file_size(output))
|
|
179
170
|
|
|
180
171
|
def file_size(path)
|
|
@@ -183,28 +174,23 @@ module Rakpak
|
|
|
183
174
|
nil
|
|
184
175
|
end
|
|
185
176
|
|
|
186
|
-
#
|
|
187
|
-
# sizer has been counting the selection since it was tagged.
|
|
177
|
+
# Excluded files are inside the count, so a total would stall the bar; show the count alone.
|
|
188
178
|
def total_members(sizer)
|
|
179
|
+
return nil if @excludes.any? || @excluded.any?
|
|
180
|
+
|
|
189
181
|
files = sizer.total(@paths).files
|
|
190
182
|
files.positive? ? files : nil
|
|
191
183
|
end
|
|
192
184
|
|
|
193
|
-
# Every step here writes one archive file, and the confirm screen has
|
|
194
|
-
# said an existing one goes.
|
|
195
185
|
def clobbers_output? = true
|
|
196
186
|
|
|
197
|
-
# Extensions a user might type that we would otherwise double up.
|
|
198
187
|
ARCHIVE_EXTS = (TAR_CODECS.map(&:ext) + TAR_CODECS.map(&:single_ext) + %w[.tgz .tbz2 .txz .zip])
|
|
199
188
|
.reject(&:empty?).uniq.sort_by { |e| -e.length }.freeze
|
|
200
189
|
|
|
201
190
|
def ensure_ext(name, ext)
|
|
202
191
|
return name if name.downcase.end_with?(ext)
|
|
203
|
-
# backup.tar gzipped on its own is backup.tar.gz; the name is the
|
|
204
|
-
# point, so nothing is stripped from it.
|
|
205
192
|
return "#{name}#{ext}" if single_compress?
|
|
206
193
|
|
|
207
|
-
# Strip a competing archive extension the user may have typed.
|
|
208
194
|
typed = ARCHIVE_EXTS.find { |e| name.downcase.end_with?(e) }
|
|
209
195
|
stripped = typed ? name[0...-typed.length] : name
|
|
210
196
|
stripped = name if stripped.empty?
|
|
@@ -217,10 +203,36 @@ module Rakpak
|
|
|
217
203
|
argv += ["--use-compress-program", filter]
|
|
218
204
|
end
|
|
219
205
|
@tar_flags.each { |f| argv.concat(f.args) if f.on }
|
|
206
|
+
argv += tar_exclude_args
|
|
220
207
|
argv += ["-f", output, "-C", base, "--"]
|
|
221
208
|
argv + members
|
|
222
209
|
end
|
|
223
210
|
|
|
211
|
+
# Names match at any depth. Marks are anchored: GNU tar takes --anchored, bsdtar a leading ^,
|
|
212
|
+
# busybox has neither (warnings says so).
|
|
213
|
+
def tar_exclude_args
|
|
214
|
+
args = @excludes.map { |n| "--exclude=#{n}" }
|
|
215
|
+
marked = @excluded.map { |p| Plan.literal(relative(p)) }
|
|
216
|
+
return args if marked.empty?
|
|
217
|
+
|
|
218
|
+
case Tools.tar_flavor
|
|
219
|
+
when *GNU then args + ["--anchored"] + marked.map { |m| "--exclude=#{m}" }
|
|
220
|
+
when :bsd then args + marked.map { |m| "--exclude=^#{m}" }
|
|
221
|
+
else args + marked.map { |m| "--exclude=#{m}" }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# zip matches -x against whole member paths, so a name needs each depth spelled out.
|
|
226
|
+
def zip_exclude_args
|
|
227
|
+
names = @excludes.map { |n| zipsafe(n) }.flat_map { |n| [n, "#{n}/*", "*/#{n}", "*/#{n}/*"] }
|
|
228
|
+
marks = @excluded.map { |p| zipsafe(Plan.literal(relative(p))) }.flat_map { |m| [m, "#{m}/*"] }
|
|
229
|
+
pats = names + marks
|
|
230
|
+
pats.empty? ? [] : ["-x", *pats]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# zip would read a leading dash as an option; [-] matches the same name.
|
|
234
|
+
def zipsafe(pat) = pat.start_with?("-") ? "[-]#{pat[1..]}" : pat
|
|
235
|
+
|
|
224
236
|
def zip_argv
|
|
225
237
|
argv = ["zip", "-r"]
|
|
226
238
|
argv << (zip_verbose? ? "-v" : "-q")
|
|
@@ -233,11 +245,9 @@ module Rakpak
|
|
|
233
245
|
argv.concat(f.args) if f.on
|
|
234
246
|
end
|
|
235
247
|
argv << output
|
|
236
|
-
argv + members.map { |m| dashsafe(m) }
|
|
248
|
+
argv + members.map { |m| dashsafe(m) } + zip_exclude_args
|
|
237
249
|
end
|
|
238
250
|
|
|
239
|
-
# gzip and friends read one file and write to stdout; the job redirects
|
|
240
|
-
# that into the output path.
|
|
241
251
|
def single_argv
|
|
242
252
|
@compressor.argv(@comp_level) + [dashsafe(members.first)]
|
|
243
253
|
end
|
|
@@ -265,8 +275,7 @@ module Rakpak
|
|
|
265
275
|
end
|
|
266
276
|
end
|
|
267
277
|
|
|
268
|
-
# Display
|
|
269
|
-
# this is for the reader's benefit; quote only what needs it.
|
|
278
|
+
# Display only; execution never goes through a shell.
|
|
270
279
|
def self.show_arg(arg)
|
|
271
280
|
arg.match?(%r{\A[\w@%+=:,./-]+\z}) ? arg : "'#{arg.gsub("'", %q('"'"'))}'"
|
|
272
281
|
end
|
|
@@ -280,7 +289,6 @@ module Rakpak
|
|
|
280
289
|
steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
|
|
281
290
|
end
|
|
282
291
|
|
|
283
|
-
# Problems worth blocking on, checked right before the run.
|
|
284
292
|
def problems
|
|
285
293
|
errs = []
|
|
286
294
|
errs << "nothing selected" if @paths.empty?
|
|
@@ -302,7 +310,6 @@ module Rakpak
|
|
|
302
310
|
errs.compact.uniq
|
|
303
311
|
end
|
|
304
312
|
|
|
305
|
-
# Non-blocking things the confirm screen should say out loud.
|
|
306
313
|
def warnings
|
|
307
314
|
warn = []
|
|
308
315
|
o = output
|
|
@@ -310,6 +317,14 @@ module Rakpak
|
|
|
310
317
|
if @paths.any? { |p| Plan.inside?(o, p) }
|
|
311
318
|
warn << "output sits inside a selected folder, so it may archive itself"
|
|
312
319
|
end
|
|
320
|
+
@paths.each do |p|
|
|
321
|
+
name = File.basename(p)
|
|
322
|
+
hit = @excludes.find { |pat| File.fnmatch?(pat, name, File::FNM_DOTMATCH) }
|
|
323
|
+
warn << "#{name} matches #{hit}, so it is left out entirely" if hit
|
|
324
|
+
end
|
|
325
|
+
if @excluded.any? && @target != :zip && Tools.tar_flavor == :busybox
|
|
326
|
+
warn << "busybox tar cannot pin a mark to one spot; same-named paths deeper in go too"
|
|
327
|
+
end
|
|
313
328
|
warn
|
|
314
329
|
end
|
|
315
330
|
end
|
data/lib/rakpak/screen.rb
CHANGED
|
@@ -4,8 +4,6 @@ require_relative "text"
|
|
|
4
4
|
require_relative "theme"
|
|
5
5
|
|
|
6
6
|
module Rakpak
|
|
7
|
-
# A character grid. Everything draws into cells, so modals overlay the
|
|
8
|
-
# browser cleanly and the whole frame ships in one write.
|
|
9
7
|
class Screen
|
|
10
8
|
attr_reader :w, :h
|
|
11
9
|
|
|
@@ -25,7 +23,6 @@ module Rakpak
|
|
|
25
23
|
@st.fill(style)
|
|
26
24
|
end
|
|
27
25
|
|
|
28
|
-
# Writes `text` at (x, y). Returns the column just past the text.
|
|
29
26
|
def put(x, y, text, style = nil)
|
|
30
27
|
return x if y.negative? || y >= @h
|
|
31
28
|
|
|
@@ -50,17 +47,14 @@ module Rakpak
|
|
|
50
47
|
cx
|
|
51
48
|
end
|
|
52
49
|
|
|
53
|
-
# A
|
|
54
|
-
# it. Writing over either half must blank the other, or the row renders
|
|
55
|
-
# one column too wide and the terminal wraps it.
|
|
50
|
+
# A wide glyph owns two cells; overwriting either half must blank the other or the row wraps.
|
|
56
51
|
def clear_halves(idx)
|
|
57
52
|
col = idx % @w
|
|
58
53
|
@ch[idx - 1] = " " if @ch[idx] == "" && col.positive? && Text.gw(@ch[idx - 1]) == 2
|
|
59
54
|
@ch[idx + 1] = " " if col < @w - 1 && @ch[idx + 1] == "" && Text.gw(@ch[idx]) == 2
|
|
60
55
|
end
|
|
61
56
|
|
|
62
|
-
#
|
|
63
|
-
# whole frame fail to concatenate and take the app down mid-render.
|
|
57
|
+
# One binary byte in @ch would break frame concatenation mid-render.
|
|
64
58
|
def printable(text)
|
|
65
59
|
str = text.to_s
|
|
66
60
|
str = str.dup.force_encoding(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8
|
|
@@ -104,8 +98,6 @@ module Rakpak
|
|
|
104
98
|
end
|
|
105
99
|
end
|
|
106
100
|
|
|
107
|
-
# Flatten everything to a faint monochrome so a modal reads as the
|
|
108
|
-
# foreground layer.
|
|
109
101
|
def veil(style = Theme::FAINT)
|
|
110
102
|
@st.fill(style)
|
|
111
103
|
end
|
data/lib/rakpak/sizer.rb
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rakpak
|
|
4
|
-
# Walks tagged directories on a worker thread so the UI can show a real
|
|
5
|
-
# byte total and file count without ever blocking on a huge tree.
|
|
6
4
|
class Sizer
|
|
7
5
|
Result = Struct.new(:bytes, :files, :partial)
|
|
8
6
|
|
|
9
|
-
BUDGET = 4.0 # seconds per path before
|
|
7
|
+
BUDGET = 4.0 # seconds per path before reporting a partial figure
|
|
10
8
|
|
|
11
9
|
def initialize
|
|
12
10
|
@cache = {}
|
|
@@ -40,7 +38,6 @@ module Rakpak
|
|
|
40
38
|
Result.new(bytes, files, known.any?(&:nil?) || known.compact.any?(&:partial))
|
|
41
39
|
end
|
|
42
40
|
|
|
43
|
-
# Drops the figure, and any measurement in flight for it.
|
|
44
41
|
def forget(path)
|
|
45
42
|
@lock.synchronize do
|
|
46
43
|
@cache.delete(path)
|
|
@@ -60,8 +57,7 @@ module Rakpak
|
|
|
60
57
|
def work(path, token)
|
|
61
58
|
res = measure(path)
|
|
62
59
|
@lock.synchronize do
|
|
63
|
-
#
|
|
64
|
-
# only the walk that the current request started may answer it.
|
|
60
|
+
# Only the walk the current request started may answer it.
|
|
65
61
|
next unless @pending[path].equal?(token)
|
|
66
62
|
|
|
67
63
|
@pending.delete(path)
|
data/lib/rakpak/term.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require "io/console"
|
|
4
4
|
|
|
5
5
|
module Rakpak
|
|
6
|
-
# Raw-mode terminal control and key decoding.
|
|
7
6
|
module Term
|
|
8
7
|
ARROWS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left,
|
|
9
8
|
"H" => :home, "F" => :end }.freeze
|
|
@@ -52,8 +51,7 @@ module Rakpak
|
|
|
52
51
|
$stdout.flush
|
|
53
52
|
end
|
|
54
53
|
|
|
55
|
-
#
|
|
56
|
-
# String, or nil on timeout.
|
|
54
|
+
# Returns a key symbol, a printable String, or nil on timeout.
|
|
57
55
|
def wait_key(timeout = nil)
|
|
58
56
|
return nil unless IO.select([$stdin], nil, nil, timeout)
|
|
59
57
|
|
|
@@ -78,10 +76,7 @@ module Rakpak
|
|
|
78
76
|
end
|
|
79
77
|
end
|
|
80
78
|
|
|
81
|
-
#
|
|
82
|
-
# stray byte arrives as a one-byte invalid string. Gather the rest of
|
|
83
|
-
# the sequence when there is one; if the result is still not valid
|
|
84
|
-
# text, the key is dropped rather than raised on later.
|
|
79
|
+
# C locale getc yields single bytes; gather the sequence and drop it if still invalid.
|
|
85
80
|
def complete_utf8(c)
|
|
86
81
|
s = c.dup.force_encoding(Encoding::UTF_8)
|
|
87
82
|
return s if s.valid_encoding?
|
|
@@ -109,9 +104,7 @@ module Rakpak
|
|
|
109
104
|
nil
|
|
110
105
|
end
|
|
111
106
|
|
|
112
|
-
#
|
|
113
|
-
# split across reads, so allow a generous window before concluding the
|
|
114
|
-
# user pressed a bare Esc. The delay is only ever paid on a real Esc.
|
|
107
|
+
# Escape sequences can split across reads on slow links; only a real Esc pays this wait.
|
|
115
108
|
ESC_WINDOW = 0.05
|
|
116
109
|
|
|
117
110
|
def read_escape
|
|
@@ -139,7 +132,6 @@ module Rakpak
|
|
|
139
132
|
:esc
|
|
140
133
|
end
|
|
141
134
|
else
|
|
142
|
-
# Alt-<char>: the whole sequence is the character.
|
|
143
135
|
:"alt_#{seq}"
|
|
144
136
|
end
|
|
145
137
|
end
|
data/lib/rakpak/text.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rakpak
|
|
4
|
-
# Grapheme-aware width math. Just enough Unicode to keep columns honest.
|
|
5
4
|
module Text
|
|
6
5
|
WIDE = [
|
|
7
6
|
0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF,
|
|
@@ -14,12 +13,10 @@ module Rakpak
|
|
|
14
13
|
|
|
15
14
|
module_function
|
|
16
15
|
|
|
17
|
-
# Display width of a single grapheme cluster.
|
|
18
16
|
def gw(cluster)
|
|
19
17
|
cp = cluster.ord
|
|
20
|
-
return 1 if cp >= 0x20 && cp < 0x7F
|
|
21
|
-
# C0, DEL and
|
|
22
|
-
# CSI, so these must never reach the frame.
|
|
18
|
+
return 1 if cp >= 0x20 && cp < 0x7F
|
|
19
|
+
# C0, DEL and C1: a UTF-8 terminal executes U+009B as CSI.
|
|
23
20
|
return 0 if cp < 0x20 || cp == 0x7F || (cp >= 0x80 && cp <= 0x9F)
|
|
24
21
|
return 0 if ZERO.any? { |r| r.cover?(cp) }
|
|
25
22
|
return 2 if WIDE.any? { |r| r.cover?(cp) }
|
|
@@ -35,7 +32,6 @@ module Rakpak
|
|
|
35
32
|
w
|
|
36
33
|
end
|
|
37
34
|
|
|
38
|
-
# Truncate to `max` columns, appending an ellipsis when something was cut.
|
|
39
35
|
def fit(str, max)
|
|
40
36
|
return "" if max <= 0
|
|
41
37
|
return str if width(str) <= max
|
|
@@ -52,7 +48,6 @@ module Rakpak
|
|
|
52
48
|
out << "…"
|
|
53
49
|
end
|
|
54
50
|
|
|
55
|
-
# Keep the tail of a path visible instead of the head.
|
|
56
51
|
def fit_left(str, max)
|
|
57
52
|
return "" if max <= 0
|
|
58
53
|
return str if width(str) <= max
|
|
@@ -75,7 +70,6 @@ module Rakpak
|
|
|
75
70
|
w >= max ? str : str + (" " * (max - w))
|
|
76
71
|
end
|
|
77
72
|
|
|
78
|
-
# Greedy wrap on spaces, with a hanging indent for continuations.
|
|
79
73
|
def wrap(str, max, indent = 0)
|
|
80
74
|
return [str] if max <= indent + 4 || width(str) <= max
|
|
81
75
|
|
|
@@ -84,7 +78,6 @@ module Rakpak
|
|
|
84
78
|
out = []
|
|
85
79
|
line = +""
|
|
86
80
|
words = str[lead.length..].to_s.split(" ").flat_map do |word|
|
|
87
|
-
# A path with no spaces can still be wider than the panel; break it.
|
|
88
81
|
limit = max - indent
|
|
89
82
|
next word if width(word) <= limit || limit < 8
|
|
90
83
|
|
|
@@ -103,7 +96,6 @@ module Rakpak
|
|
|
103
96
|
out
|
|
104
97
|
end
|
|
105
98
|
|
|
106
|
-
# /home/me/x → ~/x. Only a real prefix counts: /home/me2 is left alone.
|
|
107
99
|
def tilde(path)
|
|
108
100
|
home = Dir.home
|
|
109
101
|
return path if home.nil? || home.empty? || home == "/"
|
|
@@ -112,8 +104,7 @@ module Rakpak
|
|
|
112
104
|
path.start_with?("#{home}/") ? "~#{path[home.length..]}" : path
|
|
113
105
|
end
|
|
114
106
|
|
|
115
|
-
#
|
|
116
|
-
# control and C1 bytes could otherwise be executed as escape sequences.
|
|
107
|
+
# Control and C1 bytes could be executed as escape sequences.
|
|
117
108
|
def plain(str)
|
|
118
109
|
str.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(/[\u0000-\u001f\u007f-\u009f]/, "?")
|
|
119
110
|
end
|
data/lib/rakpak/theme.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rakpak
|
|
4
|
-
#
|
|
5
|
-
# concatenation without needing to be self-contained.
|
|
4
|
+
# Every style is emitted after a reset, so fragments needn't be self-contained.
|
|
6
5
|
module Theme
|
|
7
6
|
RESET = "\e[0m"
|
|
8
7
|
|
data/lib/rakpak/unpack.rb
CHANGED
|
@@ -5,22 +5,11 @@ require_relative "formats"
|
|
|
5
5
|
require_relative "plan"
|
|
6
6
|
|
|
7
7
|
module Rakpak
|
|
8
|
-
# Turns one archive plus a destination into a command that unpacks it.
|
|
9
|
-
# Wears the same face as Plan, so Job runs it without knowing which way
|
|
10
|
-
# round the work goes.
|
|
11
|
-
#
|
|
12
|
-
# Three shapes, mirroring the ones Plan writes:
|
|
13
|
-
# :tar tar, decompressing through the codec if there is one
|
|
14
|
-
# :zip unzip
|
|
15
|
-
# :single one compressed file back to one plain file
|
|
16
8
|
class Unpack
|
|
17
|
-
#
|
|
18
|
-
# entry doing the compression, so the binary probing already done for
|
|
19
|
-
# packing decides whether this can be read back.
|
|
9
|
+
# `codec` is a TAR_CODECS id, so packing's binary probing decides readability.
|
|
20
10
|
Shape = Struct.new(:ext, :kind, :codec, keyword_init: true) do
|
|
21
11
|
def tool = kind == :zip ? "unzip" : "tar"
|
|
22
12
|
|
|
23
|
-
# The compressor's binary, when one is involved.
|
|
24
13
|
def bin = codec && Rakpak.tar_codec(codec)&.bin
|
|
25
14
|
end
|
|
26
15
|
|
|
@@ -47,7 +36,6 @@ module Rakpak
|
|
|
47
36
|
Shape.new(ext: ".br", kind: :single, codec: :brotli)
|
|
48
37
|
].sort_by { |s| -s.ext.length }.freeze
|
|
49
38
|
|
|
50
|
-
# nil when the name carries no extension we know how to open.
|
|
51
39
|
def self.format(path)
|
|
52
40
|
name = File.basename(path.to_s).downcase
|
|
53
41
|
SHAPES.find { |s| name.end_with?(s.ext) && name.length > s.ext.length }
|
|
@@ -55,9 +43,7 @@ module Rakpak
|
|
|
55
43
|
|
|
56
44
|
def self.archive?(path) = !format(path).nil?
|
|
57
45
|
|
|
58
|
-
# "
|
|
59
|
-
# leaves nothing, "." or ".." behind would resolve to the destination's
|
|
60
|
-
# parent once joined, so those keep the whole basename instead.
|
|
46
|
+
# A name reducing to "", "." or ".." would resolve to the parent, so keep the whole basename.
|
|
61
47
|
def self.strip_ext(path)
|
|
62
48
|
name = File.basename(path.to_s)
|
|
63
49
|
shape = format(path)
|
|
@@ -65,13 +51,10 @@ module Rakpak
|
|
|
65
51
|
["", ".", ".."].include?(stripped) ? name : stripped
|
|
66
52
|
end
|
|
67
53
|
|
|
68
|
-
# The folder to make for the contents, or nil when the archive holds a
|
|
69
|
-
# single file that should land beside you.
|
|
70
54
|
def self.default_subdir(path)
|
|
71
55
|
format(path)&.kind == :single ? nil : strip_ext(path)
|
|
72
56
|
end
|
|
73
57
|
|
|
74
|
-
# What a lone compressed file decompresses back into.
|
|
75
58
|
def self.member_name(path) = strip_ext(path)
|
|
76
59
|
|
|
77
60
|
attr_reader :archive, :shape
|
|
@@ -86,22 +69,17 @@ module Rakpak
|
|
|
86
69
|
def kind = @shape&.kind
|
|
87
70
|
def single? = kind == :single
|
|
88
71
|
|
|
89
|
-
# Job chdirs here, so it
|
|
72
|
+
# Job chdirs here, so it must exist before the first step runs.
|
|
90
73
|
def base = @dest
|
|
91
74
|
|
|
92
|
-
# A tarball or zip pours its members into the folder; a lone compressed
|
|
93
|
-
# file has one real output, and Job may replace that.
|
|
94
75
|
def outputs = [single? ? File.join(@dest, Unpack.member_name(@archive)) : @dest]
|
|
95
76
|
def output = outputs.first
|
|
96
77
|
def clobbers_output? = single?
|
|
97
78
|
|
|
98
|
-
#
|
|
99
|
-
# single byte of the archive, so it cannot be aimed at the file it is
|
|
100
|
-
# meant to replace. It writes here and is renamed over the top on success.
|
|
79
|
+
# Output is opened before the archive is read, so write here and rename over on success.
|
|
101
80
|
def scratch = File.join(@dest, ".#{Unpack.member_name(@archive)}.part")
|
|
102
81
|
|
|
103
82
|
def prepare
|
|
104
|
-
# Remember what we make, so a failure can leave the tree as it was.
|
|
105
83
|
@made = []
|
|
106
84
|
dir = @dest
|
|
107
85
|
until File.directory?(dir) || dir == "/"
|
|
@@ -111,13 +89,11 @@ module Rakpak
|
|
|
111
89
|
FileUtils.mkdir_p(@dest)
|
|
112
90
|
end
|
|
113
91
|
|
|
114
|
-
# Every step succeeded, so the recovered file may take its real name.
|
|
115
92
|
def commit
|
|
116
93
|
File.rename(scratch, output) if single? && File.exist?(scratch)
|
|
117
94
|
end
|
|
118
95
|
|
|
119
|
-
#
|
|
120
|
-
# was already on disk, or anything the run managed to write, stays.
|
|
96
|
+
# Remove only folders we created that stayed empty.
|
|
121
97
|
def rollback
|
|
122
98
|
(@made || []).each do |dir|
|
|
123
99
|
Dir.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
|
|
@@ -126,14 +102,11 @@ module Rakpak
|
|
|
126
102
|
end
|
|
127
103
|
end
|
|
128
104
|
|
|
129
|
-
# Counting
|
|
130
|
-
# first, which is most of the work. The job counts up as it goes instead.
|
|
105
|
+
# Counting members would mean decompressing the whole archive first.
|
|
131
106
|
def total_members(_sizer) = nil
|
|
132
107
|
|
|
133
108
|
def gerund = "unpacking"
|
|
134
109
|
|
|
135
|
-
# A recovered lone file can be weighed. A folder of members has no useful
|
|
136
|
-
# size of its own, so it reports what happened instead.
|
|
137
110
|
def outcome
|
|
138
111
|
return "#{File.basename(output)} #{Text.bytes(file_size(output))}" if single?
|
|
139
112
|
|
|
@@ -150,17 +123,13 @@ module Rakpak
|
|
|
150
123
|
|
|
151
124
|
def tar_argv
|
|
152
125
|
argv = ["tar", "-x", "-v"]
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
# already set; the other five ignore the repeat.
|
|
156
|
-
argv += ["--use-compress-program", @shape.bin] if @shape.bin
|
|
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
|
|
157
128
|
argv + ["-f", @archive, "-C", @dest]
|
|
158
129
|
end
|
|
159
130
|
|
|
160
131
|
def zip_argv = ["unzip", "-o", @archive, "-d", @dest]
|
|
161
132
|
|
|
162
|
-
# The compressor reading the archive and writing the plain file to
|
|
163
|
-
# stdout, which Job points at the output path.
|
|
164
133
|
def single_argv = [@shape.bin, "-dc", @archive]
|
|
165
134
|
|
|
166
135
|
# [label, argv, expects_verbose_output, stdout_path]
|
|
@@ -182,8 +151,6 @@ module Rakpak
|
|
|
182
151
|
# GNU tar and bsdtar can hand the stream to a compressor; busybox cannot.
|
|
183
152
|
def tar_pipes? = Tools.tar_pipes?
|
|
184
153
|
|
|
185
|
-
# Every tool this run needs, so a half-installed machine is caught on
|
|
186
|
-
# the confirm screen rather than partway through the extraction.
|
|
187
154
|
def tools = [@shape&.kind == :single ? nil : @shape&.tool, @shape&.bin].compact
|
|
188
155
|
|
|
189
156
|
def problems
|
|
@@ -200,8 +167,7 @@ module Rakpak
|
|
|
200
167
|
end
|
|
201
168
|
# mkdir_p would raise EEXIST partway through the run; say it up front.
|
|
202
169
|
errs << "not a folder: #{@dest}" if File.exist?(@dest) && !File.directory?(@dest)
|
|
203
|
-
#
|
|
204
|
-
# needs to do to avoid clobbering, and which warnings needs to read.
|
|
170
|
+
# tar needs to list the folder to avoid clobbering.
|
|
205
171
|
if File.directory?(@dest) && !File.readable?(@dest)
|
|
206
172
|
errs << "destination is not readable: #{@dest}"
|
|
207
173
|
end
|
|
@@ -210,8 +176,6 @@ module Rakpak
|
|
|
210
176
|
errs.uniq
|
|
211
177
|
end
|
|
212
178
|
|
|
213
|
-
# Nothing is created until the job runs, so writability is a question
|
|
214
|
-
# about the nearest folder that already exists.
|
|
215
179
|
def existing_parent(dir)
|
|
216
180
|
dir = File.dirname(dir) until File.directory?(dir) || dir == "/"
|
|
217
181
|
dir
|
|
@@ -227,8 +191,7 @@ module Rakpak
|
|
|
227
191
|
warn
|
|
228
192
|
end
|
|
229
193
|
|
|
230
|
-
#
|
|
231
|
-
# about and this must not raise, since the confirm screen draws it.
|
|
194
|
+
# Must not raise: the confirm screen calls this while drawing.
|
|
232
195
|
def empty_dir?(dir)
|
|
233
196
|
Dir.children(dir).empty?
|
|
234
197
|
rescue StandardError
|
data/lib/rakpak/version.rb
CHANGED