cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / headless / value.go
value.go 10.3K
1package headless23import (4 "encoding/json"5 "fmt"6 "math"7 "strings"8 "sync"9 "time"1011 "src.rest/cat/012/internal/fileio"12 "src.rest/cat/012/internal/numfmt"13 "src.rest/cat/012/internal/nuon"14 "src.rest/cat/012/internal/sheet"15)1617// Value is a cell's value as agents write and read it, in JSON (see18// docs/reference/json.md#values): a number, a string (text, kept as19// text however it looks), true or false, null for blank, or an object20// naming its type, so money, percentages, dates, times, durations and21// sizes keep their meaning both ways:22//23// {"currency": 3.5} $3.50; "symbol": "€", "decimals": 024// {"percent": 0.12} 12%25// {"date": "2026-09-29"} a date, or "2026-09-29T14:30:00" with a time26// {"time": "14:30:00"} a time of day27// {"duration": "90min"} elapsed time, as nushell writes it, or seconds28// {"size": 1500} bytes, or "1.5kb" as nushell writes it29// {"number": 1234.5, "decimals": 2} a number with a format30// {"text": "00123"} text31//32// Any object may add "format", a number format code ("#,##0.00",33// "yyyy-mm-dd") shown in its type's place. Written as NUON, sizes,34// durations and dates may be nushell's own values.35type Value json.RawMessage3637// MarshalJSON writes v as it is, null when empty.38func (v Value) MarshalJSON() ([]byte, error) {39 if len(v) == 0 {40 return []byte("null"), nil41 }42 return v, nil43}4445// UnmarshalJSON keeps the JSON as it is: null is a Value too, one that46// clears a cell.47func (v *Value) UnmarshalJSON(b []byte) error {48 *v = append((*v)[:0], b...)49 return nil50}5152// typedTypes are the types a Value's object may name.53var typedTypes = []string{"currency", "percent", "date", "time", "duration", "size", "number", "text"}5455// typed is what writing a Value types into a cell: the entry, and the56// format it sets when formatted.57type typed struct {58 input string59 format sheet.Format60 formatted bool61}6263// parseValue reads a Value, as JSON or NUON.64func parseValue(v Value) (typed, error) {65 n, err := nuon.Parse(v)66 if err != nil {67 return typed{}, fmt.Errorf("%s isn't a value: %v", v, err)68 }69 return typedOf(n)70}7172// typedOf is what writing n types.73func typedOf(n nuon.Value) (typed, error) {74 switch n.Kind {75 case nuon.Null:76 return typed{}, nil77 case nuon.Bool:78 return typed{input: strings.ToUpper(fmt.Sprint(n.Bool))}, nil79 case nuon.Int, nuon.Float:80 f, _ := number(n)81 return typed{input: fileio.NumInput(f)}, nil82 case nuon.String:83 return typed{input: fileio.TextInput(n.Str)}, nil84 case nuon.Filesize, nuon.Duration, nuon.Date:85 c := fileio.NUONCell(n)86 return typed{input: fileio.NumInput(c.V.Num), format: c.F, formatted: true}, nil87 case nuon.Record:88 return typedRecord(n.Fields)89 }90 return typed{}, fmt.Errorf("%s isn't a cell's value: write a number, text, true, false, null or an object naming its type (%s)", n, strings.Join(typedTypes, ", "))91}9293// number is n as a float64, if it's a number.94func number(n nuon.Value) (float64, bool) {95 switch n.Kind {96 case nuon.Int:97 return float64(n.Int), true98 case nuon.Float:99 return n.Float, true100 }101 return 0, false102}103104// record is a Value's object: its type, the value under it, and the105// other fields.106type record struct {107 kind string108 val nuon.Value109 symbol string110 dec *int111 code string112}113114func readRecord(fs []nuon.Field) (record, error) {115 var r record116 for _, f := range fs {117 switch {118 case f.Key == "symbol" && f.Value.Kind == nuon.String:119 r.symbol = f.Value.Str120 case f.Key == "decimals" && f.Value.Kind == nuon.Int && f.Value.Int >= 0 && f.Value.Int <= sheet.MaxDecimals:121 d := int(f.Value.Int)122 r.dec = &d123 case f.Key == "format" && f.Value.Kind == nuon.String:124 r.code = f.Value.Str125 case r.kind == "" && oneOf(f.Key, typedTypes):126 r.kind, r.val = f.Key, f.Value127 default:128 return r, fmt.Errorf("%q isn't a field of a value: name one type (%s), with symbol, decimals or format", f.Key, strings.Join(typedTypes, ", "))129 }130 }131 if r.kind == "" {132 return r, fmt.Errorf("a value's object names its type: %s", strings.Join(typedTypes, ", "))133 }134 return r, nil135}136137func oneOf(s string, list []string) bool {138 for _, x := range list {139 if s == x {140 return true141 }142 }143 return false144}145146// typedRecord is what writing a Value's object types.147func typedRecord(fs []nuon.Field) (typed, error) {148 r, err := readRecord(fs)149 if err != nil {150 return typed{}, err151 }152 t, err := r.typed()153 if err != nil {154 return typed{}, fmt.Errorf("%s: %w", r.kind, err)155 }156 if r.dec != nil && t.format.Kind.HasDecimals() {157 t.format.Decimals = *r.dec158 }159 if r.code != "" {160 t.format, t.formatted = FormatOfCode(r.code), true161 }162 return t, nil163}164165func (r record) typed() (typed, error) {166 if r.kind == "text" {167 if r.val.Kind != nuon.String {168 return typed{}, fmt.Errorf("text is a string")169 }170 return typed{input: fileio.TextInput(r.val.Str)}, nil171 }172 v, f, err := r.serial()173 if err != nil {174 return typed{}, err175 }176 return typed{input: fileio.NumInput(v), format: f, formatted: true}, nil177}178179// serial is the number r stands for and the format it's shown in.180func (r record) serial() (float64, sheet.Format, error) {181 switch r.kind {182 case "date", "time":183 return r.clock()184 case "duration", "size":185 return r.unit()186 }187 n, ok := number(r.val)188 if !ok {189 return 0, sheet.Format{}, fmt.Errorf("%s is a number", r.val)190 }191 switch r.kind {192 case "currency":193 dec := 2194 if r.dec != nil {195 dec = *r.dec196 }197 return n, currencyFormat(r.symbol, dec), nil198 case "percent":199 return n, sheet.Format{Kind: sheet.FmtPercent, Decimals: percentDecimals(n)}, nil200 }201 if r.dec == nil {202 return n, sheet.Format{}, nil203 }204 return n, sheet.Format{Kind: sheet.FmtNumber}, nil205}206207// currencyFormat shows an amount in symbol ($ when ""), with dec208// decimals: 012's Currency for dollars, a pattern for the others.209func currencyFormat(symbol string, dec int) sheet.Format {210 if symbol == "" || symbol == "$" {211 return sheet.Format{Kind: sheet.FmtCurrency, Decimals: dec}212 }213 return sheet.Format{Kind: sheet.FmtCustom, Pattern: currencyPattern(symbol, dec)}214}215216func currencyPattern(symbol string, dec int) string {217 p := `"` + strings.ReplaceAll(symbol, `"`, "") + `"#,##0`218 if dec > 0 {219 p += "." + strings.Repeat("0", dec)220 }221 return p222}223224// percentDecimals are the decimals typing n as a percentage shows: none225// for a whole percentage (12%), two otherwise (12.50%).226func percentDecimals(n float64) int {227 p := n * 100228 if math.Abs(p-math.Round(p)) < 1e-9 {229 return 0230 }231 return 2232}233234// clock reads a date, a date and time, or a time of day.235func (r record) clock() (float64, sheet.Format, error) {236 if r.val.Kind == nuon.Date {237 c := fileio.NUONCell(r.val)238 return c.V.Num, c.F, nil239 }240 if r.val.Kind != nuon.String {241 return 0, sheet.Format{}, fmt.Errorf("%s is a string: 2026-09-29, 2026-09-29T14:30:00, 14:30", r.val)242 }243 s := strings.TrimSpace(r.val.Str)244 if r.kind == "time" {245 v, f, ok := sheet.ParseValue(s)246 if !ok || f.Kind != sheet.FmtTime {247 return 0, sheet.Format{}, fmt.Errorf("%q isn't a time of day: 14:30, 14:30:05, 2:30 PM", s)248 }249 return v, sheet.Preset(sheet.FmtTime), nil250 }251 t, withTime, ok := parseISO(s)252 switch {253 case !ok:254 return 0, sheet.Format{}, fmt.Errorf("%q isn't a date: 2026-09-29, 2026-09-29T14:30:00", s)255 case t.Location() != time.UTC:256 return fileio.NUONCell(nuon.DateValue(t)).V.Num, sheet.Preset(sheet.FmtDateTime), nil257 case withTime:258 return numfmt.SerialOf(t), sheet.Preset(sheet.FmtDateTime), nil259 }260 return numfmt.SerialOf(t), sheet.Preset(sheet.FmtDate), nil261}262263// isoLayouts are the dates a Value's date takes, without an offset: the264// time on the sheet's clock.265var isoLayouts = []string{"2006-01-02T15:04:05.999999999", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999", "2006-01-02 15:04"}266267// parseISO reads a date in ISO 8601: a day, or a day and a time, with268// an offset (in a location of its own, for the sheet's time zone to269// convert) or without one (in UTC, the time on the sheet's clock).270func parseISO(s string) (t time.Time, withTime, ok bool) {271 if t, err := time.Parse("2006-01-02", s); err == nil {272 return t, false, true273 }274 if t, err := time.Parse(time.RFC3339Nano, s); err == nil {275 _, off := t.Zone()276 return t.In(time.FixedZone("", off)), true, true277 }278 for _, l := range isoLayouts {279 if t, err := time.Parse(l, s); err == nil {280 return t, true, true281 }282 }283 return time.Time{}, false, false284}285286// unit reads a duration or a size: a number (seconds, bytes) or287// nushell's literal (90min, 1.5kb).288func (r record) unit() (float64, sheet.Format, error) {289 v := r.val290 if v.Kind == nuon.String {291 p, err := nuon.Parse([]byte(v.Str))292 if err != nil {293 return 0, sheet.Format{}, fmt.Errorf("%q isn't nushell's %s: %s", v.Str, r.kind, unitExample(r.kind))294 }295 v = p296 }297 if n, ok := number(v); ok {298 if r.kind == "duration" {299 v = nuon.DurationValue(time.Duration(math.Round(n * 1e9)))300 } else {301 v = nuon.FilesizeValue(int64(math.Round(n)))302 }303 }304 if want := map[string]nuon.Kind{"duration": nuon.Duration, "size": nuon.Filesize}[r.kind]; v.Kind != want {305 return 0, sheet.Format{}, fmt.Errorf("%s isn't a %s: %s", v, r.kind, unitExample(r.kind))306 }307 c := fileio.NUONCell(v)308 return c.V.Num, c.F, nil309}310311func unitExample(kind string) string {312 if kind == "duration" {313 return "90min, 1.5hr, 2day, or a number of seconds"314 }315 return "1.5kb, 3MiB, or a number of bytes"316}317318// FormatOfCode is the format a number format code stands for: one of319// 012's formats when it's that format's code ("$"#,##0.00 is Currency),320// a date or time with that pattern, or a custom format.321func FormatOfCode(code string) sheet.Format {322 if f, ok := presetCodes()[code]; ok {323 return f324 }325 if rest, ok := strings.CutPrefix(code, "$"); ok {326 if f, ok := presetCodes()[`"$"`+rest]; ok {327 return f // $#,##0.00, as people write Currency's code328 }329 }330 switch k := fileio.CodeKind(code); k {331 case sheet.FmtDate, sheet.FmtTime, sheet.FmtDateTime, sheet.FmtDuration:332 return sheet.Format{Kind: k, Pattern: code}333 }334 return sheet.Format{Kind: sheet.FmtCustom, Pattern: code}335}336337// presetCodes are 012's formats by their codes.338var presetCodes = sync.OnceValue(func() map[string]sheet.Format {339 m := map[string]sheet.Format{}340 for k := sheet.FmtSize; k > sheet.FmtAuto; k-- {341 if !k.HasDecimals() {342 m[sheet.Format{Kind: k}.Code()] = sheet.Format{Kind: k}343 continue344 }345 for d := sheet.MaxDecimals; d >= 0; d-- {346 f := sheet.Format{Kind: k, Decimals: d}347 m[f.Code()] = f348 }349 }350 delete(m, "")351 return m352})