|
@@ -0,0 +1,1098 @@
|
|
|
+package progressbar
|
|
|
+
|
|
|
+import (
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "math"
|
|
|
+ "os"
|
|
|
+ "regexp"
|
|
|
+ "strings"
|
|
|
+ "sync"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/mattn/go-runewidth"
|
|
|
+ "github.com/mitchellh/colorstring"
|
|
|
+ "golang.org/x/term"
|
|
|
+)
|
|
|
+
|
|
|
+// ProgressBar is a thread-safe, simple
|
|
|
+// progress bar
|
|
|
+type ProgressBar struct {
|
|
|
+ state state
|
|
|
+ config config
|
|
|
+ lock sync.Mutex
|
|
|
+}
|
|
|
+
|
|
|
+// State is the basic properties of the bar
|
|
|
+type State struct {
|
|
|
+ CurrentPercent float64
|
|
|
+ CurrentBytes float64
|
|
|
+ SecondsSince float64
|
|
|
+ SecondsLeft float64
|
|
|
+ KBsPerSecond float64
|
|
|
+}
|
|
|
+
|
|
|
+type state struct {
|
|
|
+ currentNum int64
|
|
|
+ currentPercent int
|
|
|
+ lastPercent int
|
|
|
+ currentSaucerSize int
|
|
|
+ isAltSaucerHead bool
|
|
|
+
|
|
|
+ lastShown time.Time
|
|
|
+ startTime time.Time
|
|
|
+
|
|
|
+ counterTime time.Time
|
|
|
+ counterNumSinceLast int64
|
|
|
+ counterLastTenRates []float64
|
|
|
+
|
|
|
+ maxLineWidth int
|
|
|
+ currentBytes float64
|
|
|
+ finished bool
|
|
|
+ exit bool // Progress bar exit halfway
|
|
|
+
|
|
|
+ rendered string
|
|
|
+}
|
|
|
+
|
|
|
+type config struct {
|
|
|
+ max int64 // max number of the counter
|
|
|
+ maxHumanized string
|
|
|
+ maxHumanizedSuffix string
|
|
|
+ width int
|
|
|
+ writer io.Writer
|
|
|
+ theme Theme
|
|
|
+ renderWithBlankState bool
|
|
|
+ description string
|
|
|
+ iterationString string
|
|
|
+ ignoreLength bool // ignoreLength if max bytes not known
|
|
|
+
|
|
|
+ // whether the output is expected to contain color codes
|
|
|
+ colorCodes bool
|
|
|
+
|
|
|
+ // show rate of change in kB/sec or MB/sec
|
|
|
+ showBytes bool
|
|
|
+ // show the iterations per second
|
|
|
+ showIterationsPerSecond bool
|
|
|
+ showIterationsCount bool
|
|
|
+
|
|
|
+ // whether the progress bar should show elapsed time.
|
|
|
+ // always enabled if predictTime is true.
|
|
|
+ elapsedTime bool
|
|
|
+
|
|
|
+ showElapsedTimeOnFinish bool
|
|
|
+
|
|
|
+ // whether the progress bar should attempt to predict the finishing
|
|
|
+ // time of the progress based on the start time and the average
|
|
|
+ // number of seconds between increments.
|
|
|
+ predictTime bool
|
|
|
+
|
|
|
+ // minimum time to wait in between updates
|
|
|
+ throttleDuration time.Duration
|
|
|
+
|
|
|
+ // clear bar once finished
|
|
|
+ clearOnFinish bool
|
|
|
+
|
|
|
+ // spinnerType should be a number between 0-75
|
|
|
+ spinnerType int
|
|
|
+
|
|
|
+ // spinnerTypeOptionUsed remembers if the spinnerType was changed manually
|
|
|
+ spinnerTypeOptionUsed bool
|
|
|
+
|
|
|
+ // spinner represents the spinner as a slice of string
|
|
|
+ spinner []string
|
|
|
+
|
|
|
+ // fullWidth specifies whether to measure and set the bar to a specific width
|
|
|
+ fullWidth bool
|
|
|
+
|
|
|
+ // invisible doesn't render the bar at all, useful for debugging
|
|
|
+ invisible bool
|
|
|
+
|
|
|
+ onCompletion func()
|
|
|
+
|
|
|
+ // whether the render function should make use of ANSI codes to reduce console I/O
|
|
|
+ useANSICodes bool
|
|
|
+
|
|
|
+ // showDescriptionAtLineEnd specifies whether description should be written at line end instead of line start
|
|
|
+ showDescriptionAtLineEnd bool
|
|
|
+}
|
|
|
+
|
|
|
+// Theme defines the elements of the bar
|
|
|
+type Theme struct {
|
|
|
+ Saucer string
|
|
|
+ AltSaucerHead string
|
|
|
+ SaucerHead string
|
|
|
+ SaucerPadding string
|
|
|
+ BarStart string
|
|
|
+ BarEnd string
|
|
|
+}
|
|
|
+
|
|
|
+// Option is the type all options need to adhere to
|
|
|
+type Option func(p *ProgressBar)
|
|
|
+
|
|
|
+// OptionSetWidth sets the width of the bar
|
|
|
+func OptionSetWidth(s int) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.width = s
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSpinnerType sets the type of spinner used for indeterminate bars
|
|
|
+func OptionSpinnerType(spinnerType int) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.spinnerTypeOptionUsed = true
|
|
|
+ p.config.spinnerType = spinnerType
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSpinnerCustom sets the spinner used for indeterminate bars to the passed
|
|
|
+// slice of string
|
|
|
+func OptionSpinnerCustom(spinner []string) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.spinner = spinner
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSetTheme sets the elements the bar is constructed of
|
|
|
+func OptionSetTheme(t Theme) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.theme = t
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSetVisibility sets the visibility
|
|
|
+func OptionSetVisibility(visibility bool) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.invisible = !visibility
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionFullWidth sets the bar to be full width
|
|
|
+func OptionFullWidth() Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.fullWidth = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSetWriter sets the output writer (defaults to os.StdOut)
|
|
|
+func OptionSetWriter(w io.Writer) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.writer = w
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSetRenderBlankState sets whether or not to render a 0% bar on construction
|
|
|
+func OptionSetRenderBlankState(r bool) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.renderWithBlankState = r
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSetDescription sets the description of the bar to render in front of it
|
|
|
+func OptionSetDescription(description string) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.description = description
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionEnableColorCodes enables or disables support for color codes
|
|
|
+// using mitchellh/colorstring
|
|
|
+func OptionEnableColorCodes(colorCodes bool) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.colorCodes = colorCodes
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSetElapsedTime will enable elapsed time. Always enabled if OptionSetPredictTime is true.
|
|
|
+func OptionSetElapsedTime(elapsedTime bool) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.elapsedTime = elapsedTime
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSetPredictTime will also attempt to predict the time remaining.
|
|
|
+func OptionSetPredictTime(predictTime bool) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.predictTime = predictTime
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionShowCount will also print current count out of total
|
|
|
+func OptionShowCount() Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.showIterationsCount = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionShowIts will also print the iterations/second
|
|
|
+func OptionShowIts() Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.showIterationsPerSecond = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionShowElapsedOnFinish will keep the display of elapsed time on finish
|
|
|
+func OptionShowElapsedTimeOnFinish() Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.showElapsedTimeOnFinish = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionSetItsString sets what's displayed for iterations a second. The default is "it" which would display: "it/s"
|
|
|
+func OptionSetItsString(iterationString string) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.iterationString = iterationString
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionThrottle will wait the specified duration before updating again. The default
|
|
|
+// duration is 0 seconds.
|
|
|
+func OptionThrottle(duration time.Duration) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.throttleDuration = duration
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionClearOnFinish will clear the bar once its finished
|
|
|
+func OptionClearOnFinish() Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.clearOnFinish = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionOnCompletion will invoke cmpl function once its finished
|
|
|
+func OptionOnCompletion(cmpl func()) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.onCompletion = cmpl
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionShowBytes will update the progress bar
|
|
|
+// configuration settings to display/hide kBytes/Sec
|
|
|
+func OptionShowBytes(val bool) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.showBytes = val
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionUseANSICodes will use more optimized terminal i/o.
|
|
|
+//
|
|
|
+// Only useful in environments with support for ANSI escape sequences.
|
|
|
+func OptionUseANSICodes(val bool) Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.useANSICodes = val
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// OptionShowDescriptionAtLineEnd defines whether description should be written at line end instead of line start
|
|
|
+func OptionShowDescriptionAtLineEnd() Option {
|
|
|
+ return func(p *ProgressBar) {
|
|
|
+ p.config.showDescriptionAtLineEnd = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+var defaultTheme = Theme{Saucer: "█", SaucerPadding: " ", BarStart: "|", BarEnd: "|"}
|
|
|
+
|
|
|
+// NewOptions constructs a new instance of ProgressBar, with any options you specify
|
|
|
+func NewOptions(max int, options ...Option) *ProgressBar {
|
|
|
+ return NewOptions64(int64(max), options...)
|
|
|
+}
|
|
|
+
|
|
|
+// NewOptions64 constructs a new instance of ProgressBar, with any options you specify
|
|
|
+func NewOptions64(max int64, options ...Option) *ProgressBar {
|
|
|
+ b := ProgressBar{
|
|
|
+ state: getBasicState(),
|
|
|
+ config: config{
|
|
|
+ writer: os.Stdout,
|
|
|
+ theme: defaultTheme,
|
|
|
+ iterationString: "it",
|
|
|
+ width: 40,
|
|
|
+ max: max,
|
|
|
+ throttleDuration: 0 * time.Nanosecond,
|
|
|
+ elapsedTime: true,
|
|
|
+ predictTime: true,
|
|
|
+ spinnerType: 9,
|
|
|
+ invisible: false,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, o := range options {
|
|
|
+ o(&b)
|
|
|
+ }
|
|
|
+
|
|
|
+ if b.config.spinnerType < 0 || b.config.spinnerType > 75 {
|
|
|
+ panic("invalid spinner type, must be between 0 and 75")
|
|
|
+ }
|
|
|
+
|
|
|
+ // ignoreLength if max bytes not known
|
|
|
+ if b.config.max == -1 {
|
|
|
+ b.config.ignoreLength = true
|
|
|
+ b.config.max = int64(b.config.width)
|
|
|
+ b.config.predictTime = false
|
|
|
+ }
|
|
|
+
|
|
|
+ b.config.maxHumanized, b.config.maxHumanizedSuffix = humanizeBytes(float64(b.config.max))
|
|
|
+
|
|
|
+ if b.config.renderWithBlankState {
|
|
|
+ b.RenderBlank()
|
|
|
+ }
|
|
|
+
|
|
|
+ return &b
|
|
|
+}
|
|
|
+
|
|
|
+func getBasicState() state {
|
|
|
+ now := time.Now()
|
|
|
+ return state{
|
|
|
+ startTime: now,
|
|
|
+ lastShown: now,
|
|
|
+ counterTime: now,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// New returns a new ProgressBar
|
|
|
+// with the specified maximum
|
|
|
+func New(max int) *ProgressBar {
|
|
|
+ return NewOptions(max)
|
|
|
+}
|
|
|
+
|
|
|
+// DefaultBytes provides a progressbar to measure byte
|
|
|
+// throughput with recommended defaults.
|
|
|
+// Set maxBytes to -1 to use as a spinner.
|
|
|
+func DefaultBytes(maxBytes int64, description ...string) *ProgressBar {
|
|
|
+ desc := ""
|
|
|
+ if len(description) > 0 {
|
|
|
+ desc = description[0]
|
|
|
+ }
|
|
|
+ return NewOptions64(
|
|
|
+ maxBytes,
|
|
|
+ OptionSetDescription(desc),
|
|
|
+ OptionSetWriter(os.Stderr),
|
|
|
+ OptionShowBytes(true),
|
|
|
+ OptionSetWidth(10),
|
|
|
+ OptionThrottle(65*time.Millisecond),
|
|
|
+ OptionShowCount(),
|
|
|
+ OptionOnCompletion(func() {
|
|
|
+ fmt.Fprint(os.Stderr, "\n")
|
|
|
+ }),
|
|
|
+ OptionSpinnerType(14),
|
|
|
+ OptionFullWidth(),
|
|
|
+ OptionSetRenderBlankState(true),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// DefaultBytesSilent is the same as DefaultBytes, but does not output anywhere.
|
|
|
+// String() can be used to get the output instead.
|
|
|
+func DefaultBytesSilent(maxBytes int64, description ...string) *ProgressBar {
|
|
|
+ // Mostly the same bar as DefaultBytes
|
|
|
+
|
|
|
+ desc := ""
|
|
|
+ if len(description) > 0 {
|
|
|
+ desc = description[0]
|
|
|
+ }
|
|
|
+ return NewOptions64(
|
|
|
+ maxBytes,
|
|
|
+ OptionSetDescription(desc),
|
|
|
+ OptionSetWriter(io.Discard),
|
|
|
+ OptionShowBytes(true),
|
|
|
+ OptionSetWidth(10),
|
|
|
+ OptionThrottle(65*time.Millisecond),
|
|
|
+ OptionShowCount(),
|
|
|
+ OptionSpinnerType(14),
|
|
|
+ OptionFullWidth(),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// Default provides a progressbar with recommended defaults.
|
|
|
+// Set max to -1 to use as a spinner.
|
|
|
+func Default(max int64, description ...string) *ProgressBar {
|
|
|
+ desc := ""
|
|
|
+ if len(description) > 0 {
|
|
|
+ desc = description[0]
|
|
|
+ }
|
|
|
+ return NewOptions64(
|
|
|
+ max,
|
|
|
+ OptionSetDescription(desc),
|
|
|
+ OptionSetWriter(os.Stderr),
|
|
|
+ OptionSetWidth(10),
|
|
|
+ OptionThrottle(65*time.Millisecond),
|
|
|
+ OptionShowCount(),
|
|
|
+ OptionShowIts(),
|
|
|
+ OptionOnCompletion(func() {
|
|
|
+ fmt.Fprint(os.Stderr, "\n")
|
|
|
+ }),
|
|
|
+ OptionSpinnerType(14),
|
|
|
+ OptionFullWidth(),
|
|
|
+ OptionSetRenderBlankState(true),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// DefaultSilent is the same as Default, but does not output anywhere.
|
|
|
+// String() can be used to get the output instead.
|
|
|
+func DefaultSilent(max int64, description ...string) *ProgressBar {
|
|
|
+ // Mostly the same bar as Default
|
|
|
+
|
|
|
+ desc := ""
|
|
|
+ if len(description) > 0 {
|
|
|
+ desc = description[0]
|
|
|
+ }
|
|
|
+ return NewOptions64(
|
|
|
+ max,
|
|
|
+ OptionSetDescription(desc),
|
|
|
+ OptionSetWriter(io.Discard),
|
|
|
+ OptionSetWidth(10),
|
|
|
+ OptionThrottle(65*time.Millisecond),
|
|
|
+ OptionShowCount(),
|
|
|
+ OptionShowIts(),
|
|
|
+ OptionSpinnerType(14),
|
|
|
+ OptionFullWidth(),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// String returns the current rendered version of the progress bar.
|
|
|
+// It will never return an empty string while the progress bar is running.
|
|
|
+func (p *ProgressBar) String() string {
|
|
|
+ return p.state.rendered
|
|
|
+}
|
|
|
+
|
|
|
+// RenderBlank renders the current bar state, you can use this to render a 0% state
|
|
|
+func (p *ProgressBar) RenderBlank() error {
|
|
|
+ if p.config.invisible {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if p.state.currentNum == 0 {
|
|
|
+ p.state.lastShown = time.Time{}
|
|
|
+ }
|
|
|
+ return p.render()
|
|
|
+}
|
|
|
+
|
|
|
+// Reset will reset the clock that is used
|
|
|
+// to calculate current time and the time left.
|
|
|
+func (p *ProgressBar) Reset() {
|
|
|
+ p.lock.Lock()
|
|
|
+ defer p.lock.Unlock()
|
|
|
+
|
|
|
+ p.state = getBasicState()
|
|
|
+}
|
|
|
+
|
|
|
+// Finish will fill the bar to full
|
|
|
+func (p *ProgressBar) Finish() error {
|
|
|
+ p.lock.Lock()
|
|
|
+ p.state.currentNum = p.config.max
|
|
|
+ p.lock.Unlock()
|
|
|
+ return p.Add(0)
|
|
|
+}
|
|
|
+
|
|
|
+// Exit will exit the bar to keep current state
|
|
|
+func (p *ProgressBar) Exit() error {
|
|
|
+ p.lock.Lock()
|
|
|
+ defer p.lock.Unlock()
|
|
|
+
|
|
|
+ p.state.exit = true
|
|
|
+ if p.config.onCompletion != nil {
|
|
|
+ p.config.onCompletion()
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// Add will add the specified amount to the progressbar
|
|
|
+func (p *ProgressBar) Add(num int) error {
|
|
|
+ return p.Add64(int64(num))
|
|
|
+}
|
|
|
+
|
|
|
+// Set will set the bar to a current number
|
|
|
+func (p *ProgressBar) Set(num int) error {
|
|
|
+ return p.Set64(int64(num))
|
|
|
+}
|
|
|
+
|
|
|
+// Set64 will set the bar to a current number
|
|
|
+func (p *ProgressBar) Set64(num int64) error {
|
|
|
+ p.lock.Lock()
|
|
|
+ toAdd := num - int64(p.state.currentBytes)
|
|
|
+ p.lock.Unlock()
|
|
|
+ return p.Add64(toAdd)
|
|
|
+}
|
|
|
+
|
|
|
+// Add64 will add the specified amount to the progressbar
|
|
|
+func (p *ProgressBar) Add64(num int64) error {
|
|
|
+ if p.config.invisible {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ p.lock.Lock()
|
|
|
+ defer p.lock.Unlock()
|
|
|
+
|
|
|
+ if p.state.exit {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ // error out since OptionSpinnerCustom will always override a manually set spinnerType
|
|
|
+ if p.config.spinnerTypeOptionUsed && len(p.config.spinner) > 0 {
|
|
|
+ return errors.New("OptionSpinnerType and OptionSpinnerCustom cannot be used together")
|
|
|
+ }
|
|
|
+
|
|
|
+ if p.config.max == 0 {
|
|
|
+ return errors.New("max must be greater than 0")
|
|
|
+ }
|
|
|
+
|
|
|
+ if p.state.currentNum < p.config.max {
|
|
|
+ if p.config.ignoreLength {
|
|
|
+ p.state.currentNum = (p.state.currentNum + num) % p.config.max
|
|
|
+ } else {
|
|
|
+ p.state.currentNum += num
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ p.state.currentBytes += float64(num)
|
|
|
+
|
|
|
+ // reset the countdown timer every second to take rolling average
|
|
|
+ p.state.counterNumSinceLast += num
|
|
|
+ if time.Since(p.state.counterTime).Seconds() > 0.5 {
|
|
|
+ p.state.counterLastTenRates = append(p.state.counterLastTenRates, float64(p.state.counterNumSinceLast)/time.Since(p.state.counterTime).Seconds())
|
|
|
+ if len(p.state.counterLastTenRates) > 10 {
|
|
|
+ p.state.counterLastTenRates = p.state.counterLastTenRates[1:]
|
|
|
+ }
|
|
|
+ p.state.counterTime = time.Now()
|
|
|
+ p.state.counterNumSinceLast = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ percent := float64(p.state.currentNum) / float64(p.config.max)
|
|
|
+ p.state.currentSaucerSize = int(percent * float64(p.config.width))
|
|
|
+ p.state.currentPercent = int(percent * 100)
|
|
|
+ updateBar := p.state.currentPercent != p.state.lastPercent && p.state.currentPercent > 0
|
|
|
+
|
|
|
+ p.state.lastPercent = p.state.currentPercent
|
|
|
+ if p.state.currentNum > p.config.max {
|
|
|
+ return errors.New("current number exceeds max")
|
|
|
+ }
|
|
|
+
|
|
|
+ // always update if show bytes/second or its/second
|
|
|
+ if updateBar || p.config.showIterationsPerSecond || p.config.showIterationsCount {
|
|
|
+ return p.render()
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// Clear erases the progress bar from the current line
|
|
|
+func (p *ProgressBar) Clear() error {
|
|
|
+ return clearProgressBar(p.config, p.state)
|
|
|
+}
|
|
|
+
|
|
|
+// Describe will change the description shown before the progress, which
|
|
|
+// can be changed on the fly (as for a slow running process).
|
|
|
+func (p *ProgressBar) Describe(description string) {
|
|
|
+ p.lock.Lock()
|
|
|
+ defer p.lock.Unlock()
|
|
|
+ p.config.description = description
|
|
|
+ if p.config.invisible {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ p.render()
|
|
|
+}
|
|
|
+
|
|
|
+// New64 returns a new ProgressBar
|
|
|
+// with the specified maximum
|
|
|
+func New64(max int64) *ProgressBar {
|
|
|
+ return NewOptions64(max)
|
|
|
+}
|
|
|
+
|
|
|
+// GetMax returns the max of a bar
|
|
|
+func (p *ProgressBar) GetMax() int {
|
|
|
+ return int(p.config.max)
|
|
|
+}
|
|
|
+
|
|
|
+// GetMax64 returns the current max
|
|
|
+func (p *ProgressBar) GetMax64() int64 {
|
|
|
+ return p.config.max
|
|
|
+}
|
|
|
+
|
|
|
+// ChangeMax takes in a int
|
|
|
+// and changes the max value
|
|
|
+// of the progress bar
|
|
|
+func (p *ProgressBar) ChangeMax(newMax int) {
|
|
|
+ p.ChangeMax64(int64(newMax))
|
|
|
+}
|
|
|
+
|
|
|
+// ChangeMax64 is basically
|
|
|
+// the same as ChangeMax,
|
|
|
+// but takes in a int64
|
|
|
+// to avoid casting
|
|
|
+func (p *ProgressBar) ChangeMax64(newMax int64) {
|
|
|
+ p.config.max = newMax
|
|
|
+
|
|
|
+ if p.config.showBytes {
|
|
|
+ p.config.maxHumanized, p.config.maxHumanizedSuffix = humanizeBytes(float64(p.config.max))
|
|
|
+ }
|
|
|
+
|
|
|
+ p.Add(0) // re-render
|
|
|
+}
|
|
|
+
|
|
|
+// IsFinished returns true if progress bar is completed
|
|
|
+func (p *ProgressBar) IsFinished() bool {
|
|
|
+ return p.state.finished
|
|
|
+}
|
|
|
+
|
|
|
+// render renders the progress bar, updating the maximum
|
|
|
+// rendered line width. this function is not thread-safe,
|
|
|
+// so it must be called with an acquired lock.
|
|
|
+func (p *ProgressBar) render() error {
|
|
|
+ // make sure that the rendering is not happening too quickly
|
|
|
+ // but always show if the currentNum reaches the max
|
|
|
+ if time.Since(p.state.lastShown).Nanoseconds() < p.config.throttleDuration.Nanoseconds() &&
|
|
|
+ p.state.currentNum < p.config.max {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if !p.config.useANSICodes {
|
|
|
+ // first, clear the existing progress bar
|
|
|
+ err := clearProgressBar(p.config, p.state)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // check if the progress bar is finished
|
|
|
+ if !p.state.finished && p.state.currentNum >= p.config.max {
|
|
|
+ p.state.finished = true
|
|
|
+ if !p.config.clearOnFinish {
|
|
|
+ renderProgressBar(p.config, &p.state)
|
|
|
+ }
|
|
|
+ if p.config.onCompletion != nil {
|
|
|
+ p.config.onCompletion()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if p.state.finished {
|
|
|
+ // when using ANSI codes we don't pre-clean the current line
|
|
|
+ if p.config.useANSICodes && p.config.clearOnFinish {
|
|
|
+ err := clearProgressBar(p.config, p.state)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ // then, re-render the current progress bar
|
|
|
+ w, err := renderProgressBar(p.config, &p.state)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ if w > p.state.maxLineWidth {
|
|
|
+ p.state.maxLineWidth = w
|
|
|
+ }
|
|
|
+
|
|
|
+ p.state.lastShown = time.Now()
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// State returns the current state
|
|
|
+func (p *ProgressBar) State() State {
|
|
|
+ p.lock.Lock()
|
|
|
+ defer p.lock.Unlock()
|
|
|
+ s := State{}
|
|
|
+ s.CurrentPercent = float64(p.state.currentNum) / float64(p.config.max)
|
|
|
+ s.CurrentBytes = p.state.currentBytes
|
|
|
+ s.SecondsSince = time.Since(p.state.startTime).Seconds()
|
|
|
+ if p.state.currentNum > 0 {
|
|
|
+ s.SecondsLeft = s.SecondsSince / float64(p.state.currentNum) * (float64(p.config.max) - float64(p.state.currentNum))
|
|
|
+ }
|
|
|
+ s.KBsPerSecond = float64(p.state.currentBytes) / 1000.0 / s.SecondsSince
|
|
|
+ return s
|
|
|
+}
|
|
|
+
|
|
|
+// regex matching ansi escape codes
|
|
|
+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
|
|
+
|
|
|
+func getStringWidth(c config, str string, colorize bool) int {
|
|
|
+ if c.colorCodes {
|
|
|
+ // convert any color codes in the progress bar into the respective ANSI codes
|
|
|
+ str = colorstring.Color(str)
|
|
|
+ }
|
|
|
+
|
|
|
+ // the width of the string, if printed to the console
|
|
|
+ // does not include the carriage return character
|
|
|
+ cleanString := strings.Replace(str, "\r", "", -1)
|
|
|
+
|
|
|
+ if c.colorCodes {
|
|
|
+ // the ANSI codes for the colors do not take up space in the console output,
|
|
|
+ // so they do not count towards the output string width
|
|
|
+ cleanString = ansiRegex.ReplaceAllString(cleanString, "")
|
|
|
+ }
|
|
|
+
|
|
|
+ // get the amount of runes in the string instead of the
|
|
|
+ // character count of the string, as some runes span multiple characters.
|
|
|
+ // see https://stackoverflow.com/a/12668840/2733724
|
|
|
+ stringWidth := runewidth.StringWidth(cleanString)
|
|
|
+ return stringWidth
|
|
|
+}
|
|
|
+
|
|
|
+func renderProgressBar(c config, s *state) (int, error) {
|
|
|
+ var sb strings.Builder
|
|
|
+
|
|
|
+ averageRate := average(s.counterLastTenRates)
|
|
|
+ if len(s.counterLastTenRates) == 0 || s.finished {
|
|
|
+ // if no average samples, or if finished,
|
|
|
+ // then average rate should be the total rate
|
|
|
+ if t := time.Since(s.startTime).Seconds(); t > 0 {
|
|
|
+ averageRate = s.currentBytes / t
|
|
|
+ } else {
|
|
|
+ averageRate = 0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // show iteration count in "current/total" iterations format
|
|
|
+ if c.showIterationsCount {
|
|
|
+ if sb.Len() == 0 {
|
|
|
+ sb.WriteString("(")
|
|
|
+ } else {
|
|
|
+ sb.WriteString(", ")
|
|
|
+ }
|
|
|
+ if !c.ignoreLength {
|
|
|
+ if c.showBytes {
|
|
|
+ currentHumanize, currentSuffix := humanizeBytes(s.currentBytes)
|
|
|
+ if currentSuffix == c.maxHumanizedSuffix {
|
|
|
+ sb.WriteString(fmt.Sprintf("%s/%s%s",
|
|
|
+ currentHumanize, c.maxHumanized, c.maxHumanizedSuffix))
|
|
|
+ } else {
|
|
|
+ sb.WriteString(fmt.Sprintf("%s%s/%s%s",
|
|
|
+ currentHumanize, currentSuffix, c.maxHumanized, c.maxHumanizedSuffix))
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ sb.WriteString(fmt.Sprintf("%.0f/%d", s.currentBytes, c.max))
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if c.showBytes {
|
|
|
+ currentHumanize, currentSuffix := humanizeBytes(s.currentBytes)
|
|
|
+ sb.WriteString(fmt.Sprintf("%s%s", currentHumanize, currentSuffix))
|
|
|
+ } else {
|
|
|
+ sb.WriteString(fmt.Sprintf("%.0f/%s", s.currentBytes, "-"))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // show rolling average rate
|
|
|
+ if c.showBytes && averageRate > 0 && !math.IsInf(averageRate, 1) {
|
|
|
+ if sb.Len() == 0 {
|
|
|
+ sb.WriteString("(")
|
|
|
+ } else {
|
|
|
+ sb.WriteString(", ")
|
|
|
+ }
|
|
|
+ currentHumanize, currentSuffix := humanizeBytes(averageRate)
|
|
|
+ sb.WriteString(fmt.Sprintf("%s%s/s", currentHumanize, currentSuffix))
|
|
|
+ }
|
|
|
+
|
|
|
+ // show iterations rate
|
|
|
+ if c.showIterationsPerSecond {
|
|
|
+ if sb.Len() == 0 {
|
|
|
+ sb.WriteString("(")
|
|
|
+ } else {
|
|
|
+ sb.WriteString(", ")
|
|
|
+ }
|
|
|
+ if averageRate > 1 {
|
|
|
+ sb.WriteString(fmt.Sprintf("%0.0f %s/s", averageRate, c.iterationString))
|
|
|
+ } else if averageRate*60 > 1 {
|
|
|
+ sb.WriteString(fmt.Sprintf("%0.0f %s/min", 60*averageRate, c.iterationString))
|
|
|
+ } else {
|
|
|
+ sb.WriteString(fmt.Sprintf("%0.0f %s/hr", 3600*averageRate, c.iterationString))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if sb.Len() > 0 {
|
|
|
+ sb.WriteString(")")
|
|
|
+ }
|
|
|
+
|
|
|
+ leftBrac, rightBrac, saucer, saucerHead := "", "", "", ""
|
|
|
+
|
|
|
+ // show time prediction in "current/total" seconds format
|
|
|
+ switch {
|
|
|
+ case c.predictTime:
|
|
|
+ rightBracNum := (time.Duration((1/averageRate)*(float64(c.max)-float64(s.currentNum))) * time.Second)
|
|
|
+ if rightBracNum.Seconds() < 0 {
|
|
|
+ rightBracNum = 0 * time.Second
|
|
|
+ }
|
|
|
+ rightBrac = rightBracNum.String()
|
|
|
+ fallthrough
|
|
|
+ case c.elapsedTime:
|
|
|
+ leftBrac = (time.Duration(time.Since(s.startTime).Seconds()) * time.Second).String()
|
|
|
+ }
|
|
|
+
|
|
|
+ if c.fullWidth && !c.ignoreLength {
|
|
|
+ width, err := termWidth()
|
|
|
+ if err != nil {
|
|
|
+ width = 80
|
|
|
+ }
|
|
|
+
|
|
|
+ amend := 1 // an extra space at eol
|
|
|
+ switch {
|
|
|
+ case leftBrac != "" && rightBrac != "":
|
|
|
+ amend = 4 // space, square brackets and colon
|
|
|
+ case leftBrac != "" && rightBrac == "":
|
|
|
+ amend = 4 // space and square brackets and another space
|
|
|
+ case leftBrac == "" && rightBrac != "":
|
|
|
+ amend = 3 // space and square brackets
|
|
|
+ }
|
|
|
+ if c.showDescriptionAtLineEnd {
|
|
|
+ amend += 1 // another space
|
|
|
+ }
|
|
|
+
|
|
|
+ c.width = width - getStringWidth(c, c.description, true) - 10 - amend - sb.Len() - len(leftBrac) - len(rightBrac)
|
|
|
+ s.currentSaucerSize = int(float64(s.currentPercent) / 100.0 * float64(c.width))
|
|
|
+ }
|
|
|
+ if s.currentSaucerSize > 0 {
|
|
|
+ if c.ignoreLength {
|
|
|
+ saucer = strings.Repeat(c.theme.SaucerPadding, s.currentSaucerSize-1)
|
|
|
+ } else {
|
|
|
+ saucer = strings.Repeat(c.theme.Saucer, s.currentSaucerSize-1)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if an alternate saucer head is set for animation
|
|
|
+ if c.theme.AltSaucerHead != "" && s.isAltSaucerHead {
|
|
|
+ saucerHead = c.theme.AltSaucerHead
|
|
|
+ s.isAltSaucerHead = false
|
|
|
+ } else if c.theme.SaucerHead == "" || s.currentSaucerSize == c.width {
|
|
|
+ // use the saucer for the saucer head if it hasn't been set
|
|
|
+ // to preserve backwards compatibility
|
|
|
+ saucerHead = c.theme.Saucer
|
|
|
+ } else {
|
|
|
+ saucerHead = c.theme.SaucerHead
|
|
|
+ s.isAltSaucerHead = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ Progress Bar format
|
|
|
+ Description % |------ | (kb/s) (iteration count) (iteration rate) (predict time)
|
|
|
+
|
|
|
+ or if showDescriptionAtLineEnd is enabled
|
|
|
+ % |------ | (kb/s) (iteration count) (iteration rate) (predict time) Description
|
|
|
+ */
|
|
|
+
|
|
|
+ repeatAmount := c.width - s.currentSaucerSize
|
|
|
+ if repeatAmount < 0 {
|
|
|
+ repeatAmount = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ str := ""
|
|
|
+
|
|
|
+ if c.ignoreLength {
|
|
|
+ selectedSpinner := spinners[c.spinnerType]
|
|
|
+ if len(c.spinner) > 0 {
|
|
|
+ selectedSpinner = c.spinner
|
|
|
+ }
|
|
|
+ spinner := selectedSpinner[int(math.Round(math.Mod(float64(time.Since(s.startTime).Milliseconds()/100), float64(len(selectedSpinner)))))]
|
|
|
+ if c.elapsedTime {
|
|
|
+ if c.showDescriptionAtLineEnd {
|
|
|
+ str = fmt.Sprintf("\r%s %s [%s] %s ",
|
|
|
+ spinner,
|
|
|
+ sb.String(),
|
|
|
+ leftBrac,
|
|
|
+ c.description)
|
|
|
+ } else {
|
|
|
+ str = fmt.Sprintf("\r%s %s %s [%s] ",
|
|
|
+ spinner,
|
|
|
+ c.description,
|
|
|
+ sb.String(),
|
|
|
+ leftBrac)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if c.showDescriptionAtLineEnd {
|
|
|
+ str = fmt.Sprintf("\r%s %s %s ",
|
|
|
+ spinner,
|
|
|
+ sb.String(),
|
|
|
+ c.description)
|
|
|
+ } else {
|
|
|
+ str = fmt.Sprintf("\r%s %s %s ",
|
|
|
+ spinner,
|
|
|
+ c.description,
|
|
|
+ sb.String())
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if rightBrac == "" {
|
|
|
+ str = fmt.Sprintf("%4d%% %s%s%s%s%s %s",
|
|
|
+ s.currentPercent,
|
|
|
+ c.theme.BarStart,
|
|
|
+ saucer,
|
|
|
+ saucerHead,
|
|
|
+ strings.Repeat(c.theme.SaucerPadding, repeatAmount),
|
|
|
+ c.theme.BarEnd,
|
|
|
+ sb.String())
|
|
|
+
|
|
|
+ if s.currentPercent == 100 && c.showElapsedTimeOnFinish {
|
|
|
+ str = fmt.Sprintf("%s [%s]", str, leftBrac)
|
|
|
+ }
|
|
|
+
|
|
|
+ if c.showDescriptionAtLineEnd {
|
|
|
+ str = fmt.Sprintf("\r%s %s ", str, c.description)
|
|
|
+ } else {
|
|
|
+ str = fmt.Sprintf("\r%s%s ", c.description, str)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if s.currentPercent == 100 {
|
|
|
+ str = fmt.Sprintf("%4d%% %s%s%s%s%s %s",
|
|
|
+ s.currentPercent,
|
|
|
+ c.theme.BarStart,
|
|
|
+ saucer,
|
|
|
+ saucerHead,
|
|
|
+ strings.Repeat(c.theme.SaucerPadding, repeatAmount),
|
|
|
+ c.theme.BarEnd,
|
|
|
+ sb.String())
|
|
|
+
|
|
|
+ if c.showElapsedTimeOnFinish {
|
|
|
+ str = fmt.Sprintf("%s [%s]", str, leftBrac)
|
|
|
+ }
|
|
|
+
|
|
|
+ if c.showDescriptionAtLineEnd {
|
|
|
+ str = fmt.Sprintf("\r%s %s", str, c.description)
|
|
|
+ } else {
|
|
|
+ str = fmt.Sprintf("\r%s%s", c.description, str)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ str = fmt.Sprintf("%4d%% %s%s%s%s%s %s [%s:%s]",
|
|
|
+ s.currentPercent,
|
|
|
+ c.theme.BarStart,
|
|
|
+ saucer,
|
|
|
+ saucerHead,
|
|
|
+ strings.Repeat(c.theme.SaucerPadding, repeatAmount),
|
|
|
+ c.theme.BarEnd,
|
|
|
+ sb.String(),
|
|
|
+ leftBrac,
|
|
|
+ rightBrac)
|
|
|
+
|
|
|
+ if c.showDescriptionAtLineEnd {
|
|
|
+ str = fmt.Sprintf("\r%s %s", str, c.description)
|
|
|
+ } else {
|
|
|
+ str = fmt.Sprintf("\r%s%s", c.description, str)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if c.colorCodes {
|
|
|
+ // convert any color codes in the progress bar into the respective ANSI codes
|
|
|
+ str = colorstring.Color(str)
|
|
|
+ }
|
|
|
+
|
|
|
+ s.rendered = str
|
|
|
+
|
|
|
+ return getStringWidth(c, str, false), writeString(c, str)
|
|
|
+}
|
|
|
+
|
|
|
+func clearProgressBar(c config, s state) error {
|
|
|
+ if s.maxLineWidth == 0 {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if c.useANSICodes {
|
|
|
+ // write the "clear current line" ANSI escape sequence
|
|
|
+ return writeString(c, "\033[2K\r")
|
|
|
+ }
|
|
|
+ // fill the empty content
|
|
|
+ // to overwrite the progress bar and jump
|
|
|
+ // back to the beginning of the line
|
|
|
+ str := fmt.Sprintf("\r%s\r", strings.Repeat(" ", s.maxLineWidth))
|
|
|
+ return writeString(c, str)
|
|
|
+ // the following does not show correctly if the previous line is longer than subsequent line
|
|
|
+ // return writeString(c, "\r")
|
|
|
+}
|
|
|
+
|
|
|
+func writeString(c config, str string) error {
|
|
|
+ if _, err := io.WriteString(c.writer, str); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ if f, ok := c.writer.(*os.File); ok {
|
|
|
+ // ignore any errors in Sync(), as stdout
|
|
|
+ // can't be synced on some operating systems
|
|
|
+ // like Debian 9 (Stretch)
|
|
|
+ f.Sync()
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// Reader is the progressbar io.Reader struct
|
|
|
+type Reader struct {
|
|
|
+ io.Reader
|
|
|
+ bar *ProgressBar
|
|
|
+}
|
|
|
+
|
|
|
+// NewReader return a new Reader with a given progress bar.
|
|
|
+func NewReader(r io.Reader, bar *ProgressBar) Reader {
|
|
|
+ return Reader{
|
|
|
+ Reader: r,
|
|
|
+ bar: bar,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Read will read the data and add the number of bytes to the progressbar
|
|
|
+func (r *Reader) Read(p []byte) (n int, err error) {
|
|
|
+ n, err = r.Reader.Read(p)
|
|
|
+ r.bar.Add(n)
|
|
|
+ return
|
|
|
+}
|
|
|
+
|
|
|
+// Close the reader when it implements io.Closer
|
|
|
+func (r *Reader) Close() (err error) {
|
|
|
+ if closer, ok := r.Reader.(io.Closer); ok {
|
|
|
+ return closer.Close()
|
|
|
+ }
|
|
|
+ r.bar.Finish()
|
|
|
+ return
|
|
|
+}
|
|
|
+
|
|
|
+// Write implement io.Writer
|
|
|
+func (p *ProgressBar) Write(b []byte) (n int, err error) {
|
|
|
+ n = len(b)
|
|
|
+ p.Add(n)
|
|
|
+ return
|
|
|
+}
|
|
|
+
|
|
|
+// Read implement io.Reader
|
|
|
+func (p *ProgressBar) Read(b []byte) (n int, err error) {
|
|
|
+ n = len(b)
|
|
|
+ p.Add(n)
|
|
|
+ return
|
|
|
+}
|
|
|
+
|
|
|
+func (p *ProgressBar) Close() (err error) {
|
|
|
+ p.Finish()
|
|
|
+ return
|
|
|
+}
|
|
|
+
|
|
|
+func average(xs []float64) float64 {
|
|
|
+ total := 0.0
|
|
|
+ for _, v := range xs {
|
|
|
+ total += v
|
|
|
+ }
|
|
|
+ return total / float64(len(xs))
|
|
|
+}
|
|
|
+
|
|
|
+func humanizeBytes(s float64) (string, string) {
|
|
|
+ sizes := []string{" B", " kB", " MB", " GB", " TB", " PB", " EB"}
|
|
|
+ base := 1000.0
|
|
|
+ if s < 10 {
|
|
|
+ return fmt.Sprintf("%2.0f", s), sizes[0]
|
|
|
+ }
|
|
|
+ e := math.Floor(logn(float64(s), base))
|
|
|
+ suffix := sizes[int(e)]
|
|
|
+ val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
|
|
|
+ f := "%.0f"
|
|
|
+ if val < 10 {
|
|
|
+ f = "%.1f"
|
|
|
+ }
|
|
|
+
|
|
|
+ return fmt.Sprintf(f, val), suffix
|
|
|
+}
|
|
|
+
|
|
|
+func logn(n, b float64) float64 {
|
|
|
+ return math.Log(n) / math.Log(b)
|
|
|
+}
|
|
|
+
|
|
|
+// termWidth function returns the visible width of the current terminal
|
|
|
+// and can be redefined for testing
|
|
|
+var termWidth = func() (width int, err error) {
|
|
|
+ width, _, err = term.GetSize(int(os.Stdout.Fd()))
|
|
|
+ if err == nil {
|
|
|
+ return width, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ return 0, err
|
|
|
+}
|