feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed help.txt
|
||||
var helpText string
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorBold = "\033[1m"
|
||||
ColorMagenta = "\033[35m"
|
||||
)
|
||||
|
||||
// isColorTerminal checks if writer supports ANSI colors
|
||||
func isColorTerminal(w io.Writer) bool {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := w.(*os.File); ok {
|
||||
return os.Getenv("TERM") != "dumb"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PrintUsage prints formatted help from embedded help.txt
|
||||
func PrintUsage(w io.Writer) {
|
||||
useColors := isColorTerminal(w)
|
||||
|
||||
if useColors {
|
||||
bold := ColorBold
|
||||
reset := ColorReset
|
||||
green := ColorGreen
|
||||
yellow := ColorYellow
|
||||
magenta := ColorMagenta
|
||||
_ = bold
|
||||
_ = reset
|
||||
_ = green
|
||||
_ = yellow
|
||||
_ = magenta
|
||||
|
||||
// Print help text with ANSI colors for section headers
|
||||
for _, line := range strings.Split(helpText, "\n") {
|
||||
if strings.HasPrefix(line, "NAME") || strings.HasPrefix(line, "SYNOPSIS") ||
|
||||
strings.HasPrefix(line, "DESCRIPTION") || strings.HasSuffix(strings.TrimRight(line, " "), ":") {
|
||||
fmt.Fprintf(w, "%s%s%s\n", ColorBold, line, ColorReset)
|
||||
} else {
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprint(w, helpText)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintCategoryHelp prints help filtered by category.
|
||||
// Categories: all, target, network, auth, mirror, recursive, verify, queue, metalink, upload, pgp, extraction, config
|
||||
func PrintCategoryHelp(w io.Writer, category string) {
|
||||
if category == "" || category == "all" {
|
||||
PrintUsage(w)
|
||||
return
|
||||
}
|
||||
|
||||
categories := map[string]string{
|
||||
"target": "TARGETS",
|
||||
"network": "NETWORK",
|
||||
"auth": "AUTHENTICATION",
|
||||
"mirror": "MIRROR MODE",
|
||||
"recursive": "RECURSIVE DOWNLOAD",
|
||||
"verify": "VERIFICATION",
|
||||
"queue": "DOWNLOAD QUEUE",
|
||||
"metalink": "METALINK",
|
||||
"upload": "BASIC OPTIONS",
|
||||
"pgp": "PGP/GPG",
|
||||
"extraction": "EXTRACTION",
|
||||
"config": "CONFIGURATION",
|
||||
}
|
||||
|
||||
title, ok := categories[strings.ToLower(category)]
|
||||
if !ok {
|
||||
fmt.Fprintf(w, "Unknown category: %s\nAvailable: %s\n", category,
|
||||
"target, network, auth, mirror, recursive, verify, queue, metalink, upload, pgp, extraction, config, all")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Showing category: %s\nUse --help for full help.\n\n", title)
|
||||
PrintUsage(w) // For now, print full help — future: filter
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PrintError prints an error with color styling
|
||||
func PrintError(w io.Writer, err error) {
|
||||
prefix, suffix := "", ""
|
||||
if isColorTerminal(w) {
|
||||
prefix = ColorRed + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
fmt.Fprintf(w, "%s✗ Error:%s %v\n", prefix, suffix, err)
|
||||
}
|
||||
|
||||
// PrintWarning prints a warning with color styling
|
||||
func PrintWarning(w io.Writer, msg string) {
|
||||
prefix, suffix := "", ""
|
||||
if isColorTerminal(w) {
|
||||
prefix = ColorYellow + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
fmt.Fprintf(w, "%s⚠ Warning:%s %s\n", prefix, suffix, msg)
|
||||
}
|
||||
|
||||
// PrintSuccess prints a success message with color styling
|
||||
func PrintSuccess(w io.Writer, msg string) {
|
||||
prefix, suffix := "", ""
|
||||
if isColorTerminal(w) {
|
||||
prefix = ColorGreen + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
fmt.Fprintf(w, "%s✓%s %s\n", prefix, suffix, msg)
|
||||
}
|
||||
|
||||
// PrintInfo prints an info message with color styling
|
||||
func PrintInfo(w io.Writer, msg string) {
|
||||
prefix, suffix := "", ""
|
||||
if isColorTerminal(w) {
|
||||
prefix = ColorCyan + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
fmt.Fprintf(w, "%sℹ%s %s\n", prefix, suffix, msg)
|
||||
}
|
||||
|
||||
// FormatError formats an error with context
|
||||
func FormatError(err error, context string) error {
|
||||
if context == "" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s: %w", context, err)
|
||||
}
|
||||
|
||||
// IsTerminal checks if the writer is a terminal
|
||||
func IsTerminal(w io.Writer) bool {
|
||||
_, ok := w.(*os.File)
|
||||
return ok && os.Getenv("TERM") != "dumb"
|
||||
}
|
||||
|
||||
// Colorize wraps text in ANSI color codes if the writer supports it
|
||||
func Colorize(w io.Writer, colorCode, text string) string {
|
||||
if !isColorTerminal(w) {
|
||||
return text
|
||||
}
|
||||
return colorCode + text + ColorReset
|
||||
}
|
||||
|
||||
// Indent returns text indented by the specified number of spaces
|
||||
func Indent(text string, spaces int) string {
|
||||
indent := strings.Repeat(" ", spaces)
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
if line != "" {
|
||||
lines[i] = indent + line
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
NAME
|
||||
goget - Modern IPv6-First Downloader
|
||||
|
||||
SYNOPSIS
|
||||
goget --url <URL> [OPTIONS]
|
||||
|
||||
DESCRIPTION
|
||||
curl/wget replacement in Go. IPv6-first, parallel downloads, resume,
|
||||
checksum verification, recursive mirroring, and 8 protocols.
|
||||
|
||||
TARGET
|
||||
--url <URL> URL to download (repeatable)
|
||||
|
||||
OUTPUT
|
||||
--output <FILE> Output file (default: auto from URL)
|
||||
--restart Delete existing file and restart download
|
||||
--overwrite Overwrite existing file (no atomic rename)
|
||||
--content-disposition Use server-provided filename
|
||||
--keep-timestamps Set file modification time from Last-Modified
|
||||
|
||||
PROGRESS
|
||||
--verbose Verbose output with progress bar (default)
|
||||
--no-progress Hide progress bar
|
||||
--progress=dot Wget-style dot progress
|
||||
--quiet Suppress all output except errors
|
||||
--json Output progress as JSON-lines (for scripting)
|
||||
--debug Debug mode with detailed info
|
||||
|
||||
NETWORK
|
||||
--timeout <DURATION> Connection timeout (0 = auto)
|
||||
--max-time <DURATION> Maximum total transfer time
|
||||
--max-retries <N> Maximum retry attempts on failure
|
||||
--max-filesize <SIZE> Abort if file exceeds size (e.g., 100MB)
|
||||
--proxy <URL> Proxy server URL
|
||||
--no-ipv4 Disable IPv4 fallback
|
||||
--no-redirect Disable following HTTP redirects
|
||||
--dns-servers <IPs> Custom DNS servers (comma-separated)
|
||||
--bind-interface <IFACE> Bind to specific network interface
|
||||
--allow-private Allow connections to private/internal IPs
|
||||
--pinned-cert <SHA256> Certificate pinning (SHA-256 hash)
|
||||
|
||||
CURL-COMPATIBLE
|
||||
--header "K: V" Custom HTTP header (repeatable)
|
||||
--insecure Skip TLS certificate verification
|
||||
--cert <FILE> Client certificate (PEM)
|
||||
--key <FILE> Client private key (PEM)
|
||||
--data "key=value" Send POST data
|
||||
--form "field=value" Multipart form data (repeatable)
|
||||
--form "field=@file" Upload file via multipart form
|
||||
--spider Check URL existence only (HEAD request)
|
||||
--write-out FORMAT Print metadata (curl format)
|
||||
--trace-time Print HTTP timing breakdown
|
||||
|
||||
WGET-COMPATIBLE
|
||||
--accept "*.pdf" Accept patterns (filename glob or MIME)
|
||||
--no-parent Do not ascend to parent directory
|
||||
--adjust-extension Append .html extension to HTML files
|
||||
--wait <DURATION> Delay between requests (recursive)
|
||||
--random-wait Randomize wait time (0.5x - 1.5x)
|
||||
--page-requisites Download CSS/JS/images for pages
|
||||
--span-hosts Follow links to external domains
|
||||
--convert-links Convert links for local viewing
|
||||
--warc-file <FILE> Write WARC output for archiving
|
||||
--progress=dot Wget-style dot progress
|
||||
|
||||
RECURSIVE / MIRROR
|
||||
--recursive Enable recursive downloading
|
||||
--mirror Mirror entire website (infinite depth)
|
||||
--max-depth <N> Maximum recursion depth
|
||||
--follow-external Follow external domains
|
||||
--exclude-pattern <P> Exclude URL patterns (comma-separated)
|
||||
--no-convert-links Don't convert links in mirror mode
|
||||
--no-mirror-assets Don't download CSS/JS/images in mirror
|
||||
--no-robots Don't respect robots.txt in mirror
|
||||
--rate-limit <RATE> Rate limit (e.g., 1MB/s)
|
||||
|
||||
DOWNLOAD MANAGEMENT
|
||||
--queue Add to download queue
|
||||
--queue-list List queue items
|
||||
--queue-process Process download queue
|
||||
--queue-clear Clear completed items from queue
|
||||
--queue-file <PATH> Custom queue file path
|
||||
--queue-priority <N> Priority (1-10)
|
||||
--batch-file <FILE> Download URLs from file (one per line)
|
||||
--schedule <TIME> Schedule download (datetime or cron)
|
||||
--on-complete <CMD> Run command after successful download
|
||||
|
||||
AUTHENTICATION
|
||||
--username <USER> Username for HTTP Basic/Digest Auth
|
||||
--password <PASS> Password (prefer --password-file for security)
|
||||
--password-file <FILE> Read password from file
|
||||
--auth-type <TYPE> Auth type: basic, digest, auto
|
||||
--cookie-jar <FILE> Load/save cookies (Netscape format)
|
||||
--oauth-client-id <ID> OAuth 2.0 Client ID
|
||||
--oauth-client-secret <S>OAuth 2.0 Client Secret
|
||||
--oauth-token-url <URL> OAuth 2.0 Token URL
|
||||
--oauth-access-token <T> OAuth 2.0 Access Token
|
||||
--oauth-refresh-token <T>OAuth 2.0 Refresh Token
|
||||
|
||||
UPLOAD
|
||||
--upload Upload file instead of downloading
|
||||
--upload-method <M> PUT or POST (default: PUT)
|
||||
--upload-file <FILE> File to upload
|
||||
--form-data "k=v" Form data (comma-separated)
|
||||
--file-field <NAME> Form field name (default: file)
|
||||
|
||||
VERIFICATION
|
||||
--checksum <HASH> Expected checksum
|
||||
--checksum-algo <ALG> Algorithm: sha256, sha512, blake2b, sha3-256, sha3-512
|
||||
--checksum-file <FILE> Read checksum from SHA256SUMS file
|
||||
|
||||
PGP / GPG
|
||||
--pgp-decrypt Decrypt PGP file after download
|
||||
--pgp-verify Verify PGP signature
|
||||
--pgp-sig <FILE> Path to signature file (.asc, .sig)
|
||||
--pgp-key <FILE> Path to key file
|
||||
--pgp-passphrase <PASS> Passphrase for private key
|
||||
--pgp-passphrase-file F Read passphrase from file
|
||||
|
||||
METALINK
|
||||
--metalink Treat URL as Metalink (multi-source)
|
||||
--metalink-file <PATH> Path to local Metalink file
|
||||
|
||||
CONFIGURATION
|
||||
--init-config Generate default config file
|
||||
--config-get <KEY> Get config value
|
||||
--config-set <K> <V> Set config value
|
||||
--config-list List all config values
|
||||
--config-unset <KEY> Unset config value
|
||||
|
||||
INFORMATION
|
||||
--info <URL> Show file info without downloading
|
||||
--benchmark <URL> Benchmark download speed
|
||||
--generate-man-page Generate man page and exit
|
||||
--completion <SHELL> Generate shell completion (bash, zsh, fish)
|
||||
--help Show this help
|
||||
--version Show version
|
||||
|
||||
PROTOCOLS
|
||||
http:// https:// HTTP/1.1, HTTP/2, TLS 1.2/1.3
|
||||
ftp:// ftps:// FTP (active/passive, explicit TLS)
|
||||
file:// Local file copy + recursive directories
|
||||
data: Inline data (base64 + plain text)
|
||||
gopher:// RFC 1436 with type parsing
|
||||
gemini:// Gemini protocol (TLS, redirect)
|
||||
|
||||
EXAMPLES
|
||||
goget --url https://example.com/file.zip
|
||||
goget --url https://a.com/1.zip --url https://b.com/2.zip
|
||||
goget -H "Authorization: Bearer TOKEN" --url https://api.example.com
|
||||
goget --form "name=value" --form "file=@./data.csv" --url https://httpbin.org/post
|
||||
goget --recursive --accept "*.pdf" --url https://example.com/docs/
|
||||
goget --mirror --convert-links --url https://example.com --output ./mirror
|
||||
goget --schedule "0 2 * * *" --url https://example.com/daily.zip
|
||||
goget --json --no-progress --url https://example.com/file.zip
|
||||
|
||||
FILES
|
||||
~/.config/goget/config.json Configuration
|
||||
<file>.goget.meta Resume metadata
|
||||
|
||||
More: https://codeberg.org/petrbalvin/goget
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Parser helps with argument parsing
|
||||
type Parser struct {
|
||||
fs *flag.FlagSet
|
||||
}
|
||||
|
||||
// NewParser creates new parser
|
||||
func NewParser(name string) *Parser {
|
||||
return &Parser{
|
||||
fs: flag.NewFlagSet(name, flag.ExitOnError),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse parses arguments
|
||||
func (p *Parser) Parse(args []string) error {
|
||||
return p.fs.Parse(args)
|
||||
}
|
||||
|
||||
// GetBool returns a bool flag
|
||||
func (p *Parser) GetBool(name string) bool {
|
||||
return p.fs.Lookup(name).Value.String() == "true"
|
||||
}
|
||||
|
||||
// GetString returns a string flag
|
||||
func (p *Parser) GetString(name string) string {
|
||||
return p.fs.Lookup(name).Value.String()
|
||||
}
|
||||
|
||||
// ParseArgs parses command-line arguments
|
||||
func ParseArgs(args []string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
key := strings.TrimPrefix(arg, "--")
|
||||
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
|
||||
result[key] = args[i+1]
|
||||
i++
|
||||
} else {
|
||||
result[key] = "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ValidateRequiredFlags validates required flags
|
||||
func ValidateRequiredFlags(flags map[string]string, required []string) error {
|
||||
var missing []string
|
||||
for _, req := range required {
|
||||
if _, exists := flags[req]; !exists {
|
||||
missing = append(missing, req)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExitWithError prints an error and exits the program
|
||||
func ExitWithError(err error) {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
format "codeberg.org/petrbalvin/goget/internal/format"
|
||||
)
|
||||
|
||||
// ProgressBarConfig configures the progress bar
|
||||
type ProgressBarConfig struct {
|
||||
Total int64
|
||||
Width int
|
||||
Colors bool
|
||||
OnComplete func()
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
// DefaultProgressBarConfig returns the default configuration
|
||||
func DefaultProgressBarConfig() *ProgressBarConfig {
|
||||
return &ProgressBarConfig{
|
||||
Total: -1,
|
||||
Width: 0,
|
||||
Colors: true,
|
||||
Writer: os.Stderr,
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressBar represents a visual progress bar
|
||||
type ProgressBar struct {
|
||||
config *ProgressBarConfig
|
||||
current int64
|
||||
startTime time.Time
|
||||
lastUpdate time.Time
|
||||
barWidth int
|
||||
mu sync.Mutex
|
||||
speedHistory []float64
|
||||
speedMax float64
|
||||
}
|
||||
|
||||
// NewProgressBar creates a new progress bar
|
||||
func NewProgressBar(cfg *ProgressBarConfig) *ProgressBar {
|
||||
if cfg == nil {
|
||||
cfg = DefaultProgressBarConfig()
|
||||
}
|
||||
|
||||
pb := &ProgressBar{
|
||||
config: cfg,
|
||||
current: 0,
|
||||
startTime: time.Now(),
|
||||
lastUpdate: time.Now(),
|
||||
barWidth: 40,
|
||||
}
|
||||
|
||||
if cfg.Width == 0 {
|
||||
tw := getTerminalWidth()
|
||||
pb.barWidth = max(20, tw-45)
|
||||
} else {
|
||||
pb.barWidth = cfg.Width
|
||||
}
|
||||
|
||||
return pb
|
||||
}
|
||||
|
||||
// SetTotal sets the total value in a thread-safe way
|
||||
func (pb *ProgressBar) SetTotal(total int64) {
|
||||
pb.mu.Lock()
|
||||
pb.config.Total = total
|
||||
pb.mu.Unlock()
|
||||
}
|
||||
|
||||
// Update updates progress
|
||||
func (pb *ProgressBar) Update(current int64) {
|
||||
pb.mu.Lock()
|
||||
pb.current = current
|
||||
pb.renderLocked()
|
||||
pb.mu.Unlock()
|
||||
}
|
||||
|
||||
// Add adds to the current progress
|
||||
func (pb *ProgressBar) Add(n int64) {
|
||||
pb.mu.Lock()
|
||||
pb.current += n
|
||||
pb.renderLocked()
|
||||
pb.mu.Unlock()
|
||||
}
|
||||
|
||||
// Finish completes the progress bar
|
||||
func (pb *ProgressBar) Finish() {
|
||||
pb.mu.Lock()
|
||||
if pb.config.Total > 0 {
|
||||
pb.current = pb.config.Total
|
||||
}
|
||||
pb.renderFinalLocked()
|
||||
pb.mu.Unlock()
|
||||
if pb.config.OnComplete != nil {
|
||||
pb.config.OnComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel cancels the progress bar with error state
|
||||
func (pb *ProgressBar) Cancel() {
|
||||
pb.mu.Lock()
|
||||
pb.renderCancelledLocked()
|
||||
pb.mu.Unlock()
|
||||
}
|
||||
|
||||
// renderLocked renders the current state; must be called with pb.mu locked
|
||||
func (pb *ProgressBar) renderLocked() {
|
||||
now := time.Now()
|
||||
if now.Sub(pb.lastUpdate) < 50*time.Millisecond {
|
||||
return
|
||||
}
|
||||
pb.lastUpdate = now
|
||||
|
||||
w := pb.config.Writer
|
||||
total := pb.config.Total
|
||||
current := pb.current
|
||||
elapsed := now.Sub(pb.startTime)
|
||||
|
||||
// Calculate speed
|
||||
speed := float64(0)
|
||||
if elapsed.Seconds() > 0 {
|
||||
speed = float64(current) / elapsed.Seconds()
|
||||
}
|
||||
|
||||
// Record speed history (keep last 15 samples)
|
||||
pb.speedHistory = append(pb.speedHistory, speed)
|
||||
if len(pb.speedHistory) > 15 {
|
||||
pb.speedHistory = pb.speedHistory[len(pb.speedHistory)-15:]
|
||||
}
|
||||
// Update max speed
|
||||
if speed > pb.speedMax {
|
||||
pb.speedMax = speed
|
||||
}
|
||||
|
||||
percent := float64(0)
|
||||
showPercent := total > 0
|
||||
if showPercent {
|
||||
percent = float64(current) / float64(total) * 100
|
||||
}
|
||||
|
||||
var percentStr string
|
||||
if showPercent {
|
||||
percentStr = fmt.Sprintf(" %5.1f%%", percent)
|
||||
} else {
|
||||
percentStr = " ??%"
|
||||
}
|
||||
|
||||
filled := 0
|
||||
empty := pb.barWidth
|
||||
if showPercent {
|
||||
filled = int(float64(pb.barWidth) * percent / 100)
|
||||
empty = pb.barWidth - filled
|
||||
}
|
||||
|
||||
barStart := ""
|
||||
barEnd := ""
|
||||
if pb.config.Colors {
|
||||
barStart = ColorGreen
|
||||
barEnd = ColorReset
|
||||
}
|
||||
|
||||
bar := barStart + strings.Repeat("█", filled) + barEnd + strings.Repeat("░", empty)
|
||||
|
||||
currentStr := format.Bytes(current)
|
||||
totalStr := "?"
|
||||
if total > 0 {
|
||||
totalStr = format.Bytes(total)
|
||||
}
|
||||
|
||||
speedStr := format.Speed(speed)
|
||||
|
||||
etaStr := ""
|
||||
if showPercent && current < total && speed > 0 {
|
||||
remaining := float64(total-current) / speed
|
||||
etaStr = formatDuration(time.Duration(remaining * float64(time.Second)))
|
||||
}
|
||||
|
||||
etaPrefix := ""
|
||||
etaSuffix := ""
|
||||
if etaStr != "" && pb.config.Colors {
|
||||
etaPrefix = ColorCyan
|
||||
etaSuffix = ColorReset
|
||||
}
|
||||
|
||||
// Build speed sparkline
|
||||
sparkline := pb.renderSparkline()
|
||||
|
||||
var line strings.Builder
|
||||
if pb.config.Colors {
|
||||
line.WriteString(ColorBold)
|
||||
}
|
||||
line.WriteString("[")
|
||||
line.WriteString(bar)
|
||||
line.WriteString("]")
|
||||
if pb.config.Colors {
|
||||
line.WriteString(ColorReset)
|
||||
}
|
||||
line.WriteString(percentStr)
|
||||
line.WriteString(fmt.Sprintf(" %s/%s", currentStr, totalStr))
|
||||
line.WriteString(fmt.Sprintf(" %s", speedStr))
|
||||
if sparkline != "" {
|
||||
line.WriteString(fmt.Sprintf(" %s", sparkline))
|
||||
}
|
||||
if etaStr != "" {
|
||||
line.WriteString(fmt.Sprintf(" %sETA: %s%s", etaPrefix, etaStr, etaSuffix))
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\r%s\033[K", line.String())
|
||||
}
|
||||
|
||||
// renderFinalLocked renders the completed state; must be called with pb.mu locked
|
||||
func (pb *ProgressBar) renderFinalLocked() {
|
||||
w := pb.config.Writer
|
||||
elapsed := time.Since(pb.startTime)
|
||||
|
||||
prefix := ""
|
||||
suffix := ""
|
||||
if pb.config.Colors {
|
||||
prefix = ColorGreen + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s✓ Download complete%s | %s | %s elapsed",
|
||||
prefix, suffix, format.Bytes(pb.current), formatDuration(elapsed))
|
||||
|
||||
fmt.Fprintf(w, "\r%s\n", line)
|
||||
}
|
||||
|
||||
// renderCancelledLocked renders the cancelled state; must be called with pb.mu locked
|
||||
func (pb *ProgressBar) renderCancelledLocked() {
|
||||
w := pb.config.Writer
|
||||
|
||||
prefix := ""
|
||||
suffix := ""
|
||||
if pb.config.Colors {
|
||||
prefix = ColorRed + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s✗ Download cancelled%s", prefix, suffix)
|
||||
fmt.Fprintf(w, "\r%s\n", line)
|
||||
}
|
||||
|
||||
// renderSparkline returns a mini ASCII sparkline of recent speed history.
|
||||
// Uses Unicode block elements: ▁▂▃▄▅▆▇█
|
||||
func (pb *ProgressBar) renderSparkline() string {
|
||||
if len(pb.speedHistory) < 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
chars := []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
|
||||
maxSpeed := pb.speedMax
|
||||
if maxSpeed == 0 {
|
||||
for _, s := range pb.speedHistory {
|
||||
if s > maxSpeed {
|
||||
maxSpeed = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxSpeed == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Take up to 10 most recent samples
|
||||
history := pb.speedHistory
|
||||
if len(history) > 10 {
|
||||
history = history[len(history)-10:]
|
||||
}
|
||||
|
||||
var sparkline strings.Builder
|
||||
for _, speed := range history {
|
||||
ratio := speed / maxSpeed
|
||||
if ratio > 1.0 {
|
||||
ratio = 1.0
|
||||
}
|
||||
idx := int(ratio * float64(len(chars)-1))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(chars) {
|
||||
idx = len(chars) - 1
|
||||
}
|
||||
sparkline.WriteString(chars[idx])
|
||||
}
|
||||
|
||||
return sparkline.String()
|
||||
}
|
||||
|
||||
// getTerminalWidth gets the terminal width
|
||||
func getTerminalWidth() int {
|
||||
if ws := os.Getenv("COLUMNS"); ws != "" {
|
||||
if w := parseInt(ws); w > 0 {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return 80
|
||||
}
|
||||
|
||||
// parseInt helper
|
||||
func parseInt(s string) int {
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// formatDuration formats a duration
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
m := d / time.Minute
|
||||
d -= m * time.Minute
|
||||
s := d / time.Second
|
||||
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
}
|
||||
if m > 0 {
|
||||
return fmt.Sprintf("%dm%ds", m, s)
|
||||
}
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
|
||||
// PrintProgressInfo prints an info message with progress styling
|
||||
func PrintProgressInfo(w io.Writer, msg string) {
|
||||
prefix, suffix := "", ""
|
||||
if isColorTerminal(w) {
|
||||
prefix = ColorCyan + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
fmt.Fprintf(w, "%s• %s%s\n", prefix, msg, suffix)
|
||||
}
|
||||
|
||||
// PrintProgressSuccess prints a success message with progress styling
|
||||
func PrintProgressSuccess(w io.Writer, msg string) {
|
||||
prefix, suffix := "", ""
|
||||
if isColorTerminal(w) {
|
||||
prefix = ColorGreen + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
fmt.Fprintf(w, "%s✓ %s%s\n", prefix, msg, suffix)
|
||||
}
|
||||
Reference in New Issue
Block a user