rakpak 1.0.0 → 1.1.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 +52 -4
- data/lib/rakpak/app.rb +111 -22
- data/lib/rakpak/browser.rb +4 -3
- data/lib/rakpak/job.rb +61 -10
- data/lib/rakpak/modal.rb +2 -2
- data/lib/rakpak/plan.rb +35 -0
- data/lib/rakpak/unpack.rb +238 -0
- data/lib/rakpak/version.rb +1 -1
- data/lib/rakpak.rb +94 -7
- metadata +10 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98de68901620153b4b19a5ff3ccf6b9757f8e718db3a4bcef3d70b81b75e5a76
|
|
4
|
+
data.tar.gz: 5b7a9592e10e1b4181d4ba5aceaba0dc8fc70e6b3286f6660e4e10cdc9b4c287
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 544d49d201309db5d5d90fb692f78e7e103280b06f7373ad872f21cc90374763c650da2f02fb5f006303e7dbd296df8d32f2f6d8eb7924b096d15d3960c5ef8b
|
|
7
|
+
data.tar.gz: 802d440f759bf5648199f07e6b98b934eda43e94b08133cdb3c07da9bed16b900cb2c0456c436ef9f2932bc4a3bcc26dbdd70b6c13b31722007864ee622c047f
|
data/README.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://res.cloudinary.com/noqoikpl/image/upload/f_auto,q_auto/rakpak-upper" alt="rakpak" width="520">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/eof0/rakpak/actions/workflows/ci.yml"><img src="https://github.com/eof0/rakpak/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
|
|
7
|
+
<a href="https://rubygems.org/gems/rakpak"><img src="https://img.shields.io/gem/v/rakpak" alt="gem version"></a>
|
|
8
|
+
<a href="https://rubygems.org/gems/rakpak"><img src="https://img.shields.io/gem/dt/rakpak" alt="downloads"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/eof0/rakpak" alt="license"></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">Tag files and folders anywhere on your filesystem, then archive them all at once, or unpack one you already have.</p>
|
|
4
13
|
|
|
5
14
|
You walk around, press `space` on anything you want, press `p`, answer a few
|
|
6
15
|
questions, and it builds the archive. The progress view can be sent to the
|
|
7
|
-
background while you keep browsing.
|
|
16
|
+
background while you keep browsing. Press `u` on an archive instead and it
|
|
17
|
+
unpacks, wherever you want it.
|
|
8
18
|
|
|
9
19
|
Needs Ruby 3.0 or newer and nothing else.
|
|
10
20
|
|
|
@@ -30,6 +40,8 @@ rakpak ~/Documents browse from there instead
|
|
|
30
40
|
rakpak . browse from the current folder
|
|
31
41
|
rakpak -p ~/Documents pack that folder: opens on the archive prompts
|
|
32
42
|
rakpak -p a.txt b/ c pack several things at once
|
|
43
|
+
rakpak -d site.tar.gz unpack it here and now, into site/
|
|
44
|
+
rakpak -d a.tgz b.zip unpack several, each into its own folder
|
|
33
45
|
```
|
|
34
46
|
|
|
35
47
|
## Starting up
|
|
@@ -44,6 +56,13 @@ it, and the archive prompts appear at once, starting with the kind of archive
|
|
|
44
56
|
and its compression. The archive is written next to that first path. Press
|
|
45
57
|
`esc` on any prompt and you are back in the browser with the tags still set.
|
|
46
58
|
|
|
59
|
+
`-d` (or `--depack`) does not open the browser at all. Each archive is
|
|
60
|
+
unpacked straight into the folder you are standing in, the way `tar` would,
|
|
61
|
+
each one into a folder of its own name: `site.tar.gz` gives you `site/`.
|
|
62
|
+
A lone compressed file has nothing to wrap, so `notes.txt.gz` lands
|
|
63
|
+
beside you as `notes.txt`. One bad archive does not stop the rest, and the
|
|
64
|
+
exit status is non-zero if any of them failed.
|
|
65
|
+
|
|
47
66
|
## The flow
|
|
48
67
|
|
|
49
68
|
1. **Browse and tag.** Vim keys or arrows. `space` tags whatever is under the
|
|
@@ -67,6 +86,24 @@ and its compression. The archive is written next to that first path. Press
|
|
|
67
86
|
|
|
68
87
|
If nothing is tagged, `p` archives whatever the cursor is on.
|
|
69
88
|
|
|
89
|
+
## Unpacking
|
|
90
|
+
|
|
91
|
+
`u` on an archive opens three prompts: which folder to unpack into, what to
|
|
92
|
+
call the folder it makes there, and the usual confirm screen showing the
|
|
93
|
+
exact command.
|
|
94
|
+
|
|
95
|
+
Unlike `p`, this follows the cursor rather than the tag set: one archive, one
|
|
96
|
+
destination. The folder is offered as the archive's own name with the
|
|
97
|
+
extension taken off, so `site.tar.gz` suggests `site/`, which means nothing
|
|
98
|
+
is ever sprayed across the folder you are standing in. Type `.` as
|
|
99
|
+
the name to unpack straight into the folder you picked instead, and the
|
|
100
|
+
folder prompt takes `~`, `$HOME` and any absolute path, so the contents can
|
|
101
|
+
go anywhere. A lone compressed file skips the folder entirely.
|
|
102
|
+
|
|
103
|
+
If the destination already has files in it, the confirm screen says so before
|
|
104
|
+
anything runs. A failed extraction leaves whatever it managed to write; the
|
|
105
|
+
files that were already there are never touched.
|
|
106
|
+
|
|
70
107
|
## Keys
|
|
71
108
|
|
|
72
109
|
| | |
|
|
@@ -86,6 +123,7 @@ If nothing is tagged, `p` archives whatever the cursor is on.
|
|
|
86
123
|
| `.` | show hidden files |
|
|
87
124
|
| `ctrl-r` | reload |
|
|
88
125
|
| `p` | pack: archive what is tagged |
|
|
126
|
+
| `u` | unpack the archive under the cursor |
|
|
89
127
|
| `b` | watch a running job |
|
|
90
128
|
| `?` | all keys |
|
|
91
129
|
| `q` | quit |
|
|
@@ -110,9 +148,16 @@ here too, so `notes.txt` becomes `notes.txt.gz` or `notes.txt.zst` without a
|
|
|
110
148
|
tarball around it. For a folder those are greyed out with the reason, since
|
|
111
149
|
`gzip` alone cannot take a folder.
|
|
112
150
|
|
|
151
|
+
Every one of those can be read back: `.tar`, `.tar.gz`, `.tar.zst`, `.tar.xz`,
|
|
152
|
+
`.tar.bz2`, `.tar.lz4`, `.tar.br`, the `.tgz`, `.tzst`, `.txz` and `.tbz2`
|
|
153
|
+
short forms, `.zip` via `unzip`, and `.gz`, `.zst`, `.xz`, `.bz2`, `.lz4` or
|
|
154
|
+
`.br` on their own.
|
|
155
|
+
|
|
113
156
|
Everything is probed at startup: `PATH` for the compressors, `tar --version` to
|
|
114
157
|
tell GNU tar from bsdtar from busybox, and `zip -v` for its compiled-in methods.
|
|
115
158
|
Anything that will not work on this machine is greyed out with the reason.
|
|
159
|
+
Unpacking a `.zip` wants `unzip`, which is a different program from the `zip`
|
|
160
|
+
used to write one, so it is checked on its own.
|
|
116
161
|
|
|
117
162
|
## How the archive is shaped
|
|
118
163
|
|
|
@@ -124,7 +169,10 @@ archive holds `a/b` and `c`.
|
|
|
124
169
|
|
|
125
170
|
- Commands are run directly, not in a shell. Filenames containing spaces,
|
|
126
171
|
quotes, `$`, `;` or a leading `-` are safe.
|
|
127
|
-
- Cancelling kills the whole process group, so the compressor goes too.
|
|
172
|
+
- Cancelling kills the whole process group, so the compressor goes too. A
|
|
173
|
+
cancelled pack removes its half-written archive; a cancelled unpack leaves
|
|
174
|
+
the files it had already extracted, since deleting a folder is not
|
|
175
|
+
rakpak's call to make.
|
|
128
176
|
- Folder sizes are counted in the background. A folder too large to finish
|
|
129
177
|
counting in a few seconds shows `≥`, meaning the real size is at least that.
|
|
130
178
|
|
data/lib/rakpak/app.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "browser"
|
|
|
6
6
|
require_relative "modal"
|
|
7
7
|
require_relative "formats"
|
|
8
8
|
require_relative "plan"
|
|
9
|
+
require_relative "unpack"
|
|
9
10
|
require_relative "job"
|
|
10
11
|
require_relative "sizer"
|
|
11
12
|
|
|
@@ -63,6 +64,7 @@ module Rakpak
|
|
|
63
64
|
class App
|
|
64
65
|
SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
65
66
|
WIZARD = %i[target options where output confirm].freeze
|
|
67
|
+
UNPACK = %i[dest_where dest_name unpack_confirm].freeze
|
|
66
68
|
|
|
67
69
|
# `pack` is a list of paths to tag before the first frame; when it is
|
|
68
70
|
# non-empty the archive prompts open immediately (rakpak -p).
|
|
@@ -81,6 +83,7 @@ module Rakpak
|
|
|
81
83
|
@notice = nil
|
|
82
84
|
@notice_until = nil
|
|
83
85
|
@plan = nil
|
|
86
|
+
@wizard = WIZARD
|
|
84
87
|
@wizard_pos = nil
|
|
85
88
|
@quit = false
|
|
86
89
|
return if pack.empty?
|
|
@@ -194,24 +197,30 @@ module Rakpak
|
|
|
194
197
|
@screen.put([(@screen.w - Text.width(want)) / 2, 0].max, (@screen.h / 2) + 1, want, Theme::DIM)
|
|
195
198
|
end
|
|
196
199
|
|
|
200
|
+
# Only the running state says which direction the work goes; the rest
|
|
201
|
+
# read the same either way.
|
|
202
|
+
def job_title(job, state)
|
|
203
|
+
case state
|
|
204
|
+
when :running then job.plan.gerund
|
|
205
|
+
when :done then "finished"
|
|
206
|
+
when :failed then "failed"
|
|
207
|
+
when :cancelled then "cancelled"
|
|
208
|
+
else "queued"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
197
212
|
def draw_job(job)
|
|
198
213
|
s = @screen
|
|
199
214
|
s.fill(0, 0, s.w, 1, " ", Theme::HEAD)
|
|
200
|
-
title = case job.state
|
|
201
|
-
when :running then "archiving"
|
|
202
|
-
when :done then "finished"
|
|
203
|
-
when :failed then "failed"
|
|
204
|
-
when :cancelled then "cancelled"
|
|
205
|
-
else "queued"
|
|
206
|
-
end
|
|
207
215
|
s.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
|
|
208
|
-
s.put(9, 0,
|
|
216
|
+
s.put(9, 0, job_title(job, job.state), Theme::HEAD + "\e[38;5;231m")
|
|
209
217
|
s.put(s.w - 20, 0, Text.duration(job.elapsed), Theme::HEAD + Theme::DIM)
|
|
210
218
|
|
|
211
219
|
y = 2
|
|
212
220
|
job.plan.outputs.each do |out|
|
|
221
|
+
# A folder being extracted into has no size worth showing.
|
|
213
222
|
size = begin
|
|
214
|
-
File.size(out)
|
|
223
|
+
File.directory?(out) ? nil : File.size(out)
|
|
215
224
|
rescue StandardError
|
|
216
225
|
nil
|
|
217
226
|
end
|
|
@@ -287,6 +296,7 @@ module Rakpak
|
|
|
287
296
|
case @browser.handle(key)
|
|
288
297
|
when :quit then request_quit
|
|
289
298
|
when :archive then start_wizard
|
|
299
|
+
when :unpack then start_unpack
|
|
290
300
|
when :tags then open_tags
|
|
291
301
|
when :help then open_help
|
|
292
302
|
when :jobs then open_jobs
|
|
@@ -351,6 +361,32 @@ module Rakpak
|
|
|
351
361
|
@name_edited = false
|
|
352
362
|
@where = 0
|
|
353
363
|
@where_text = ""
|
|
364
|
+
@wizard = WIZARD
|
|
365
|
+
@wizard_pos = 0
|
|
366
|
+
open_wizard_step
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Unpacking follows the cursor rather than the tag set: an archive is one
|
|
370
|
+
# thing with one destination, not a pile to gather up.
|
|
371
|
+
def start_unpack
|
|
372
|
+
e = @browser.current
|
|
373
|
+
unless e && File.file?(e.path) && Unpack.archive?(e.path)
|
|
374
|
+
@modal = MessageModal.new(
|
|
375
|
+
title: "not an archive",
|
|
376
|
+
lines: ["rakpak unpacks tarballs, zips and single",
|
|
377
|
+
"compressed files: .tar, .tar.gz, .tar.zst,",
|
|
378
|
+
".tar.xz, .tar.bz2, .tar.lz4, .tar.br, .zip,",
|
|
379
|
+
"and .gz, .zst, .xz, .bz2, .lz4, .br on their own."],
|
|
380
|
+
style: Theme::WARN
|
|
381
|
+
)
|
|
382
|
+
return
|
|
383
|
+
end
|
|
384
|
+
@plan = Unpack.new(archive: e.path, dest: @browser.cwd)
|
|
385
|
+
@dest_parent = @browser.cwd
|
|
386
|
+
@dest_name = Unpack.default_subdir(e.path) || "."
|
|
387
|
+
@where = 0
|
|
388
|
+
@where_text = ""
|
|
389
|
+
@wizard = UNPACK
|
|
354
390
|
@wizard_pos = 0
|
|
355
391
|
open_wizard_step
|
|
356
392
|
end
|
|
@@ -369,7 +405,7 @@ module Rakpak
|
|
|
369
405
|
|
|
370
406
|
def open_wizard_step
|
|
371
407
|
loop do
|
|
372
|
-
step =
|
|
408
|
+
step = @wizard[@wizard_pos]
|
|
373
409
|
return finish_wizard if step.nil?
|
|
374
410
|
|
|
375
411
|
modal = build_step(step)
|
|
@@ -383,7 +419,7 @@ module Rakpak
|
|
|
383
419
|
end
|
|
384
420
|
|
|
385
421
|
def wizard_forward(modal)
|
|
386
|
-
apply_step(
|
|
422
|
+
apply_step(@wizard[@wizard_pos], modal)
|
|
387
423
|
return if @wizard_pos.nil? # a step may have ended the wizard itself
|
|
388
424
|
|
|
389
425
|
@wizard_pos += 1
|
|
@@ -398,7 +434,7 @@ module Rakpak
|
|
|
398
434
|
@plan = nil
|
|
399
435
|
return
|
|
400
436
|
end
|
|
401
|
-
modal = build_step(
|
|
437
|
+
modal = build_step(@wizard[@wizard_pos])
|
|
402
438
|
next if modal.nil?
|
|
403
439
|
|
|
404
440
|
@modal = modal
|
|
@@ -413,6 +449,9 @@ module Rakpak
|
|
|
413
449
|
when :where then where_modal
|
|
414
450
|
when :output then output_modal
|
|
415
451
|
when :confirm then confirm_modal
|
|
452
|
+
when :dest_where then dest_where_modal
|
|
453
|
+
when :dest_name then dest_name_modal
|
|
454
|
+
when :unpack_confirm then unpack_confirm_modal
|
|
416
455
|
end
|
|
417
456
|
end
|
|
418
457
|
|
|
@@ -429,6 +468,15 @@ module Rakpak
|
|
|
429
468
|
@name_edited = true
|
|
430
469
|
@plan.basename = modal.result
|
|
431
470
|
when :confirm then launch
|
|
471
|
+
when :dest_where
|
|
472
|
+
@where = modal.index
|
|
473
|
+
@where_text = modal.text
|
|
474
|
+
@dest_parent = @where == 2 ? Rakpak.expand_dir(modal.result) : modal.result
|
|
475
|
+
sync_dest
|
|
476
|
+
when :dest_name
|
|
477
|
+
@dest_name = modal.result
|
|
478
|
+
sync_dest
|
|
479
|
+
when :unpack_confirm then launch
|
|
432
480
|
end
|
|
433
481
|
end
|
|
434
482
|
|
|
@@ -494,14 +542,57 @@ module Rakpak
|
|
|
494
542
|
|
|
495
543
|
def where_modal
|
|
496
544
|
WhereModal.new(here: @browser.cwd, home: Dir.home, index: @where, text: @where_text,
|
|
497
|
-
validate:
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
545
|
+
validate: method(:writable_folder))
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def writable_folder(text)
|
|
549
|
+
d = Rakpak.expand_dir(text)
|
|
550
|
+
if !File.directory?(d) then "not a folder: #{d}"
|
|
551
|
+
elsif !File.writable?(d) then "not writable: #{d}"
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def dest_where_modal
|
|
556
|
+
WhereModal.new(here: @browser.cwd, home: Dir.home, index: @where, text: @where_text,
|
|
557
|
+
validate: method(:writable_folder), title: "unpack it where?")
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# nil when there is no folder to name, which open_wizard_step skips over.
|
|
561
|
+
def dest_name_modal
|
|
562
|
+
return nil if @plan.single?
|
|
563
|
+
|
|
564
|
+
InputModal.new(title: "unpack into",
|
|
565
|
+
value: @dest_name,
|
|
566
|
+
hint: "a new folder in #{Text.tilde(@dest_parent)} · . unpacks straight in",
|
|
567
|
+
validate: lambda { |name|
|
|
568
|
+
# The folder was chosen on the previous screen, so this
|
|
569
|
+
# is a bare name and can never reach outside it.
|
|
570
|
+
if name.include?("/") then "just a name, no slashes; the folder was picked already"
|
|
571
|
+
elsif name.include?("\0") || name == ".." then "not a valid name"
|
|
501
572
|
end
|
|
502
573
|
})
|
|
503
574
|
end
|
|
504
575
|
|
|
576
|
+
# "." is how you say "no subfolder, straight into the folder I picked",
|
|
577
|
+
# and a lone compressed file never has one to begin with.
|
|
578
|
+
def sync_dest
|
|
579
|
+
@plan.dest = @dest_name == "." ? @dest_parent : File.join(@dest_parent, @dest_name)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def unpack_confirm_modal
|
|
583
|
+
lines = [[:head, "unpacks #{Text.tilde(@plan.archive)}"],
|
|
584
|
+
[:key, ""],
|
|
585
|
+
[:head, "into #{Text.tilde(@plan.dest)}"],
|
|
586
|
+
[:key, ""]]
|
|
587
|
+
@plan.preview.each do |label, cmd|
|
|
588
|
+
lines << [:head, "#{label}:"]
|
|
589
|
+
lines << [:cmd, " #{cmd}"]
|
|
590
|
+
end
|
|
591
|
+
lines << [:key, ""]
|
|
592
|
+
ConfirmModal.new(title: "ready", lines: lines,
|
|
593
|
+
warnings: @plan.warnings, errors: @plan.problems)
|
|
594
|
+
end
|
|
595
|
+
|
|
505
596
|
def output_modal
|
|
506
597
|
@plan.basename = default_basename(@plan.paths) unless @name_edited
|
|
507
598
|
InputModal.new(title: "output name",
|
|
@@ -540,8 +631,7 @@ module Rakpak
|
|
|
540
631
|
|
|
541
632
|
# Always opens the job view; b from there sends it to the background.
|
|
542
633
|
def launch
|
|
543
|
-
|
|
544
|
-
job = Job.new(@plan, total_files: total.files.positive? ? total.files : nil).start
|
|
634
|
+
job = Job.new(@plan, total_files: @plan.total_members(@sizer)).start
|
|
545
635
|
@jobs << job
|
|
546
636
|
@focus_job = job
|
|
547
637
|
@wizard_pos = nil
|
|
@@ -557,9 +647,7 @@ module Rakpak
|
|
|
557
647
|
next if @mode == :job && @focus_job == j
|
|
558
648
|
|
|
559
649
|
case j.state
|
|
560
|
-
when :done
|
|
561
|
-
sizes = j.summary.map { |n, sz| "#{n} #{Text.bytes(sz)}" }.join(" · ")
|
|
562
|
-
notify("✓ #{sizes}", Theme::OK, 8)
|
|
650
|
+
when :done then notify("✓ #{j.plan.outcome}", Theme::OK, 8)
|
|
563
651
|
when :failed then notify("✗ #{j.error}", Theme::ERR, 10)
|
|
564
652
|
when :cancelled then notify("cancelled", Theme::WARN)
|
|
565
653
|
end
|
|
@@ -597,6 +685,7 @@ module Rakpak
|
|
|
597
685
|
["/", "filter this folder"], [".", "show hidden"],
|
|
598
686
|
["ctrl-r", "reload"],
|
|
599
687
|
["p", "pack: archive the tagged items"],
|
|
688
|
+
["u", "unpack the archive under the cursor"],
|
|
600
689
|
["b", "watch running jobs"], ["q", "quit"]
|
|
601
690
|
]
|
|
602
691
|
w = keys.map { |k, _| Text.width(k) }.max
|
|
@@ -627,7 +716,7 @@ module Rakpak
|
|
|
627
716
|
done.each do |j|
|
|
628
717
|
# Printed after the TUI has gone, straight to the shell, so a folder
|
|
629
718
|
# or file name carrying an escape sequence must be defanged.
|
|
630
|
-
|
|
719
|
+
puts "#{Text.plain(j.plan.outputs.first)} #{Text.plain(j.plan.report_note)}"
|
|
631
720
|
end
|
|
632
721
|
end
|
|
633
722
|
end
|
data/lib/rakpak/browser.rb
CHANGED
|
@@ -159,7 +159,7 @@ module Rakpak
|
|
|
159
159
|
!idx.nil?
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
# Returns :quit, :archive, :tags, :help, :jobs or nil.
|
|
162
|
+
# Returns :quit, :archive, :unpack, :tags, :help, :jobs or nil.
|
|
163
163
|
def handle(key)
|
|
164
164
|
return handle_filter(key) if @filtering
|
|
165
165
|
|
|
@@ -197,12 +197,13 @@ module Rakpak
|
|
|
197
197
|
when :ctrl_r then refresh!
|
|
198
198
|
when "~" then goto(Dir.home)
|
|
199
199
|
when "p" then :archive
|
|
200
|
+
when "u" then :unpack
|
|
200
201
|
when "T" then :tags
|
|
201
202
|
when "?" then :help
|
|
202
203
|
when "b" then :jobs
|
|
203
204
|
when "q", :ctrl_c then :quit
|
|
204
205
|
end
|
|
205
|
-
%i[archive tags help jobs quit].include?(result) ? result : nil
|
|
206
|
+
%i[archive unpack tags help jobs quit].include?(result) ? result : nil
|
|
206
207
|
end
|
|
207
208
|
|
|
208
209
|
def toggle_hidden
|
|
@@ -487,7 +488,7 @@ module Rakpak
|
|
|
487
488
|
end
|
|
488
489
|
|
|
489
490
|
HINTS = [["space", "tag"], ["←→", "nav"], ["a", "all"], ["t", "queue"], ["/", "find"],
|
|
490
|
-
[".", "hidden"], ["p", "pack"], ["?", "help"]].freeze
|
|
491
|
+
[".", "hidden"], ["p", "pack"], ["u", "unpack"], ["?", "help"]].freeze
|
|
491
492
|
|
|
492
493
|
def draw_footer(screen, y, status_line)
|
|
493
494
|
screen.fill(0, y, screen.w, 1, " ", nil)
|
data/lib/rakpak/job.rb
CHANGED
|
@@ -67,16 +67,24 @@ module Rakpak
|
|
|
67
67
|
[base + (per * [@file_count.to_f / @total_files, 1.0].min), 1.0].min
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# Bytes on disk for whatever this step is writing right now.
|
|
70
|
+
# Bytes on disk for whatever this step is writing right now. A folder
|
|
71
|
+
# being extracted into has only its own inode size, which means nothing.
|
|
71
72
|
def output_size
|
|
72
|
-
out =
|
|
73
|
-
return nil
|
|
73
|
+
out = writing_now
|
|
74
|
+
return nil if out.nil? || File.directory?(out)
|
|
74
75
|
|
|
75
76
|
File.size(out)
|
|
76
77
|
rescue StandardError
|
|
77
78
|
nil
|
|
78
79
|
end
|
|
79
80
|
|
|
81
|
+
# A step redirected to a file is writing that file; otherwise the tool is
|
|
82
|
+
# writing the plan's output itself.
|
|
83
|
+
def writing_now
|
|
84
|
+
i = [@step_index, @steps.size - 1].min
|
|
85
|
+
@steps[i]&.[](3) || @plan.outputs[[i, @plan.outputs.size - 1].min]
|
|
86
|
+
end
|
|
87
|
+
|
|
80
88
|
def summary
|
|
81
89
|
@plan.outputs.map do |o|
|
|
82
90
|
size = begin
|
|
@@ -132,6 +140,7 @@ module Rakpak
|
|
|
132
140
|
end
|
|
133
141
|
|
|
134
142
|
def run_all
|
|
143
|
+
@plan.prepare
|
|
135
144
|
@steps.each_with_index do |(label, argv, verbose, stdout), idx|
|
|
136
145
|
@step_index = idx
|
|
137
146
|
@file_count = 0
|
|
@@ -149,6 +158,7 @@ module Rakpak
|
|
|
149
158
|
end
|
|
150
159
|
@spawned = nil
|
|
151
160
|
end
|
|
161
|
+
@plan.commit
|
|
152
162
|
finish(:done)
|
|
153
163
|
rescue StandardError => e
|
|
154
164
|
@error = "#{e.class}: #{e.message}"
|
|
@@ -169,7 +179,10 @@ module Rakpak
|
|
|
169
179
|
end
|
|
170
180
|
|
|
171
181
|
def finish(state)
|
|
172
|
-
|
|
182
|
+
if %i[failed cancelled].include?(state)
|
|
183
|
+
cleanup_incomplete
|
|
184
|
+
@plan.rollback
|
|
185
|
+
end
|
|
173
186
|
@state = state
|
|
174
187
|
@finished_at = now
|
|
175
188
|
end
|
|
@@ -178,7 +191,13 @@ module Rakpak
|
|
|
178
191
|
# members, then fails. Remove it, but only the output of the step that
|
|
179
192
|
# was actually interrupted. Archives finished by earlier steps are whole
|
|
180
193
|
# and must survive, and a step that never spawned wrote nothing.
|
|
194
|
+
#
|
|
195
|
+
# An extraction is the other way round: its output is a folder full of
|
|
196
|
+
# files that were there before, or are the part of the job that did
|
|
197
|
+
# work. Those are not ours to throw away.
|
|
181
198
|
def cleanup_incomplete
|
|
199
|
+
return unless @plan.clobbers_output?
|
|
200
|
+
|
|
182
201
|
path = @spawned
|
|
183
202
|
return unless path && File.exist?(path)
|
|
184
203
|
|
|
@@ -191,25 +210,31 @@ module Rakpak
|
|
|
191
210
|
# `stdout` names a file the command's output is the archive for (gzip -c);
|
|
192
211
|
# otherwise stdout joins stderr in the log.
|
|
193
212
|
def run_step(argv, stdout = nil)
|
|
194
|
-
out = @plan.outputs[@step_index]
|
|
195
|
-
# Every tool
|
|
196
|
-
# screen has said an existing one will be overwritten.
|
|
197
|
-
# otherwise update it in place, keeping members that no
|
|
198
|
-
|
|
213
|
+
out = stdout || @plan.outputs[@step_index]
|
|
214
|
+
# Every tool asked to create an archive gets a clear path to write to,
|
|
215
|
+
# and the confirm screen has said an existing one will be overwritten.
|
|
216
|
+
# zip would otherwise update it in place, keeping members that no
|
|
217
|
+
# longer exist. An extraction writes into a folder instead, which must
|
|
218
|
+
# be left exactly as it is.
|
|
219
|
+
clear_path(out) if @plan.clobbers_output?
|
|
199
220
|
@spawned = out
|
|
200
221
|
|
|
201
222
|
rd, wr = IO.pipe
|
|
223
|
+
sink = nil
|
|
202
224
|
begin
|
|
203
|
-
|
|
225
|
+
sink = open_sink(stdout) if stdout
|
|
226
|
+
pid = Process.spawn(*argv, out: sink || wr, err: wr, in: File::NULL,
|
|
204
227
|
pgroup: true, chdir: @plan.base)
|
|
205
228
|
rescue StandardError => e
|
|
206
229
|
# Nothing was spawned, so nothing will close these for us.
|
|
207
230
|
rd.close
|
|
208
231
|
wr.close
|
|
232
|
+
sink&.close
|
|
209
233
|
@error = e.message
|
|
210
234
|
return nil
|
|
211
235
|
end
|
|
212
236
|
wr.close
|
|
237
|
+
sink&.close
|
|
213
238
|
@pid = pid
|
|
214
239
|
# A cancel that raced the spawn saw no pid to signal; do it now.
|
|
215
240
|
kill_current if @cancel
|
|
@@ -244,6 +269,32 @@ module Rakpak
|
|
|
244
269
|
status
|
|
245
270
|
end
|
|
246
271
|
|
|
272
|
+
# Never a directory: that is a destination, not something we wrote.
|
|
273
|
+
#
|
|
274
|
+
# lstat, not exist?: a symlink whose target is missing does not "exist",
|
|
275
|
+
# and leaving one here would mean opening the path later and writing
|
|
276
|
+
# through it to wherever it points. Remove the link itself.
|
|
277
|
+
def clear_path(path)
|
|
278
|
+
return unless path
|
|
279
|
+
|
|
280
|
+
st = begin
|
|
281
|
+
File.lstat(path)
|
|
282
|
+
rescue StandardError
|
|
283
|
+
return
|
|
284
|
+
end
|
|
285
|
+
File.unlink(path) unless st.directory?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The tool must never open its own output: between clearing the path and
|
|
289
|
+
# the spawn, anything already sitting there could be a symlink out of the
|
|
290
|
+
# folder. O_EXCL means we either create the file or refuse to write at
|
|
291
|
+
# all, and O_NOFOLLOW refuses a link even if one appears first.
|
|
292
|
+
SINK_FLAGS = File::WRONLY | File::CREAT | File::EXCL |
|
|
293
|
+
(defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
|
|
294
|
+
|
|
295
|
+
# 0666 so the result lands on the user's umask, as a shell redirect would.
|
|
296
|
+
def open_sink(path) = File.open(path, SINK_FLAGS, 0o666)
|
|
297
|
+
|
|
247
298
|
def record(line)
|
|
248
299
|
return if line.empty?
|
|
249
300
|
|
data/lib/rakpak/modal.rb
CHANGED
|
@@ -364,8 +364,8 @@ module Rakpak
|
|
|
364
364
|
class WhereModal < Modal
|
|
365
365
|
attr_reader :index
|
|
366
366
|
|
|
367
|
-
def initialize(here:, home:, index: 0, text: "", validate: nil)
|
|
368
|
-
super(title:
|
|
367
|
+
def initialize(here:, home:, index: 0, text: "", validate: nil, title: "save it where?")
|
|
368
|
+
super(title: title, footer: "1-3 or ↑↓ pick · enter choose · esc back")
|
|
369
369
|
@choices = [["This directory", here], ["Home directory", home], ["Specify", nil]]
|
|
370
370
|
@index = index
|
|
371
371
|
@field = InputModal.new(title: "", value: text, validate: validate)
|
data/lib/rakpak/plan.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "formats"
|
|
4
|
+
require_relative "text"
|
|
4
5
|
|
|
5
6
|
module Rakpak
|
|
6
7
|
# Turns a set of tagged paths plus the wizard's answers into one concrete
|
|
@@ -159,6 +160,40 @@ module Rakpak
|
|
|
159
160
|
def output = File.join(@outdir, ensure_ext(@basename, ext))
|
|
160
161
|
def outputs = [output]
|
|
161
162
|
|
|
163
|
+
# Nothing to make ready: the destination folder was checked, not created,
|
|
164
|
+
# and every step writes straight to its final name.
|
|
165
|
+
def prepare; end
|
|
166
|
+
def commit; end
|
|
167
|
+
def rollback; end
|
|
168
|
+
|
|
169
|
+
# What the job view calls this work while it runs.
|
|
170
|
+
def gerund = "archiving"
|
|
171
|
+
|
|
172
|
+
# One line for the notice when it finishes.
|
|
173
|
+
def outcome
|
|
174
|
+
outputs.map { |o| "#{File.basename(o)} #{Text.bytes(file_size(o))}" }.join(" · ")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# What follows the path on the line printed to the shell afterwards.
|
|
178
|
+
def report_note = Text.bytes(file_size(output))
|
|
179
|
+
|
|
180
|
+
def file_size(path)
|
|
181
|
+
File.size(path)
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# How many members the job should expect, for the progress bar. The
|
|
187
|
+
# sizer has been counting the selection since it was tagged.
|
|
188
|
+
def total_members(sizer)
|
|
189
|
+
files = sizer.total(@paths).files
|
|
190
|
+
files.positive? ? files : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Every step here writes one archive file, and the confirm screen has
|
|
194
|
+
# said an existing one goes.
|
|
195
|
+
def clobbers_output? = true
|
|
196
|
+
|
|
162
197
|
# Extensions a user might type that we would otherwise double up.
|
|
163
198
|
ARCHIVE_EXTS = (TAR_CODECS.map(&:ext) + TAR_CODECS.map(&:single_ext) + %w[.tgz .tbz2 .txz .zip])
|
|
164
199
|
.reject(&:empty?).uniq.sort_by { |e| -e.length }.freeze
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "formats"
|
|
5
|
+
require_relative "plan"
|
|
6
|
+
|
|
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
|
+
class Unpack
|
|
17
|
+
# An archive shape recognised by extension. `codec` names the TAR_CODECS
|
|
18
|
+
# entry doing the compression, so the binary probing already done for
|
|
19
|
+
# packing decides whether this can be read back.
|
|
20
|
+
Shape = Struct.new(:ext, :kind, :codec, keyword_init: true) do
|
|
21
|
+
def tool = kind == :zip ? "unzip" : "tar"
|
|
22
|
+
|
|
23
|
+
# The compressor's binary, when one is involved.
|
|
24
|
+
def bin = codec && Rakpak.tar_codec(codec)&.bin
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Longest extension first, so notes.tar.gz never reads as a bare .gz.
|
|
28
|
+
SHAPES = [
|
|
29
|
+
Shape.new(ext: ".tar", kind: :tar),
|
|
30
|
+
Shape.new(ext: ".tar.gz", kind: :tar, codec: :gzip),
|
|
31
|
+
Shape.new(ext: ".tar.zst", kind: :tar, codec: :zstd),
|
|
32
|
+
Shape.new(ext: ".tar.xz", kind: :tar, codec: :xz),
|
|
33
|
+
Shape.new(ext: ".tar.bz2", kind: :tar, codec: :bzip2),
|
|
34
|
+
Shape.new(ext: ".tar.lz4", kind: :tar, codec: :lz4),
|
|
35
|
+
Shape.new(ext: ".tar.br", kind: :tar, codec: :brotli),
|
|
36
|
+
Shape.new(ext: ".tgz", kind: :tar, codec: :gzip),
|
|
37
|
+
Shape.new(ext: ".tzst", kind: :tar, codec: :zstd),
|
|
38
|
+
Shape.new(ext: ".txz", kind: :tar, codec: :xz),
|
|
39
|
+
Shape.new(ext: ".tbz2", kind: :tar, codec: :bzip2),
|
|
40
|
+
Shape.new(ext: ".tbz", kind: :tar, codec: :bzip2),
|
|
41
|
+
Shape.new(ext: ".zip", kind: :zip),
|
|
42
|
+
Shape.new(ext: ".gz", kind: :single, codec: :gzip),
|
|
43
|
+
Shape.new(ext: ".zst", kind: :single, codec: :zstd),
|
|
44
|
+
Shape.new(ext: ".xz", kind: :single, codec: :xz),
|
|
45
|
+
Shape.new(ext: ".bz2", kind: :single, codec: :bzip2),
|
|
46
|
+
Shape.new(ext: ".lz4", kind: :single, codec: :lz4),
|
|
47
|
+
Shape.new(ext: ".br", kind: :single, codec: :brotli)
|
|
48
|
+
].sort_by { |s| -s.ext.length }.freeze
|
|
49
|
+
|
|
50
|
+
# nil when the name carries no extension we know how to open.
|
|
51
|
+
def self.format(path)
|
|
52
|
+
name = File.basename(path.to_s).downcase
|
|
53
|
+
SHAPES.find { |s| name.end_with?(s.ext) && name.length > s.ext.length }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.archive?(path) = !format(path).nil?
|
|
57
|
+
|
|
58
|
+
# "notes.tar.gz" -> "notes"; the whole archive extension goes. A name that
|
|
59
|
+
# leaves nothing, "." or ".." behind would resolve to the destination's
|
|
60
|
+
# parent once joined, so those keep the whole basename instead.
|
|
61
|
+
def self.strip_ext(path)
|
|
62
|
+
name = File.basename(path.to_s)
|
|
63
|
+
shape = format(path)
|
|
64
|
+
stripped = shape ? name[0...-shape.ext.length] : name
|
|
65
|
+
["", ".", ".."].include?(stripped) ? name : stripped
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The folder to make for the contents, or nil when the archive holds a
|
|
69
|
+
# single file that should land beside you.
|
|
70
|
+
def self.default_subdir(path)
|
|
71
|
+
format(path)&.kind == :single ? nil : strip_ext(path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# What a lone compressed file decompresses back into.
|
|
75
|
+
def self.member_name(path) = strip_ext(path)
|
|
76
|
+
|
|
77
|
+
attr_reader :archive, :shape
|
|
78
|
+
attr_accessor :dest
|
|
79
|
+
|
|
80
|
+
def initialize(archive:, dest:)
|
|
81
|
+
@archive = File.expand_path(archive)
|
|
82
|
+
@dest = File.expand_path(dest)
|
|
83
|
+
@shape = Unpack.format(@archive)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def kind = @shape&.kind
|
|
87
|
+
def single? = kind == :single
|
|
88
|
+
|
|
89
|
+
# Job chdirs here, so it has to exist by the time the first step runs.
|
|
90
|
+
def base = @dest
|
|
91
|
+
|
|
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
|
+
def outputs = [single? ? File.join(@dest, Unpack.member_name(@archive)) : @dest]
|
|
95
|
+
def output = outputs.first
|
|
96
|
+
def clobbers_output? = single?
|
|
97
|
+
|
|
98
|
+
# The decompressor's output is opened for writing before it has read a
|
|
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.
|
|
101
|
+
def scratch = File.join(@dest, ".#{Unpack.member_name(@archive)}.part")
|
|
102
|
+
|
|
103
|
+
def prepare
|
|
104
|
+
# Remember what we make, so a failure can leave the tree as it was.
|
|
105
|
+
@made = []
|
|
106
|
+
dir = @dest
|
|
107
|
+
until File.directory?(dir) || dir == "/"
|
|
108
|
+
@made << dir
|
|
109
|
+
dir = File.dirname(dir)
|
|
110
|
+
end
|
|
111
|
+
FileUtils.mkdir_p(@dest)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Every step succeeded, so the recovered file may take its real name.
|
|
115
|
+
def commit
|
|
116
|
+
File.rename(scratch, output) if single? && File.exist?(scratch)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Folders we created and never filled are ours to take back; anything that
|
|
120
|
+
# was already on disk, or anything the run managed to write, stays.
|
|
121
|
+
def rollback
|
|
122
|
+
(@made || []).each do |dir|
|
|
123
|
+
Dir.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Counting an archive's members means decompressing the whole thing
|
|
130
|
+
# first, which is most of the work. The job counts up as it goes instead.
|
|
131
|
+
def total_members(_sizer) = nil
|
|
132
|
+
|
|
133
|
+
def gerund = "unpacking"
|
|
134
|
+
|
|
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
|
+
def outcome
|
|
138
|
+
return "#{File.basename(output)} #{Text.bytes(file_size(output))}" if single?
|
|
139
|
+
|
|
140
|
+
"unpacked into #{File.basename(@dest)}/"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def report_note = single? ? Text.bytes(file_size(output)) : "unpacked"
|
|
144
|
+
|
|
145
|
+
def file_size(path)
|
|
146
|
+
File.size(path)
|
|
147
|
+
rescue StandardError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def tar_argv
|
|
152
|
+
argv = ["tar", "-x", "-v"]
|
|
153
|
+
# Reading an archive, tar runs the program with -d appended, so this is
|
|
154
|
+
# the bare binary. Adding our own -d makes brotli refuse the command as
|
|
155
|
+
# already set; the other five ignore the repeat.
|
|
156
|
+
argv += ["--use-compress-program", @shape.bin] if @shape.bin
|
|
157
|
+
argv + ["-f", @archive, "-C", @dest]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def zip_argv = ["unzip", "-o", @archive, "-d", @dest]
|
|
161
|
+
|
|
162
|
+
# The compressor reading the archive and writing the plain file to
|
|
163
|
+
# stdout, which Job points at the output path.
|
|
164
|
+
def single_argv = [@shape.bin, "-dc", @archive]
|
|
165
|
+
|
|
166
|
+
# [label, argv, expects_verbose_output, stdout_path]
|
|
167
|
+
def steps
|
|
168
|
+
case kind
|
|
169
|
+
when :tar then [["tar", tar_argv, true, nil]]
|
|
170
|
+
when :zip then [["unzip", zip_argv, true, nil]]
|
|
171
|
+
when :single then [[@shape.bin, single_argv, false, scratch]]
|
|
172
|
+
else []
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def preview
|
|
177
|
+
steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def missing?(bin) = !Tools.available?(bin)
|
|
181
|
+
|
|
182
|
+
# GNU tar and bsdtar can hand the stream to a compressor; busybox cannot.
|
|
183
|
+
def tar_pipes? = Tools.tar_pipes?
|
|
184
|
+
|
|
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
|
+
def tools = [@shape&.kind == :single ? nil : @shape&.tool, @shape&.bin].compact
|
|
188
|
+
|
|
189
|
+
def problems
|
|
190
|
+
return ["not an archive rakpak knows how to open: #{File.basename(@archive)}"] if @shape.nil?
|
|
191
|
+
|
|
192
|
+
errs = []
|
|
193
|
+
if !File.exist?(@archive) then errs << "no such file: #{@archive}"
|
|
194
|
+
elsif !File.file?(@archive) then errs << "not a file: #{@archive}"
|
|
195
|
+
elsif !File.readable?(@archive) then errs << "not readable: #{@archive}"
|
|
196
|
+
end
|
|
197
|
+
tools.each { |bin| errs << "#{bin} not installed" if missing?(bin) }
|
|
198
|
+
if kind == :tar && @shape.bin && !tar_pipes?
|
|
199
|
+
errs << "this tar cannot pipe through #{@shape.bin}"
|
|
200
|
+
end
|
|
201
|
+
# mkdir_p would raise EEXIST partway through the run; say it up front.
|
|
202
|
+
errs << "not a folder: #{@dest}" if File.exist?(@dest) && !File.directory?(@dest)
|
|
203
|
+
# A folder can be writable and still refuse to be listed, which tar
|
|
204
|
+
# needs to do to avoid clobbering, and which warnings needs to read.
|
|
205
|
+
if File.directory?(@dest) && !File.readable?(@dest)
|
|
206
|
+
errs << "destination is not readable: #{@dest}"
|
|
207
|
+
end
|
|
208
|
+
parent = existing_parent(@dest)
|
|
209
|
+
errs << "destination is not writable: #{parent}" unless File.writable?(parent)
|
|
210
|
+
errs.uniq
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Nothing is created until the job runs, so writability is a question
|
|
214
|
+
# about the nearest folder that already exists.
|
|
215
|
+
def existing_parent(dir)
|
|
216
|
+
dir = File.dirname(dir) until File.directory?(dir) || dir == "/"
|
|
217
|
+
dir
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def warnings
|
|
221
|
+
warn = []
|
|
222
|
+
if single?
|
|
223
|
+
warn << "#{Unpack.member_name(@archive)} already exists and will be replaced" if File.exist?(output)
|
|
224
|
+
elsif File.directory?(@dest) && !empty_dir?(@dest)
|
|
225
|
+
warn << "#{File.basename(@dest)} already has files in it; matching names will be replaced"
|
|
226
|
+
end
|
|
227
|
+
warn
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# An unlistable folder is reported by problems; there is nothing to warn
|
|
231
|
+
# about and this must not raise, since the confirm screen draws it.
|
|
232
|
+
def empty_dir?(dir)
|
|
233
|
+
Dir.children(dir).empty?
|
|
234
|
+
rescue StandardError
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
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,20 +10,23 @@ 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
31
|
# A folder as a person would type it: ~, ~/x, $HOME/x, ${HOME}/x, or an
|
|
28
32
|
# absolute or relative path.
|
|
@@ -35,10 +39,11 @@ module Rakpak
|
|
|
35
39
|
text # ~nosuchuser and the like; the caller reports it is not a folder
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
# Returns Options. `action` is :browse, :help or :version. Raises
|
|
42
|
+
# Returns Options. `action` is :browse, :unpack, :help or :version. Raises
|
|
39
43
|
# ArgumentError on anything it does not understand.
|
|
40
44
|
def self.parse(argv)
|
|
41
45
|
pack_mode = false
|
|
46
|
+
depack_mode = false
|
|
42
47
|
positional = []
|
|
43
48
|
rest = argv.dup
|
|
44
49
|
until rest.empty?
|
|
@@ -47,12 +52,28 @@ module Rakpak
|
|
|
47
52
|
when "-h", "--help" then return Options.new(action: :help)
|
|
48
53
|
when "-v", "--version" then return Options.new(action: :version)
|
|
49
54
|
when "-p", "--pack" then pack_mode = true
|
|
55
|
+
when "-d", "--depack", "--unpack" then depack_mode = true
|
|
50
56
|
when "--" then positional.concat(rest) && rest.clear
|
|
51
57
|
when /\A-./ then raise ArgumentError, "unknown option: #{arg}"
|
|
52
58
|
else positional << arg
|
|
53
59
|
end
|
|
54
60
|
end
|
|
55
61
|
|
|
62
|
+
raise ArgumentError, "-p and -d do different things; pick one" if pack_mode && depack_mode
|
|
63
|
+
|
|
64
|
+
if depack_mode
|
|
65
|
+
raise ArgumentError, "-d needs at least one archive" if positional.empty?
|
|
66
|
+
|
|
67
|
+
paths = positional.map { |p| File.expand_path(p) }
|
|
68
|
+
paths.each do |p|
|
|
69
|
+
raise ArgumentError, "no such file: #{p}" unless File.exist?(p)
|
|
70
|
+
unless File.file?(p) && Unpack.archive?(p)
|
|
71
|
+
raise ArgumentError, "not an archive rakpak knows how to open: #{File.basename(p)}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
return Options.new(action: :unpack, unpack: paths, pack: [])
|
|
75
|
+
end
|
|
76
|
+
|
|
56
77
|
if pack_mode
|
|
57
78
|
raise ArgumentError, "-p needs at least one path" if positional.empty?
|
|
58
79
|
|
|
@@ -62,15 +83,78 @@ module Rakpak
|
|
|
62
83
|
end
|
|
63
84
|
# Open the browser on the folder holding the first target, with that
|
|
64
85
|
# target under the cursor, so the archive lands next to it.
|
|
65
|
-
Options.new(action: :browse, dir: File.dirname(paths.first), pack: paths)
|
|
86
|
+
Options.new(action: :browse, dir: File.dirname(paths.first), pack: paths, unpack: [])
|
|
66
87
|
else
|
|
67
88
|
raise ArgumentError, "expected one folder, got #{positional.size}" if positional.size > 1
|
|
68
89
|
|
|
69
90
|
dir = File.expand_path(positional.first || Dir.home)
|
|
70
91
|
raise ArgumentError, "not a folder: #{dir}" unless File.directory?(dir)
|
|
71
92
|
|
|
72
|
-
Options.new(action: :browse, dir: dir, pack: [])
|
|
93
|
+
Options.new(action: :browse, dir: dir, pack: [], unpack: [])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Unpacks each archive into the folder you are standing in, the way tar
|
|
98
|
+
# would, each into a folder of its own name. A lone compressed file has
|
|
99
|
+
# nothing to wrap, so it lands beside you. Returns an exit status;
|
|
100
|
+
# one bad archive does not stop the rest.
|
|
101
|
+
def self.unpack_all(archives, dir: Dir.pwd, out: $stdout)
|
|
102
|
+
status = 0
|
|
103
|
+
archives.each do |archive|
|
|
104
|
+
sub = Unpack.default_subdir(archive)
|
|
105
|
+
plan = Unpack.new(archive: archive, dest: sub ? File.join(dir, sub) : dir)
|
|
106
|
+
problems = plan.problems
|
|
107
|
+
if problems.any?
|
|
108
|
+
problems.each { |m| warn "rakpak: #{m}" }
|
|
109
|
+
status = 1
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
out.puts "#{Text.plain(File.basename(archive))} → #{Text.plain(Text.tilde(plan.dest))}"
|
|
113
|
+
plan.warnings.each { |w| out.puts " #{w}" }
|
|
114
|
+
# Anything the job says goes to stderr; keep the two streams in order.
|
|
115
|
+
out.flush
|
|
116
|
+
status = 1 unless run_headless(plan, out)
|
|
117
|
+
end
|
|
118
|
+
status
|
|
119
|
+
rescue Interrupt
|
|
120
|
+
130
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# True when it worked. On a terminal the count is rewritten in place; down
|
|
124
|
+
# a pipe only the closing line is written, so logs stay readable.
|
|
125
|
+
#
|
|
126
|
+
# The extractor runs in its own process group so a cancel can take its whole
|
|
127
|
+
# pipeline, which also means the terminal's ctrl-c never reaches it. Catching
|
|
128
|
+
# the interrupt here is what stops tar carrying on without us.
|
|
129
|
+
def self.run_headless(plan, out)
|
|
130
|
+
job = Job.new(plan).start
|
|
131
|
+
live = out.respond_to?(:tty?) && out.tty?
|
|
132
|
+
begin
|
|
133
|
+
while job.running?
|
|
134
|
+
job.wait(0.1)
|
|
135
|
+
next unless live
|
|
136
|
+
|
|
137
|
+
out.print "\r #{job.file_count} entries · #{Text.duration(job.elapsed)}\e[K"
|
|
138
|
+
out.flush
|
|
139
|
+
end
|
|
140
|
+
rescue Interrupt
|
|
141
|
+
out.print "\r\e[K" if live
|
|
142
|
+
job.cancel
|
|
143
|
+
job.wait(5)
|
|
144
|
+
warn "rakpak: cancelled"
|
|
145
|
+
raise
|
|
146
|
+
end
|
|
147
|
+
out.print "\r\e[K" if live
|
|
148
|
+
unless job.ok?
|
|
149
|
+
warn "rakpak: #{job.error}"
|
|
150
|
+
# The exit status says a tool failed; its own last words say why.
|
|
151
|
+
# Member names come from inside the archive, so they reach the shell
|
|
152
|
+
# defanged, the way the finished-job report does.
|
|
153
|
+
job.tail(4).each { |line| warn " #{Text.plain(line)}" unless line.start_with?("▸") }
|
|
154
|
+
return false
|
|
73
155
|
end
|
|
156
|
+
out.puts " #{job.file_count} entries · #{Text.duration(job.elapsed)} · #{plan.report_note}"
|
|
157
|
+
true
|
|
74
158
|
end
|
|
75
159
|
|
|
76
160
|
# Exit status.
|
|
@@ -89,6 +173,9 @@ module Rakpak
|
|
|
89
173
|
when :version
|
|
90
174
|
puts "rakpak #{VERSION}"
|
|
91
175
|
return 0
|
|
176
|
+
when :unpack
|
|
177
|
+
# Nothing to browse and nothing to ask, so this works down a pipe.
|
|
178
|
+
return unpack_all(opts.unpack)
|
|
92
179
|
end
|
|
93
180
|
unless $stdout.tty? && $stdin.tty?
|
|
94
181
|
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.1.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: []
|