Browse Source

add humanize package

fancl 1 year ago
parent
commit
4199b81b5f
7 changed files with 730 additions and 0 deletions
  1. 1 0
      instance.go
  2. 154 0
      util/humanize/duration.go
  3. 184 0
      util/humanize/number.go
  4. 173 0
      util/humanize/size.go
  5. 177 0
      util/humanize/time.go
  6. 26 0
      util/humanize/time_test.go
  7. 15 0
      util/reflect/reflect_test.go

+ 1 - 0
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"

+ 154 - 0
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)
+}

+ 184 - 0
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))
+}

+ 173 - 0
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))
+}

+ 177 - 0
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
+}

+ 26 - 0
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)
+}

+ 15 - 0
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)
+}