package syncengine import ( "regexp" "strconv" "strings" ) type RsyncStats struct { SentBytes int64 ReceivedBytes int64 TotalSize int64 Speedup float64 FilesSent int FilesTotal int } type ProgressLine struct { Phase string Percent float64 Files int Total int SentBytes int64 XferedBytes int64 } type ProgressFields struct { FileBytes int64 Pct int SpeedBps int64 EtaSeconds int XfrDone int XfrTotal int } var ( progressRegex = regexp.MustCompile(`\s*([\d,]+)\s+([\d,]+)\s+([\d%]+)\s*`) sentRegex = regexp.MustCompile(`sent\s+([\d,]+)\s+bytes`) recvRegex = regexp.MustCompile(`received\s+([\d,]+)\s+bytes`) totalRegex = regexp.MustCompile(`total size is\s+([\d,]+)`) filesRegex = regexp.MustCompile(`Number of files: ([\d,]+)`) ) var perFileProgressRegex = regexp.MustCompile( `^\s*(\d{1,3}(?:,\d{3})+)\s+(\d+)%\s+(\d+\.\d+)([kMG])B/s\s+(\d+:\d{2}:\d{2})(.*)`, ) var xfrRegex = regexp.MustCompile(`xfr#(\d+).*to-chk=(\d+)/(\d+)`) func parseXfrSuffix(suffix string) (done, total int) { m := xfrRegex.FindStringSubmatch(suffix) if m == nil { return 0, 0 } done, _ = strconv.Atoi(m[1]) t, _ := strconv.Atoi(m[2]) _ = t total, _ = strconv.Atoi(m[3]) return done, total } func ParseProgressLine(line string) *ProgressLine { if strings.Contains(line, "files to consider") || strings.Contains(line, "files...") { return &ProgressLine{Phase: "scanning"} } if strings.Contains(line, "building file list") { return &ProgressLine{Phase: "listing"} } if strings.Contains(line, "sent") && strings.Contains(line, "bytes") { return &ProgressLine{Phase: "stats"} } return nil } func ParseStatsLine(line string) (int64, bool) { m := sentRegex.FindStringSubmatch(line) if len(m) >= 2 { n, _ := strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) return n, true } return 0, false } func ParseFinalStats(output string) *RsyncStats { stats := &RsyncStats{} lines := strings.Split(output, "\n") for _, line := range lines { line = strings.TrimSpace(line) if m := sentRegex.FindStringSubmatch(line); len(m) >= 2 { stats.SentBytes, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) } if m := recvRegex.FindStringSubmatch(line); len(m) >= 2 { stats.ReceivedBytes, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) } if m := totalRegex.FindStringSubmatch(line); len(m) >= 2 { stats.TotalSize, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) } if m := filesRegex.FindStringSubmatch(line); len(m) >= 2 { stats.FilesTotal, _ = strconv.Atoi(strings.ReplaceAll(m[1], ",", "")) } } return stats } func isProgressOnlyLine(line string) bool { return perFileProgressRegex.MatchString(line) } func parseProgressFields(line string) *ProgressFields { m := perFileProgressRegex.FindStringSubmatch(line) if m == nil { return nil } bytes, _ := strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) pct, _ := strconv.Atoi(m[2]) speed, _ := strconv.ParseFloat(m[3], 64) unit := m[4] eta := m[5] suffix := m[6] speedBps := int64(speed * 1e6) switch unit { case "k", "K": speedBps = int64(speed * 1e3) case "m", "M": speedBps = int64(speed * 1e6) case "g", "G": speedBps = int64(speed * 1e9) } etaSecs := 0 parts := strings.Split(eta, ":") if len(parts) == 3 { h, _ := strconv.Atoi(parts[0]) m, _ := strconv.Atoi(parts[1]) s, _ := strconv.Atoi(parts[2]) etaSecs = h*3600 + m*60 + s } pf := &ProgressFields{ FileBytes: bytes, Pct: pct, SpeedBps: speedBps, EtaSeconds: etaSecs, } if suffix != "" { done, total := parseXfrSuffix(suffix) pf.XfrDone = done pf.XfrTotal = total } return pf } func isFileNameLine(line string) bool { if line == "" || strings.TrimSpace(line) == "" { return false } if strings.Contains(line, "sending incremental file list") || strings.Contains(line, "building file list") || strings.Contains(line, "cannot open") || strings.Contains(line, "skipping non-regular") || strings.HasPrefix(line, "sent ") || strings.HasPrefix(line, "total ") || strings.HasPrefix(line, "Number of files:") || strings.Contains(line, "bytes received") { return false } return !isProgressOnlyLine(line) }