diff --git a/go.mod b/go.mod index 5d110e5..c866b18 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,26 @@ module flashfiller go 1.19 -require github.com/schollz/progressbar/v3 v3.12.1 +require ( + github.com/charmbracelet/bubbles v0.14.0 + github.com/charmbracelet/bubbletea v0.23.1 + github.com/charmbracelet/lipgloss v0.5.0 +) require ( + github.com/aymanbagabas/go-osc52 v1.0.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/containerd/console v1.0.3 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect - github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.13.0 // indirect github.com/rivo/uniseg v0.4.2 // indirect golang.org/x/sys v0.1.0 // indirect golang.org/x/term v0.1.0 // indirect + golang.org/x/text v0.3.7 // indirect ) diff --git a/go.sum b/go.sum index 2a3e7d0..dc97aaf 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,58 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= +github.com/charmbracelet/bubbles v0.14.0 h1:DJfCwnARfWjZLvMglhSQzo76UZ2gucuHPy9jLWX45Og= +github.com/charmbracelet/bubbles v0.14.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= +github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= +github.com/charmbracelet/bubbletea v0.23.1 h1:CYdteX1wCiCzKNUlwm25ZHBIc1GXlYFyUIte8WPvhck= +github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= +github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/schollz/progressbar/v3 v3.12.1 h1:JAhtIrLWAn6/p7i82SrpSG3fgAwlAxi+Sy12r4AzBvQ= -github.com/schollz/progressbar/v3 v3.12.1/go.mod h1:g7QSuwyGpqCjVQPFZXA31MSxtrhka9Y9LMdF+XT77/Y= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go index 1f89c7f..2a9371e 100644 --- a/main.go +++ b/main.go @@ -4,8 +4,10 @@ import ( "crypto/md5" "flag" "fmt" - "github.com/schollz/progressbar/v3" - "golang.org/x/term" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "io" "io/fs" "log" @@ -24,22 +26,50 @@ const ( gb = 1024 * mb ) +var noCheckMd5 = false +var noLives = false +var dropLess = "" +var dropThreshold = int64(-1) + +type fileStatus int + +const ( + md5passed fileStatus = iota + md5failed + inProgress +) + +type lastFileItem struct { + path string + name string + status fileStatus +} + +type uiState int + +const ( + uiStateSearching uiState = iota + uiStateUploading +) +const fileHistoryLines = 5 + +var appPadding = lipgloss.NewStyle().Padding(1, 2, 1, 2).Render +var greyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render +var inProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fdfdfd")).Bold(true).Render +var passedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")).Render +var failedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")).Render + func getMd5(path string) string { file, err := os.Open(path) - if err != nil { - panic(err) + return "failed" // FIXME add error handling } - defer file.Close() - hash := md5.New() _, err = io.Copy(hash, file) - if err != nil { - panic(err) + return "failed" } - return fmt.Sprintf("%x", hash.Sum(nil)) } @@ -59,11 +89,11 @@ func parseSize(s string) (int64, error) { } multiplier := 0 switch unit { - case "g": + case "g", "gb", "гб": multiplier = gb - case "m": + case "m", "mb", "мб": multiplier = mb - case "k": + case "k", "kb", "кб": multiplier = kb case "": multiplier = 1 @@ -76,17 +106,29 @@ func parseSize(s string) (int64, error) { func formatSize(s int64) string { if s >= gb { - return fmt.Sprintf("%.1fG", float64(s)/float64(gb)) + return fmt.Sprintf("%.1fГб", float64(s)/float64(gb)) } else if s >= mb { - return fmt.Sprintf("%.1fM", float64(s)/float64(mb)) + return fmt.Sprintf("%.1fМб", float64(s)/float64(mb)) } else if s >= kb { - return fmt.Sprintf("%.1fK", float64(s)/float64(kb)) + return fmt.Sprintf("%.1fКб", float64(s)/float64(kb)) } else { return fmt.Sprintf("%d байт", s) } } -func copyFile(from, to string) error { +type copyWatcher struct { + bytes int64 + ch chan fillerMsg +} + +func (w *copyWatcher) Write(p []byte) (int, error) { + diff := int64(len(p)) + w.bytes += diff + w.ch <- bytesCopied{diff: diff, total: w.bytes} + return len(p), nil +} + +func copyFile(from, to string, ch chan fillerMsg) error { r, err := os.Open(from) if err != nil { return err @@ -97,7 +139,10 @@ func copyFile(from, to string) error { return err } defer w.Close() - _, err = io.Copy(w, r) + _, err = io.Copy(w, io.TeeReader(r, ©Watcher{ + bytes: 0, + ch: ch, + })) if err != nil { return err } @@ -144,37 +189,239 @@ func matchesPatterns(patterns []string, noLives bool, path string) bool { return false } -func formatFilename(s string) string { - w, _, err := term.GetSize(int(os.Stdin.Fd())) - if err != nil { - w = 80 +func (f fileStatus) Style(s string) string { + switch f { + case inProgress: + return inProgressStyle("> " + s) + case md5passed: + return passedStyle(" " + s) + case md5failed: + return failedStyle(" " + s) } - free := w - 65 - if free < 10 { - return "" + return s +} + +type model struct { + sub chan fillerMsg + // All states + currentState uiState + start time.Time + explanation string + // Searching State + filesFound int + filesMatch int + spinner spinner.Model + lastFileFound string + // UploadingState + uploadStartedAt time.Time + totalProgress progress.Model + currentProgress progress.Model + overallBytes int64 + overallFiles int + overallBytesCopied int64 + currentFileBytesCopied int64 + currentFileBytes int64 + currentFilename string + currentFiles int + lastFiles []*lastFileItem + failedMd5Number int +} + +func newApp(sub chan fillerMsg, explanation string) model { + return model{ + sub: sub, + currentState: uiStateSearching, + start: time.Now(), + explanation: explanation, + filesFound: 0, + filesMatch: 0, + spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), + lastFileFound: "", + totalProgress: progress.New(progress.WithDefaultGradient()), + currentProgress: progress.New(progress.WithDefaultGradient()), + overallBytes: 0, + overallFiles: 0, + overallBytesCopied: 0, + currentFileBytesCopied: int64(0), + currentFileBytes: 0, + currentFilename: "", + currentFiles: 0, + lastFiles: make([]*lastFileItem, 0, fileHistoryLines+1), + failedMd5Number: 0, } - if free > 25 { - free = 20 +} + +type fillerMsg any + +func activityBridge(ch chan fillerMsg) tea.Cmd { + return func() tea.Msg { + msg := <-ch + return msg } +} - if len(s) <= free { - return s +func (m model) Init() tea.Cmd { + return tea.Batch(activityBridge(m.sub), m.spinner.Tick) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + case done: + return m, tea.Quit + case totalsFound: + m.overallFiles = msg.files + m.overallBytes = msg.bytes + case newFileStarted: + m.lastFiles = append(m.lastFiles, &lastFileItem{ + name: msg.currentFilename, + status: inProgress, + path: msg.path, + }) + if len(m.lastFiles) == fileHistoryLines+1 { + m.lastFiles = m.lastFiles[1:] + } + m.currentFilename = msg.currentFilename + m.currentFileBytes = msg.currentFileBytes + m.currentFiles = msg.currentFileNumber + case bytesCopied: + m.currentFileBytesCopied = msg.total + m.overallBytesCopied += msg.diff + case md5checked: + for _, lf := range m.lastFiles { + if lf.path == msg.path { + lf.status = msg.status + } + } + if msg.status == md5failed { + m.failedMd5Number++ + } + case uiState: + m.currentState = msg + switch msg { + case uiStateUploading: + m.uploadStartedAt = time.Now() + } + case searchFileFound: + m.filesFound++ + m.lastFileFound = string(msg) + case searchFileMatches: + m.filesMatch++ } - return fmt.Sprintf("...%s", s[len(s)-(free-3):]) + // we do not really need these spinner ticks once Searching State is finished, + // but they are really handy to make the event loop spin and show realtime updates + // to the user console + newSpinner, cmd := m.spinner.Update(msg) + m.spinner = newSpinner + + return m, tea.Batch(activityBridge(m.sub), cmd) } -var checkMd5 = true -var noLives = false -var dropLess = "" -var dropThreshold = int64(-1) +func (m model) viewState() string { + switch m.currentState { + case uiStateSearching: + return fmt.Sprintf( + "\n\n%s\n%s %d файлов найдено (%d подходит)\n\n", + m.lastFileFound, + m.spinner.View(), + m.filesFound, + m.filesMatch, + ) + case uiStateUploading: + overallProgress := float64(m.overallBytesCopied) / float64(m.overallBytes) + currentFileProgress := float64(m.currentFileBytesCopied) / float64(m.currentFileBytes) + speed := float64(m.overallBytesCopied) / time.Since(m.start).Seconds() + + lastFilesLines := make([]string, 0, len(m.lastFiles)) + for i := len(m.lastFiles) - 1; i >= 0; i-- { + lf := m.lastFiles[i] + lastFilesLines = append(lastFilesLines, lf.status.Style(lf.name)) + } + + for i := 0; i < fileHistoryLines-len(m.lastFiles); i++ { + lastFilesLines = append(lastFilesLines, "") + } + + failedMsg := "" + if m.failedMd5Number != 0 { + failedMsg = " / " + failedStyle(fmt.Sprintf("%d ошибок md5", m.failedMd5Number)) + } + + lastFilesFmt := strings.Join(lastFilesLines, "\n") + return fmt.Sprintf( + "Прогресс: %d / %d%s [%s/сек]\n%s\nТекущий файл: %s [%s]\n%s\n\n%s\n\n", + m.currentFiles, + m.overallFiles, + failedMsg, + formatSize(int64(speed)), + m.totalProgress.ViewAs(overallProgress), + m.currentFilename, + formatSize(m.currentFileBytes), + m.currentProgress.ViewAs(currentFileProgress), + lastFilesFmt, + ) + } + return "unknown state" +} + +var helpText = greyStyle("Нажмите ctrl+c для выхода...") + +func (m model) View() string { + curStateView := m.viewState() + duration := time.Since(m.start) + uploadDuration := time.Since(m.uploadStartedAt) + passed := fmt.Sprintf("Прошло: %s", duration.Round(time.Second)) + leftStr := "" + if m.overallBytes > 0 && m.overallBytesCopied > 0 { + overallProgress := float64(m.overallBytesCopied) / float64(m.overallBytes) + left := time.Duration((uploadDuration.Seconds()/overallProgress)-uploadDuration.Seconds()) * time.Second + leftStr = fmt.Sprintf(" / Осталось %s", left.Round(time.Second)) + } + passed += leftStr + return appPadding(fmt.Sprintf( + "%s\n%s\n\n%s\n%s", + greyStyle(m.explanation), + greyStyle(passed), + curStateView, + helpText, + )) +} + +type totalsFound struct { + files int + bytes int64 +} +type newFileStarted struct { + currentFileNumber int + currentFilename string + path string + currentFileBytes int64 +} +type bytesCopied struct { + diff int64 + total int64 +} +type done struct{} +type md5checked struct { + path string + status fileStatus +} +type searchFileFound string +type searchFileMatches struct{} + +var noGUI bool func main() { rand.Seed(time.Now().UnixNano()) flag.StringVar(&pattern, "pattern", "mp3", "файлы для поиска. Например: -pattern=mp3,ogg. По умолчанию: mp3") - flag.BoolVar(&checkMd5, "md5", true, "проверять хэш-суммы после записи [0/1]. По умолчанию: 1") + flag.BoolVar(&noCheckMd5, "nomd5", false, "не проверять хэш-суммы после записи") flag.BoolVar(&noLives, "nolive", false, "не включать в список live выступления (если в имени файла или родительской папке содержится 'live') [0/1]. По умолчанию: 0") flag.StringVar(&dropLess, "drop", "", "не включать в список файлы, размер которых меньше параметра (например: -drop=1M или -drop=900K). По умолчанию включаются все") + flag.BoolVar(&noGUI, "nogui", false, "не отображать GUI, вместо этого писать логи") flag.Parse() args := flag.Args() if flag.NArg() != 3 { @@ -185,6 +432,9 @@ func main() { sizeStr := args[0] from := args[1] to := args[2] + if !noGUI { + log.SetOutput(io.Discard) + } patterns := getPatterns(pattern) left, err := parseSize(sizeStr) if err != nil { @@ -196,13 +446,13 @@ func main() { log.Fatalln(err) } } - + sub := make(chan fillerMsg, 10) _makeExplanation := func() string { parts := make([]string, 0) parts = append(parts, fmt.Sprintf("Ищем файлы %s в %s", patterns, from)) parts = append(parts, fmt.Sprintf("пишем %s в %s", formatSize(left), to)) hashCheck := "проверяя контрольные суммы при записи" - if !checkMd5 { + if noCheckMd5 { hashCheck = "не " + hashCheck } parts = append(parts, hashCheck) @@ -216,141 +466,138 @@ func main() { } log.Println(_makeExplanation()) + doneCh := make(chan struct{}) + go func() { + _matchesLimits := func(path string) bool { + patterned := matchesPatterns(patterns, noLives, path) + if !patterned { + return false + } + if dropThreshold != -1 { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.Size() >= dropThreshold + } + return true + } - walkPg := progressbar.NewOptions(-1, - progressbar.OptionSpinnerType(14), - progressbar.OptionSetElapsedTime(true), - progressbar.OptionShowCount(), - progressbar.OptionThrottle(time.Millisecond*100), - progressbar.OptionClearOnFinish(), - progressbar.OptionShowIts(), - progressbar.OptionSetItsString("files"), - ) - - _matchesLimits := func(path string) bool { - patterned := matchesPatterns(patterns, noLives, path) - if !patterned { - return false + files := make([]string, 0) + err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if !d.IsDir() { + sub <- searchFileFound(path) + } + if !d.IsDir() && _matchesLimits(path) { + files = append(files, path) + sub <- searchFileMatches{} + } + return nil + }) + + sub <- uiStateUploading + + if err != nil { + log.Fatalln(err) } - if dropThreshold != -1 { + log.Println("найдено файлов:", len(files)) + + rand.Shuffle(len(files), func(i, j int) { + files[i], files[j] = files[j], files[i] + }) + toWrite := make([]string, 0) + toWriteBytesCount := int64(0) + + tries := 0 + for _, path := range files { info, err := os.Stat(path) if err != nil { - return false + continue } - return info.Size() >= dropThreshold - } - return true - } - files := make([]string, 0) - err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { - if !d.IsDir() && _matchesLimits(path) { - files = append(files, path) - walkPg.Add(1) - _, filename := filepath.Split(path) - walkPg.Describe(formatFilename(filename)) + size := info.Size() + if left >= size { + toWrite = append(toWrite, path) + left -= size + toWriteBytesCount += size + tries = 0 + } else if tries > 10 { + break + } else { + tries++ + } } - return nil - }) - if err != nil { - log.Fatalln(err) - } - walkPg.Finish() - log.Println("найдено файлов:", len(files)) + sub <- totalsFound{ + files: len(toWrite), + bytes: toWriteBytesCount, + } + log.Println("будет записано файлов:", len(toWrite), "на", formatSize(toWriteBytesCount)) - rand.Shuffle(len(files), func(i, j int) { - files[i], files[j] = files[j], files[i] - }) + counter := 0 + parentCreated := false - toWrite := make([]string, 0) - toWriteBytesCount := int64(0) + copyError := false + for i, path := range toWrite { + info, err := os.Stat(path) + if err != nil { + copyError = true + continue + } + _, filename := filepath.Split(path) - tries := 0 - for _, path := range files { - info, err := os.Stat(path) - if err != nil { - continue - } + sub <- newFileStarted{ + currentFileNumber: i + 1, + currentFilename: filename, + currentFileBytes: info.Size(), + path: path, + } + ext := filepath.Ext(path) + name := fmt.Sprintf("%010d%s", counter, ext) + counter++ + newPath := filepath.Join(to, name) + log.Println("Пишем", path, "->", newPath) + if !parentCreated { + parentDir := filepath.Dir(newPath) + if err := os.MkdirAll(parentDir, os.ModePerm); err != nil { + log.Fatalln("не удалось создать родительские папки") + } + parentCreated = true + } + err = copyFile(path, newPath, sub) + if err != nil { + copyError = true + log.Println(err.Error()) + continue + } - size := info.Size() - if left >= size { - toWrite = append(toWrite, path) - left -= size - toWriteBytesCount += size - tries = 0 - } else if tries > 10 { - break - } else { - tries++ - } - } - log.Println("будет записано файлов:", len(toWrite)) - - pg := progressbar.NewOptions64(toWriteBytesCount, - progressbar.OptionEnableColorCodes(true), - progressbar.OptionShowBytes(true), - progressbar.OptionSetElapsedTime(true), - progressbar.OptionSetPredictTime(true), - progressbar.OptionSpinnerType(14), - progressbar.OptionShowDescriptionAtLineEnd(), - progressbar.OptionThrottle(time.Millisecond*50), - progressbar.OptionSetTheme(progressbar.Theme{ - Saucer: "[green]=[reset]", - SaucerHead: "[green]>[reset]", - SaucerPadding: " ", - BarStart: "[", - BarEnd: "]", - }), - ) - - counter := 0 - errors := make([]string, 0) - parentCreated := false - - for _, path := range toWrite { - info, err := os.Stat(path) - if err != nil { - errors = append(errors, err.Error()) - continue - } - _, filename := filepath.Split(path) - pg.Describe(formatFilename(filename)) - ext := filepath.Ext(path) - name := fmt.Sprintf("%010d%s", counter, ext) - counter++ - newPath := filepath.Join(to, name) - if !parentCreated { - parentDir := filepath.Dir(newPath) - if err := os.MkdirAll(parentDir, os.ModePerm); err != nil { - pg.Finish() - pg.Clear() - log.Fatalln("не удалось создать родительские папки") + md5event := md5checked{ + path: path, } - parentCreated = true - } - err = copyFile(path, newPath) - if err != nil { - errors = append(errors, err.Error()) - continue - } - if checkMd5 { - if getMd5(path) != getMd5(newPath) { - errors = append(errors, fmt.Sprintf("md5 %s -> %s не совпали", path, newPath)) + // TODO: checking source md5 via TeeReader in copyFile will remove an extra read + if noCheckMd5 || getMd5(path) == getMd5(newPath) { + md5event.status = md5passed + } else { + md5event.status = md5failed + log.Printf("md5 %s -> %s не совпали\n", path, newPath) + copyError = true } + sub <- md5event } - pg.Add(int(info.Size())) - } - - fmt.Println() - if len(errors) != 0 { - log.Println("завершено с ошибками:") - for _, e := range errors { - log.Println(e) + sub <- done{} + if copyError && noGUI { + os.Exit(1) } - os.Exit(1) - } else { - log.Println("завершено без ошибок") + doneCh <- struct{}{} + }() + opts := make([]tea.ProgramOption, 0) + if noGUI { + opts = append(opts, tea.WithoutRenderer()) + } + _, err = tea.NewProgram(newApp(sub, _makeExplanation()), opts...).Run() + if err != nil { + log.Fatalln(err) } + <-doneCh }