307 lines
8.9 KiB
Go
307 lines
8.9 KiB
Go
// Port of web/kit/Formatters.ts.
|
|
package webui
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// formattersNonDigit matches every non-digit rune (the JS /\D/g).
|
|
var formattersNonDigit = regexp.MustCompile(`\D`)
|
|
|
|
// formattersDigits strips every non-digit character (mirrors String(n).replace(/\D/g, "")).
|
|
func formattersDigits(s string) string {
|
|
return formattersNonDigit.ReplaceAllString(s, "")
|
|
}
|
|
|
|
// formattersStringify mimics JavaScript's String(v) for the `string | number`
|
|
// union accepted by several formatters. Go has no union type, so these take
|
|
// `any` and stringify int / float / string inputs the way JS would.
|
|
func formattersStringify(v any) string {
|
|
switch n := v.(type) {
|
|
case string:
|
|
return n
|
|
case int:
|
|
return strconv.Itoa(n)
|
|
case int8, int16, int32, int64:
|
|
return fmt.Sprintf("%d", n)
|
|
case uint, uint8, uint16, uint32, uint64:
|
|
return fmt.Sprintf("%d", n)
|
|
case float32:
|
|
return strconv.FormatFloat(float64(n), 'f', -1, 32)
|
|
case float64:
|
|
return strconv.FormatFloat(n, 'f', -1, 64)
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
// formattersPadStartZero left-pads s with '0' up to length (the JS padStart(n, "0")).
|
|
func formattersPadStartZero(s string, length int) string {
|
|
if len(s) >= length {
|
|
return s
|
|
}
|
|
return strings.Repeat("0", length-len(s)) + s
|
|
}
|
|
|
|
// formattersParseInt approximates JS parseInt(s, 10): optional sign then leading
|
|
// decimal digits. JS would yield NaN for non-numeric input; here we return 0.
|
|
func formattersParseInt(s string) int {
|
|
s = strings.TrimSpace(s)
|
|
i := 0
|
|
neg := false
|
|
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
|
neg = s[i] == '-'
|
|
i++
|
|
}
|
|
start := i
|
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
|
i++
|
|
}
|
|
if start == i {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(s[start:i])
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
if neg {
|
|
return -n
|
|
}
|
|
return n
|
|
}
|
|
|
|
// FormatPhoneNumber formats a number as a US phone number: (XXX) XXX-XXXX.
|
|
func FormatPhoneNumber(number any) string {
|
|
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 10)[:10]
|
|
areaCode := digits[0:3]
|
|
centralOfficeCode := digits[3:6]
|
|
lineNumber := digits[6:10]
|
|
return "(" + areaCode + ") " + centralOfficeCode + "-" + lineNumber
|
|
}
|
|
|
|
// FormatZipCode formats a number as a US zip code (5 or 9 digits).
|
|
func FormatZipCode(number any) string {
|
|
num := formattersParseInt(formattersStringify(number))
|
|
|
|
if num <= 99999 {
|
|
return formattersPadStartZero(strconv.Itoa(num), 5)
|
|
}
|
|
|
|
digits := formattersPadStartZero(strconv.Itoa(num), 9)
|
|
zipCode := digits[0:5]
|
|
plus4 := digits[5:9]
|
|
return zipCode + "-" + plus4
|
|
}
|
|
|
|
// FormatTaxID formats a number as a US Tax ID (EIN): XX-XXXXXXX.
|
|
func FormatTaxID(number any) string {
|
|
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 9)[:9]
|
|
prefix := digits[0:2]
|
|
identifier := digits[2:9]
|
|
return prefix + "-" + identifier
|
|
}
|
|
|
|
// formattersGroupThousands inserts ',' every three digits from the right of an
|
|
// integer-digit string (no sign, no fraction).
|
|
func formattersGroupThousands(intDigits string) string {
|
|
n := len(intDigits)
|
|
if n <= 3 {
|
|
return intDigits
|
|
}
|
|
var b strings.Builder
|
|
pre := n % 3
|
|
if pre > 0 {
|
|
b.WriteString(intDigits[:pre])
|
|
}
|
|
for i := pre; i < n; i += 3 {
|
|
if b.Len() > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
b.WriteString(intDigits[i : i+3])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// formattersFormatGrouped renders n like Intl.NumberFormat("en-US") with the
|
|
// given minimum/maximum fraction digits: rounds to maxFrac, trims trailing zeros
|
|
// down to minFrac, and groups the integer part with commas.
|
|
func formattersFormatGrouped(n float64, minFrac, maxFrac int) string {
|
|
neg := math.Signbit(n)
|
|
s := strconv.FormatFloat(math.Abs(n), 'f', maxFrac, 64)
|
|
|
|
intPart, fracPart := s, ""
|
|
if dot := strings.IndexByte(s, '.'); dot >= 0 {
|
|
intPart = s[:dot]
|
|
fracPart = s[dot+1:]
|
|
}
|
|
|
|
for len(fracPart) > minFrac && strings.HasSuffix(fracPart, "0") {
|
|
fracPart = fracPart[:len(fracPart)-1]
|
|
}
|
|
|
|
intPart = formattersGroupThousands(intPart)
|
|
res := intPart
|
|
if len(fracPart) > 0 {
|
|
res += "." + fracPart
|
|
}
|
|
|
|
// Only keep the sign when the rounded result is actually non-zero (avoids "-0").
|
|
if neg && strings.ContainsFunc(res, func(r rune) bool { return r >= '1' && r <= '9' }) {
|
|
res = "-" + res
|
|
}
|
|
return res
|
|
}
|
|
|
|
// FormatNumber formats a number with US thousands separators (Intl.NumberFormat
|
|
// "en-US" defaults: 0 minimum and 3 maximum fraction digits).
|
|
func FormatNumber(number float64) string {
|
|
return formattersFormatGrouped(number, 0, 3)
|
|
}
|
|
|
|
// FormatDecimal formats a number with US thousands separators and a fixed number
|
|
// of fraction digits (default 2). Go has no default parameters, so decimalPlaces
|
|
// is an optional variadic argument.
|
|
func FormatDecimal(number float64, decimalPlaces ...int) string {
|
|
dp := 2
|
|
if len(decimalPlaces) > 0 {
|
|
dp = decimalPlaces[0]
|
|
}
|
|
return formattersFormatGrouped(number, dp, dp)
|
|
}
|
|
|
|
// State Code Utilities
|
|
|
|
var formattersStateCodeMap = map[string]string{
|
|
"Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA",
|
|
"Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "District of Columbia": "DC", "Florida": "FL",
|
|
"Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN",
|
|
"Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME",
|
|
"Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
|
|
"Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH",
|
|
"New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND",
|
|
"Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Puerto Rico": "PR",
|
|
"Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX",
|
|
"Utah": "UT", "Vermont": "VT", "Virgin Islands": "VI", "Virginia": "VA", "Washington": "WA",
|
|
"West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY",
|
|
}
|
|
|
|
// StateToStateCode resolves a state name to its two-letter code, matching the
|
|
// exact name first and then case-insensitively. Returns "" when unknown.
|
|
func StateToStateCode(state string) string {
|
|
if code, ok := formattersStateCodeMap[state]; ok {
|
|
return code
|
|
}
|
|
|
|
stateLower := strings.ToLower(state)
|
|
for stateName, code := range formattersStateCodeMap {
|
|
if strings.ToLower(stateName) == stateLower {
|
|
return code
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// StateCodeToState resolves a two-letter code to its state name. Returns "" when unknown.
|
|
func StateCodeToState(code string) string {
|
|
up := strings.ToUpper(code)
|
|
for state, stateCode := range formattersStateCodeMap {
|
|
if stateCode == up {
|
|
return state
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsValidStateCode reports whether code is a known two-letter state code.
|
|
func IsValidStateCode(code string) bool {
|
|
up := strings.ToUpper(code)
|
|
for _, stateCode := range formattersStateCodeMap {
|
|
if stateCode == up {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// formattersDateLayouts are the layouts tried when coercing a string into a
|
|
// time.Time, standing in for JS `new Date(string)` (which cannot be reproduced
|
|
// exactly). Ordered most-specific first.
|
|
var formattersDateLayouts = []string{
|
|
time.RFC3339Nano,
|
|
time.RFC3339,
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02T15:04",
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02",
|
|
"2006/01/02",
|
|
"01/02/2006",
|
|
time.RFC1123Z,
|
|
time.RFC1123,
|
|
time.ANSIC,
|
|
}
|
|
|
|
// formattersToTime coerces the `string | Date` union into a time.Time. The bool
|
|
// is false when a string could not be parsed (JS would produce "Invalid Date").
|
|
func formattersToTime(date any) (time.Time, bool) {
|
|
switch d := date.(type) {
|
|
case time.Time:
|
|
return d, true
|
|
case string:
|
|
for _, layout := range formattersDateLayouts {
|
|
if t, err := time.Parse(layout, d); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
default:
|
|
return time.Time{}, false
|
|
}
|
|
}
|
|
|
|
// FormatDate formats a date as MM/DD/YYYY. Returns "" for an unparseable string.
|
|
func FormatDate(date any) string {
|
|
d, ok := formattersToTime(date)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return d.Format("01/02/2006")
|
|
}
|
|
|
|
// FormatDateLong formats a date as e.g. "January 2, 2006". Returns "" for an
|
|
// unparseable string.
|
|
func FormatDateLong(date any) string {
|
|
d, ok := formattersToTime(date)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return d.Format("January 2, 2006")
|
|
}
|
|
|
|
// FormatDateTime formats a date+time as e.g. "01/02/2006, 3:04 PM". Returns ""
|
|
// for an unparseable string.
|
|
func FormatDateTime(date any) string {
|
|
d, ok := formattersToTime(date)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return d.Format("01/02/2006, 3:04 PM")
|
|
}
|
|
|
|
// FormatPercent formats a value as a percentage with a fixed number of decimal
|
|
// places. When isDecimal is true the value is treated as a fraction (0-1) and
|
|
// scaled by 100. Go has no default parameters, so decimalPlaces and isDecimal
|
|
// (defaults 2 and false in the TS source) are required here.
|
|
func FormatPercent(value float64, decimalPlaces int, isDecimal bool) string {
|
|
percent := value
|
|
if isDecimal {
|
|
percent = value * 100
|
|
}
|
|
return strconv.FormatFloat(percent, 'f', decimalPlaces, 64) + "%"
|
|
}
|