1package value23import (4 "strings"56 "src.rest/cat/012/internal/locale"7)89// Entries are stored as typed in en-US, the canonical locale, so a sheet10// reads the same in every locale. What is typed in another locale is11// rewritten to that form as it's entered (Canonicalize), and shown in12// the locale's form when edited again (Localize): 1.234,5 in de-DE is13// stored as 1,234.5, 26.09.2026 as 09/26/2026 and 12,50 € as $12.50.14// Both keep what was typed as far as they can (grouping, leading zeros,15// ISO dates, month names, times), and the value is the same either way.1617// ParseValueIn is ParseValue for an entry typed in loc.18func ParseValueIn(s string, loc *locale.Locale) (float64, Format, bool) {19 if loc.IsCanonical() {20 return ParseValue(s)21 }22 c, ok := Canonicalize(s, loc)23 if !ok {24 return 0, Format{}, false25 }26 return ParseValue(c)27}2829// Canonicalize rewrites a number, date or time typed in loc as it would30// be typed in en-US, reporting false when s isn't one in loc.31func Canonicalize(s string, loc *locale.Locale) (string, bool) {32 if loc.IsCanonical() {33 _, _, ok := ParseValue(s)34 return s, ok35 }36 t := strings.TrimSpace(s)37 if t == "" {38 return s, false39 }40 if c, ok := canonicalNumber(t, loc); ok {41 if _, _, ok := parseNumberFormat(c); ok {42 return c, true43 }44 }45 if c := canonicalDateTime(t, loc); c != "" {46 if _, _, ok := ParseDateTime(c); ok {47 return c, true48 }49 }50 if c, ok := canonicalMonthDate(t, loc); ok {51 if _, _, ok := ParseDateTime(c); ok {52 return c, true53 }54 }55 return s, false56}5758// canonicalMonthDate rewrites a date whose month is named in loc's59// language (26. September 2026, 26 wrz 2026) with the month's English60// name, which dates read everywhere, dropping the dot German and Czech61// put after the day. It reports false when no word names a month.62func canonicalMonthDate(t string, loc *locale.Locale) (string, bool) {63 n := loc.Names()64 if n == locale.English {65 return "", false66 }67 words := strings.Fields(t)68 found := false69 for i, w := range words {70 if m, long, ok := localMonth(w, n); ok && !found {71 words[i], found = locale.English.Short[m], true72 if long {73 words[i] = locale.English.Months[m]74 }75 continue76 }77 if d, ok := strings.CutSuffix(w, "."); ok && d != "" && strings.Trim(d, "0123456789") == "" {78 words[i] = d79 }80 }81 return strings.Join(words, " "), found82}8384// localMonth finds the month (0 to 11) a word names in n, ignoring case85// and a trailing dot, and whether it's the full name.86func localMonth(w string, n *locale.Names) (m int, long, ok bool) {87 w = strings.ToLower(w)88 bare := strings.TrimSuffix(w, ".")89 for i := range 12 {90 switch bare {91 case strings.ToLower(n.Months[i]), strings.ToLower(n.Alone[i]):92 if bare != "" {93 return i, true, true94 }95 case strings.ToLower(strings.TrimSuffix(n.Short[i], ".")):96 return i, false, true97 }98 }99 return 0, false, false100}101102// Localize is the inverse of Canonicalize: a number, date or time entry103// as stored, written as typed in loc. Anything else is returned as is.104func Localize(s string, loc *locale.Locale) string {105 if loc.IsCanonical() {106 return s107 }108 t := strings.TrimSpace(s)109 if _, _, ok := parseNumberFormat(t); ok {110 return localNumber(t, loc)111 }112 if _, _, ok := ParseDateTime(t); ok {113 return localDateTime(t, loc)114 }115 return s116}117118// spaces are the spaces that may stand between a number and its119// currency symbol or percent sign.120const spaces = " "121122// canonicalNumber rewrites a number typed in loc with en-US's123// separators, $ for loc's currency symbol before it and % after it. It124// reports false for characters that are no part of a number in loc,125// such as the other locale's separators.126func canonicalNumber(t string, loc *locale.Locale) (string, bool) {127 sign := ""128 if t[0] == '-' || t[0] == '+' {129 sign, t = t[:1], t[1:]130 }131 cur := false132 if rest, ok := strings.CutPrefix(t, loc.Currency); ok {133 t, cur = strings.TrimLeft(rest, spaces), true134 } else if rest, ok := strings.CutSuffix(t, loc.Currency); ok {135 t, cur = strings.TrimRight(rest, spaces), true136 }137 pct := false138 if rest, ok := strings.CutSuffix(t, "%"); ok {139 t, pct = strings.TrimRight(rest, spaces), true140 }141 if t == "" {142 return "", false143 }144 var b strings.Builder145 b.Grow(len(t) + 3)146 b.WriteString(sign)147 if cur {148 b.WriteByte('$')149 }150 for _, r := range t {151 switch {152 case r == rune(loc.Decimal):153 b.WriteByte('.')154 case loc.IsGroup(r):155 b.WriteByte(',')156 case r >= '0' && r <= '9', r == 'e', r == 'E', r == '+', r == '-':157 b.WriteRune(r)158 default:159 return "", false160 }161 }162 if pct {163 b.WriteByte('%')164 }165 return b.String(), true166}167168// localNumber writes a number entry as stored (en-US) as typed in loc.169func localNumber(t string, loc *locale.Locale) string {170 sign := ""171 if t[0] == '-' || t[0] == '+' {172 sign, t = t[:1], t[1:]173 }174 t, cur := strings.CutPrefix(t, "$")175 var b strings.Builder176 for i := 0; i < len(t); i++ {177 switch c := t[i]; c {178 case '.':179 b.WriteByte(loc.Decimal)180 case ',':181 b.WriteString(loc.Group)182 default:183 b.WriteByte(c)184 }185 }186 num := b.String()187 if !cur {188 return sign + num189 }190 gap := ""191 if loc.Space {192 gap = " "193 }194 if loc.After {195 return sign + num + gap + loc.Currency196 }197 return sign + loc.Currency + gap + num198}199200// canonicalDateTime rewrites a date typed with numbers in loc's order201// (26.09.2026, 26/9) in en-US's (9/26/2026), keeping any time after it.202// Other dates and times are returned as they are; "" is a date of203// numbers that can't be one in loc.204func canonicalDateTime(t string, loc *locale.Locale) string {205 head, tail, spaced := strings.Cut(t, " ")206 date, shaped := canonicalDate(head, loc)207 if !shaped {208 return t209 }210 if date == "" || !spaced {211 return date212 }213 if loc.Decimal == ',' {214 tail = strings.Replace(tail, ",", ".", 1) // 14:30:15,5215 }216 return date + " " + tail217}218219// canonicalDate rewrites a date of numbers typed in loc in en-US's220// order. shaped is false when s isn't numbers and separators; the date221// is "" when they can't make a date in loc.222func canonicalDate(s string, loc *locale.Locale) (date string, shaped bool) {223 sep := dateSep(s, loc)224 if sep == 0 {225 return "", false226 }227 if sep == '.' {228 s = strings.TrimSuffix(s, ".") // 26.09.2026.229 }230 p := strings.Split(s, string(sep))231 if len(p) < 2 || len(p) > 3 || strings.Contains(s, string(sep)+string(sep)) || p[0] == "" || p[len(p)-1] == "" {232 return "", true233 }234 if len(p) == 3 && len(p[0]) == 4 { // the year first, in every locale235 switch {236 case loc.Order == locale.YMD:237 return p[1] + "/" + p[2] + "/" + p[0], true238 case sep == '.':239 return p[0] + "/" + p[1] + "/" + p[2], true240 }241 return s, true242 }243 var d, m, y string244 switch {245 case loc.Order == locale.DMY:246 d, m = p[0], p[1]247 case loc.Order == locale.YMD && len(p) == 3:248 y, m, d = p[0], p[1], p[2]249 default:250 m, d = p[0], p[1]251 }252 if len(p) == 3 && y == "" {253 y = p[2]254 }255 if y == "" {256 return m + "/" + d, true257 }258 return m + "/" + d + "/" + y, true259}260261// dateSep is the separator of a date of numbers typed in loc: / and -262// everywhere, . where loc writes dates with it. It is 0 when s is263// anything else, or mixes separators.264func dateSep(s string, loc *locale.Locale) byte {265 var sep byte266 for i := 0; i < len(s); i++ {267 c := s[i]268 switch {269 case isDigit(c):270 continue271 case c != '/' && c != '-' && (c != '.' || loc.DateSep != '.'):272 return 0273 case sep != 0 && c != sep:274 return 0275 }276 sep = c277 }278 return sep279}280281// localDateTime writes a date or time entry as stored (en-US) as typed282// in loc: a date of numbers in loc's order with its separator. ISO283// dates, month names and times stay as they are.284func localDateTime(t string, loc *locale.Locale) string {285 head, tail, spaced := strings.Cut(t, " ")286 sep := dateSep(head, locale.Canonical)287 if sep == 0 {288 return t289 }290 p := strings.Split(head, string(sep))291 if len(p[0]) == 4 || len(p) < 2 {292 return t // the year first: as typed, in every locale293 }294 m, d := p[0], p[1]295 parts := []string{m, d}296 switch {297 case loc.Order == locale.DMY:298 parts = []string{d, m}299 if len(p) == 3 {300 parts = append(parts, p[2])301 }302 case loc.Order == locale.YMD && len(p) == 3:303 parts = []string{p[2], m, d}304 case len(p) == 3:305 parts = append(parts, p[2])306 }307 sep = loc.DateSep308 if len(parts) == 2 && sep == loc.Decimal {309 sep = '/' // 26.9 would be a number in de-CH310 }311 date := strings.Join(parts, string(sep))312 if !spaced {313 return date314 }315 if loc.Decimal == ',' {316 tail = strings.Replace(tail, ".", ",", 1)317 }318 return date + " " + tail319}320321// currencyCode is the Currency format's pattern in loc, with dec322// decimals: "$"#,##0.00 in en-US, #,##0.00" €" in de-DE.323func currencyCode(loc *locale.Locale, dec string) string {324 sym := loc.Currency325 if loc.Space {326 if loc.After {327 sym = " " + sym328 } else {329 sym += " "330 }331 }332 if loc.After {333 return "#,##0" + dec + `"` + sym + `"`334 }335 return `"` + sym + `"#,##0` + dec336}337338// CodeIn is Code as shown in loc: the Currency format with loc's symbol339// and the Date, Time and Date time formats in loc's order. Patterns of340// their own are the same in every locale.341func (f Format) CodeIn(loc *locale.Locale) string {342 if loc.IsCanonical() || f.Pattern != "" && (f.Kind == FmtCustom || f.Kind.IsTime()) {343 return f.Code()344 }345 switch f.Kind {346 case FmtCurrency:347 dec := ""348 if f.Decimals > 0 {349 dec = "." + strings.Repeat("0", min(f.Decimals, MaxDecimals))350 }351 return currencyCode(loc, dec)352 case FmtDate:353 return loc.Date354 case FmtTime:355 return loc.Time356 case FmtDateTime:357 return loc.DateTime()358 }359 return f.Code()360}