From 4199b81b5f6102140de27ca811f82c6f22db9fc3 Mon Sep 17 00:00:00 2001 From: fancl Date: Mon, 7 Aug 2023 14:57:16 +0800 Subject: [PATCH] add humanize package --- instance.go | 1 + util/humanize/duration.go | 154 +++++++++++++++++++++++++++++ util/humanize/number.go | 184 +++++++++++++++++++++++++++++++++++ util/humanize/size.go | 173 ++++++++++++++++++++++++++++++++ util/humanize/time.go | 177 +++++++++++++++++++++++++++++++++ util/humanize/time_test.go | 26 +++++ util/reflect/reflect_test.go | 15 +++ 7 files changed, 730 insertions(+) create mode 100644 util/humanize/duration.go create mode 100644 util/humanize/number.go create mode 100644 util/humanize/size.go create mode 100644 util/humanize/time.go create mode 100644 util/humanize/time_test.go create mode 100644 util/reflect/reflect_test.go diff --git a/instance.go b/instance.go index 509ef45..c8f05f0 100644 --- a/instance.go +++ b/instance.go @@ -6,6 +6,7 @@ import ( _ "git.nspix.com/golang/kos/pkg/request" _ "git.nspix.com/golang/kos/util/bs" _ "git.nspix.com/golang/kos/util/fetch" + _ "git.nspix.com/golang/kos/util/humanize" _ "git.nspix.com/golang/kos/util/random" _ "git.nspix.com/golang/kos/util/reflection" "sync" diff --git a/util/humanize/duration.go b/util/humanize/duration.go new file mode 100644 index 0000000..059eb2d --- /dev/null +++ b/util/humanize/duration.go @@ -0,0 +1,154 @@ +package humanize + +import ( + "bytes" + "git.nspix.com/golang/kos/util/bs" + "time" +) + +const ( + Nanosecond Duration = 1 + Microsecond = 1000 * Nanosecond + Millisecond = 1000 * Microsecond + Second = 1000 * Millisecond + Minute = 60 * Second + Hour = 60 * Minute +) + +type Duration int64 + +// fmtFrac formats the fraction of v/10**prec (e.g., ".12345") into the +// tail of buf, omitting trailing zeros. It omits the decimal +// point too when the fraction is 0. It returns the index where the +// output bytes begin and the value v/10**prec. +func fmtFrac(buf []byte, v uint64, prec int) (nw int, nv uint64) { + // Omit trailing zeros up to and including decimal point. + w := len(buf) + print := false + for i := 0; i < prec; i++ { + digit := v % 10 + print = print || digit != 0 + if print { + w-- + buf[w] = byte(digit) + '0' + } + v /= 10 + } + if print { + w-- + buf[w] = '.' + } + return w, v +} + +// fmtInt formats v into the tail of buf. +// It returns the index where the output begins. +func fmtInt(buf []byte, v uint64) int { + w := len(buf) + if v == 0 { + w-- + buf[w] = '0' + } else { + for v > 0 { + w-- + buf[w] = byte(v%10) + '0' + v /= 10 + } + } + return w +} + +func (d Duration) String() string { + // Largest time is 2540400h10m10.000000000s + var buf [32]byte + w := len(buf) + + u := uint64(d) + neg := d < 0 + if neg { + u = -u + } + + if u < uint64(time.Second) { + // Special case: if duration is smaller than a second, + // use smaller units, like 1.2ms + var prec int + w-- + buf[w] = 's' + w-- + switch { + case u == 0: + return "0s" + case u < uint64(time.Microsecond): + // print nanoseconds + prec = 0 + buf[w] = 'n' + case u < uint64(time.Millisecond): + // print microseconds + prec = 3 + // U+00B5 'µ' micro sign == 0xC2 0xB5 + w-- // Need room for two bytes. + copy(buf[w:], "µ") + default: + // print milliseconds + prec = 6 + buf[w] = 'm' + } + w, u = fmtFrac(buf[:w], u, prec) + w = fmtInt(buf[:w], u) + } else { + w-- + buf[w] = 's' + + w, u = fmtFrac(buf[:w], u, 9) + + // u is now integer seconds + w = fmtInt(buf[:w], u%60) + u /= 60 + + // u is now integer minutes + if u > 0 { + w-- + buf[w] = 'm' + w = fmtInt(buf[:w], u%60) + u /= 60 + + // u is now integer hours + // Stop at hours because days can be different lengths. + if u > 0 { + w-- + buf[w] = 'h' + w = fmtInt(buf[:w], u) + } + } + } + + if neg { + w-- + buf[w] = '-' + } + + return string(buf[w:]) +} + +func (d *Duration) UnmarshalJSON(b []byte) (err error) { + var n time.Duration + b = bytes.TrimFunc(b, func(r rune) bool { + if r == '"' { + return true + } + return false + }) + if n, err = time.ParseDuration(bs.BytesToString(b)); err == nil { + *d = Duration(n) + } + return err +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return bs.StringToBytes(`"` + d.String() + `"`), nil +} + +func (d Duration) Duration() time.Duration { + return time.Duration(d) +} diff --git a/util/humanize/number.go b/util/humanize/number.go new file mode 100644 index 0000000..368b5d1 --- /dev/null +++ b/util/humanize/number.go @@ -0,0 +1,184 @@ +package humanize + +import ( + "math" + "strconv" +) + +var ( + renderFloatPrecisionMultipliers = [...]float64{ + 1, + 10, + 100, + 1000, + 10000, + 100000, + 1000000, + 10000000, + 100000000, + 1000000000, + } + + renderFloatPrecisionRounders = [...]float64{ + 0.5, + 0.05, + 0.005, + 0.0005, + 0.00005, + 0.000005, + 0.0000005, + 0.00000005, + 0.000000005, + 0.0000000005, + } +) + +// FormatFloat produces a formatted number as string based on the following user-specified criteria: +// * thousands separator +// * decimal separator +// * decimal precision +// +// Usage: s := RenderFloat(format, n) +// The format parameter tells how to render the number n. +// +// See examples: http://play.golang.org/p/LXc1Ddm1lJ +// +// Examples of format strings, given n = 12345.6789: +// "#,###.##" => "12,345.67" +// "#,###." => "12,345" +// "#,###" => "12345,678" +// "#\u202F###,##" => "12 345,68" +// "#.###,###### => 12.345,678900 +// "" (aka default format) => 12,345.67 +// +// The highest precision allowed is 9 digits after the decimal symbol. +// There is also a version for integer number, FormatInteger(), +// which is convenient for calls within template. +func FormatFloat(format string, n float64) string { + // Special cases: + // NaN = "NaN" + // +Inf = "+Infinity" + // -Inf = "-Infinity" + if math.IsNaN(n) { + return "NaN" + } + if n > math.MaxFloat64 { + return "Infinity" + } + if n < (0.0 - math.MaxFloat64) { + return "-Infinity" + } + + // default format + precision := 2 + decimalStr := "." + thousandStr := "," + positiveStr := "" + negativeStr := "-" + + if len(format) > 0 { + format := []rune(format) + + // If there is an explicit format directive, + // then default values are these: + precision = 9 + thousandStr = "" + + // collect indices of meaningful formatting directives + formatIndx := []int{} + for i, char := range format { + if char != '#' && char != '0' { + formatIndx = append(formatIndx, i) + } + } + + if len(formatIndx) > 0 { + // Directive at index 0: + // Must be a '+' + // Raise an error if not the case + // index: 0123456789 + // +0.000,000 + // +000,000.0 + // +0000.00 + // +0000 + if formatIndx[0] == 0 { + if format[formatIndx[0]] != '+' { + panic("RenderFloat(): invalid positive sign directive") + } + positiveStr = "+" + formatIndx = formatIndx[1:] + } + + // Two directives: + // First is thousands separator + // Raise an error if not followed by 3-digit + // 0123456789 + // 0.000,000 + // 000,000.00 + if len(formatIndx) == 2 { + if (formatIndx[1] - formatIndx[0]) != 4 { + panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers") + } + thousandStr = string(format[formatIndx[0]]) + formatIndx = formatIndx[1:] + } + + // One directive: + // Directive is decimal separator + // The number of digit-specifier following the separator indicates wanted precision + // 0123456789 + // 0.00 + // 000,0000 + if len(formatIndx) == 1 { + decimalStr = string(format[formatIndx[0]]) + precision = len(format) - formatIndx[0] - 1 + } + } + } + + // generate sign part + var signStr string + if n >= 0.000000001 { + signStr = positiveStr + } else if n <= -0.000000001 { + signStr = negativeStr + n = -n + } else { + signStr = "" + n = 0.0 + } + + // split number into integer and fractional parts + intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision]) + + // generate integer part string + intStr := strconv.FormatInt(int64(intf), 10) + + // add thousand separator if required + if len(thousandStr) > 0 { + for i := len(intStr); i > 3; { + i -= 3 + intStr = intStr[:i] + thousandStr + intStr[i:] + } + } + + // no fractional part, we can leave now + if precision == 0 { + return signStr + intStr + } + + // generate fractional part + fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision])) + // may need padding + if len(fracStr) < precision { + fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr + } + + return signStr + intStr + decimalStr + fracStr +} + +// FormatInteger produces a formatted number as string. +// See FormatFloat. +func FormatInteger(format string, n int) string { + return FormatFloat(format, float64(n)) +} diff --git a/util/humanize/size.go b/util/humanize/size.go new file mode 100644 index 0000000..d48bd4c --- /dev/null +++ b/util/humanize/size.go @@ -0,0 +1,173 @@ +package humanize + +import ( + "bytes" + "fmt" + "git.nspix.com/golang/kos/util/bs" + "math" + "strconv" + "strings" + "unicode" +) + +type Size uint64 + +// IEC Sizes. +// kibis of bits +const ( + Byte = 1 << (iota * 10) + KiByte + MiByte + GiByte + TiByte + PiByte + EiByte +) + +// SI Sizes. +const ( + IByte = 1 + KByte = IByte * 1000 + MByte = KByte * 1000 + GByte = MByte * 1000 + TByte = GByte * 1000 + PByte = TByte * 1000 + EByte = PByte * 1000 +) + +var bytesSizeTable = map[string]uint64{ + "b": Byte, + "kib": KiByte, + "kb": KByte, + "mib": MiByte, + "mb": MByte, + "gib": GiByte, + "gb": GByte, + "tib": TiByte, + "tb": TByte, + "pib": PiByte, + "pb": PByte, + "eib": EiByte, + "eb": EByte, + // Without suffix + "": Byte, + "ki": KiByte, + "k": KByte, + "mi": MiByte, + "m": MByte, + "gi": GiByte, + "g": GByte, + "ti": TiByte, + "t": TByte, + "pi": PiByte, + "p": PByte, + "ei": EiByte, + "e": EByte, +} + +func logn(n, b float64) float64 { + return math.Log(n) / math.Log(b) +} + +func humanateBytes(s uint64, base float64, sizes []string) string { + if s < 10 { + return fmt.Sprintf("%d B", s) + } + 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 %s" + if val < 10 { + f = "%.1f %s" + } + + return fmt.Sprintf(f, val, suffix) +} + +// Bytes produces a human readable representation of an SI size. +// +// See also: ParseBytes. +// +// Bytes(82854982) -> 83 MB +func Bytes(s uint64) string { + sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} + return humanateBytes(s, 1000, sizes) +} + +// IBytes produces a human readable representation of an IEC size. +// +// See also: ParseBytes. +// +// IBytes(82854982) -> 79 MiB +func IBytes(s uint64) string { + sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} + return humanateBytes(s, 1024, sizes) +} + +// ParseBytes parses a string representation of bytes into the number +// of bytes it represents. +// +// See Also: Bytes, IBytes. +// +// ParseBytes("42 MB") -> 42000000, nil +// ParseBytes("42 mib") -> 44040192, nil +func ParseBytes(s string) (uint64, error) { + lastDigit := 0 + hasComma := false + for _, r := range s { + if !(unicode.IsDigit(r) || r == '.' || r == ',') { + break + } + if r == ',' { + hasComma = true + } + lastDigit++ + } + + num := s[:lastDigit] + if hasComma { + num = strings.Replace(num, ",", "", -1) + } + + f, err := strconv.ParseFloat(num, 64) + if err != nil { + return 0, err + } + + extra := strings.ToLower(strings.TrimSpace(s[lastDigit:])) + if m, ok := bytesSizeTable[extra]; ok { + f *= float64(m) + if f >= math.MaxUint64 { + return 0, fmt.Errorf("too large: %v", s) + } + return uint64(f), nil + } + + return 0, fmt.Errorf("unhandled size name: %v", extra) +} + +func (b *Size) UnmarshalJSON(buf []byte) error { + var ( + n uint64 + err error + ) + buf = bytes.TrimFunc(buf, func(r rune) bool { + if r == '"' { + return true + } + return false + }) + if n, err = ParseBytes(bs.BytesToString(buf)); err == nil { + *b = Size(n) + } + return err +} + +func (b Size) MarshalJSON() ([]byte, error) { + s := `"` + IBytes(uint64(b)) + `"` + return bs.StringToBytes(s), nil +} + +func (b Size) String() string { + return IBytes(uint64(b)) +} diff --git a/util/humanize/time.go b/util/humanize/time.go new file mode 100644 index 0000000..5841e2b --- /dev/null +++ b/util/humanize/time.go @@ -0,0 +1,177 @@ +package humanize + +import ( + "bytes" + "fmt" + "git.nspix.com/golang/kos/util/bs" + "math" + "sort" + "time" +) + +const ( + Day = 24 * time.Hour + Week = 7 * Day + Month = 30 * Day + Year = 12 * Month + LongTime = 37 * Year +) + +// A RelTimeMagnitude struct contains a relative time point at which +// the relative format of time will switch to a new format string. A +// slice of these in ascending order by their "D" field is passed to +// CustomRelTime to format durations. +// +// The Format field is a string that may contain a "%s" which will be +// replaced with the appropriate signed label (e.g. "ago" or "from +// now") and a "%d" that will be replaced by the quantity. +// +// The DivBy field is the amount of time the time difference must be +// divided by in order to display correctly. +// +// e.g. if D is 2*time.Minute and you want to display "%d minutes %s" +// DivBy should be time.Minute so whatever the duration is will be +// expressed in minutes. +type RelTimeMagnitude struct { + D time.Duration + Format string + DivBy time.Duration +} + +var defaultMagnitudes = []RelTimeMagnitude{ + {time.Second, "now", time.Second}, + {2 * time.Second, "1 second %s", 1}, + {time.Minute, "%d seconds %s", time.Second}, + {2 * time.Minute, "1 minute %s", 1}, + {time.Hour, "%d minutes %s", time.Minute}, + {2 * time.Hour, "1 hour %s", 1}, + {Day, "%d hours %s", time.Hour}, + {2 * Day, "1 day %s", 1}, + {Week, "%d days %s", Day}, + {2 * Week, "1 week %s", 1}, + {Month, "%d weeks %s", Week}, + {2 * Month, "1 month %s", 1}, + {Year, "%d months %s", Month}, + {18 * Month, "1 year %s", 1}, + {2 * Year, "2 years %s", 1}, + {LongTime, "%d years %s", Year}, + {math.MaxInt64, "a long while %s", 1}, +} + +// RelTime formats a time into a relative string. +// +// It takes two times and two labels. In addition to the generic time +// delta string (e.g. 5 minutes), the labels are used applied so that +// the label corresponding to the smaller time is applied. +// +// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier" +func RelTime(a, b time.Time, albl, blbl string) string { + return CustomRelTime(a, b, albl, blbl, defaultMagnitudes) +} + +// CustomRelTime formats a time into a relative string. +// +// It takes two times two labels and a table of relative time formats. +// In addition to the generic time delta string (e.g. 5 minutes), the +// labels are used applied so that the label corresponding to the +// smaller time is applied. +func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string { + lbl := albl + diff := b.Sub(a) + + if a.After(b) { + lbl = blbl + diff = a.Sub(b) + } + + n := sort.Search(len(magnitudes), func(i int) bool { + return magnitudes[i].D > diff + }) + + if n >= len(magnitudes) { + n = len(magnitudes) - 1 + } + mag := magnitudes[n] + args := []interface{}{} + escaped := false + for _, ch := range mag.Format { + if escaped { + switch ch { + case 's': + args = append(args, lbl) + case 'd': + args = append(args, diff/mag.DivBy) + } + escaped = false + } else { + escaped = ch == '%' + } + } + return fmt.Sprintf(mag.Format, args...) +} + +type Time struct { + tm time.Time +} + +func Now() Time { + return Time{tm: time.Now()} +} + +func WrapTime(t time.Time) Time { + return Time{tm: t} +} + +func (t Time) Add(d Duration) Time { + t.tm = t.tm.Add(d.Duration()) + return t +} + +func (t Time) AddDuration(d time.Duration) Time { + t.tm = t.tm.Add(d) + return t +} + +func (t Time) After(u Time) bool { + return t.tm.After(u.tm) +} + +func (t Time) AfterTime(u time.Time) bool { + return t.tm.After(u) +} + +func (t Time) Sub(u Time) Duration { + return Duration(t.tm.Sub(u.tm)) +} + +func (t Time) SubTime(u time.Time) Duration { + return Duration(t.tm.Sub(u)) +} + +func (t Time) Time() time.Time { + return t.tm +} + +func (t Time) String() string { + return t.tm.Format(time.DateTime) +} + +func (t Time) MarshalJSON() ([]byte, error) { + s := `"` + t.tm.Format(time.DateTime) + `"` + return bs.StringToBytes(s), nil +} + +func (t Time) Ago() string { + return RelTime(t.tm, time.Now(), "ago", "from now") +} + +func (t *Time) UnmarshalJSON(buf []byte) (err error) { + buf = bytes.TrimFunc(buf, func(r rune) bool { + if r == '"' { + return true + } + return false + }) + t.tm, err = time.ParseInLocation(time.DateTime, bs.BytesToString(buf), time.Local) + return err +} diff --git a/util/humanize/time_test.go b/util/humanize/time_test.go new file mode 100644 index 0000000..89388c5 --- /dev/null +++ b/util/humanize/time_test.go @@ -0,0 +1,26 @@ +package humanize + +import ( + "encoding/json" + "testing" +) + +type test struct { + Time Time +} + +func TestNow(t *testing.T) { + tm := Now().Add(-1 * Hour * 223) + t.Log(tm.Ago()) + ts := &test{Time: Now()} + buf, err := json.Marshal(ts) + if err != nil { + t.Error(err) + } + t.Log(string(buf)) + vv := &test{} + if err = json.Unmarshal(buf, vv); err != nil { + t.Error(err) + } + t.Log(vv.Time) +} diff --git a/util/reflect/reflect_test.go b/util/reflect/reflect_test.go new file mode 100644 index 0000000..f187a98 --- /dev/null +++ b/util/reflect/reflect_test.go @@ -0,0 +1,15 @@ +package reflect + +import ( + "testing" + "time" +) + +func TestSet(t *testing.T) { + type hack struct { + Duration time.Duration + } + h := &hack{} + Set(h, "Duration", "5s") + t.Log(h.Duration) +}