write_cells, apply_operations and 012 set --value take values with
their types ({"currency": 3.5}, {"date": ...}, {"size": "1.5kb"}) and
number format codes; write_table, create_workbook's data and 012 set
--table write tables formatted by column. read_range, resources and
012 get --format json return the same typed values, with each cell's
shown text by default. Notebook outputs sent to a sheet take again the
formats of the columns their cells read, so currency and percentages,
numbers in nu, come back formatted.
Co-Authored-By: Claude <noreply@anthropic.com>- commit
- efbed527a23a21792eb5d163ac1ead7138961e22
- parent
- 20d8b4d6b627a301963de15727f4f71c7b91d4fa
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.3 of 3, Jev's estimate
| M | cmd/ | 62 | +++++- |
| M | cmd/ | 26 | ++- |
| A | cmd/ | 51 | ++++ |
| M | docs/ | 22 | +- |
| M | docs/ | 5 | +- |
| M | docs/ | 17 | +- |
| M | docs/ | 4 | +- |
| M | docs/ | 21 | ++ |
| M | docs/ | 78 | ++++++- |
| A | internal/ | 122 | ++++++++++ |
| A | internal/ | 29 | ++ |
| M | internal/ | 37 | +++- |
| M | internal/ | 26 | +- |
| M | internal/ | 2 | + |
| M | internal/ | 29 | ++- |
| M | internal/ | 32 | +-- |
| M | internal/ | 72 | +++++- |
| A | internal/ | 171 | +++++++++++++++ |
| M | internal/ | 11 | +- |
| A | internal/ | 352 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 151 | +++++++++++++ |
| A | internal/ | 177 | +++++++++++++++ |
| M | internal/ | 24 | ++- |
| M | internal/ | 6 | +- |
| M | internal/ | 15 | +- |
| A | internal/ | 51 | ++++ |
| A | internal/ | 113 | ++++++++++ |
| M | internal/ | 5 | +- |
| M | internal/ | 26 | ++- |
| M | internal/ | 1 | + |
@@ -10,6 +10,7 @@ import (
1010 "github.com/FelineStateMachine/012/internal/diff"1111 "github.com/FelineStateMachine/012/internal/headless"1212 "github.com/FelineStateMachine/012/internal/notebook"13+ "github.com/FelineStateMachine/012/internal/nuon"1314 )1415 1516 // 012 get, set and recalc: workbook files read and changed without the@@ -18,7 +19,7 @@ import (
1819 1920 const (2021 getUsage = "usage: 012 get file.012 [ref] [--format text|csv|tsv|json|nuon] [--input] [--no-header] [--notebooks] [--jev] [--trust]"21- setUsage = "usage: 012 set file.012 ref input [ref input ...] [--force] [--dry-run] [--format text|json|nuon]"22+ setUsage = "usage: 012 set file.012 [ref input ...] [--value] [--table ref] [--force] [--dry-run] [--format text|json|nuon]"2223 recalcUsage = "usage: 012 recalc file.012 [--format text|json|nuon] [--notebooks] [--jev] [--trust]"2324 )2425 @@ -67,14 +68,14 @@ func runGet(args []string, e env) error {
6768 // runSet is 012 set: type entries into cells and save, or with6869 // --dry-run print what would change, as 012 diff would, and not save.6970 func runSet(args []string, e env) error {70- a, err := parseArgs(args, []string{"format"}, []string{"force", "dry-run", "help"})71+ a, err := parseArgs(args, []string{"format", "table"}, []string{"force", "dry-run", "value", "help"})7172 switch {7273 case err != nil:7374 return usageError(err.Error(), setUsage)7475 case a.has("help"):7576 fmt.Fprintln(e.stdout, setUsage)7677 return nil77- case len(a.pos) < 3 || len(a.pos)%2 == 0:78+ case len(a.pos) < 1 || len(a.pos) < 3 && !a.has("table") || len(a.pos)%2 == 0:7879 return usageError("", setUsage)7980 }8081 format, err := resultFormat(a)@@ -85,11 +86,7 @@ func runSet(args []string, e env) error {
8586 if err != nil {8687 return err8788 }88- var entries []headless.Entry89- for i := 1; i < len(a.pos); i += 2 {90- entries = append(entries, headless.Entry{Ref: a.pos[i], Input: a.pos[i+1]})91- }92- warnings, err := headless.Set(f.Book, entries, headless.SetOptions{Force: a.has("force")})89+ warnings, err := setEntries(f, a, e.stdin)9390 if err != nil {9491 return fmt.Errorf("%w; %s is unchanged", err, f.Path)9592 }@@ -127,6 +124,55 @@ func runSet(args []string, e env) error {
127124 128125 // resultFormat is --format's form for a command's result: text, json129126 // or nuon.127+// setEntries makes 012 set's change: the table --table reads from in,128+// then the entries, typed or with --value each a value with its type.129+func setEntries(f *headless.File, a cliArgs, in io.Reader) ([]string, error) {130+ o := headless.SetOptions{Force: a.has("force")}131+ var warnings []string132+ if a.has("table") {133+ data, err := io.ReadAll(in)134+ if err != nil {135+ return nil, err136+ }137+ rows, err := tableRows(data)138+ if err != nil {139+ return nil, fmt.Errorf("--table: %w", err)140+ }141+ if warnings, err = headless.WriteTable(f.Book, headless.TableSpec{At: a.flags["table"], Rows: rows}, o); err != nil {142+ return nil, err143+ }144+ }145+ var entries []headless.Entry146+ for i := 1; i < len(a.pos); i += 2 {147+ e := headless.Entry{Ref: a.pos[i], Input: a.pos[i+1]}148+ if a.has("value") {149+ e.Input, e.Value = "", headless.Value(a.pos[i+1])150+ }151+ entries = append(entries, e)152+ }153+ more, err := headless.Set(f.Book, entries, o)154+ return append(warnings, more...), err155+}156+157+// tableRows are the records of a NUON or JSON table, each as NUON.158+func tableRows(data []byte) ([]headless.Row, error) {159+ v, err := nuon.Parse(data)160+ if err != nil {161+ return nil, err162+ }163+ if v.Kind == nuon.Record {164+ v = nuon.ListValue(v)165+ }166+ if v.Kind != nuon.List {167+ return nil, errors.New("standard input isn't a table: a list of records, as NUON or JSON")168+ }169+ rows := make([]headless.Row, len(v.List))170+ for i, r := range v.List {171+ rows[i] = headless.Row(nuon.Append(nil, r))172+ }173+ return rows, nil174+}175+130176 func resultFormat(a cliArgs) (string, error) {131177 switch f := strings.ToLower(a.flags["format"]); f {132178 case "", "text":@@ -28,8 +28,14 @@ Read what describe points at rather than whole sheets.
2828 012 get book.012 'Q3 plan'!A1:F20 --format json # records named by the first row2929 012 get book.012 Sales --format json # a table or named range by name3030 012 get book.012 'Sales[Amount]' --format json # one column of a table31+012 get book.012 A1:D9 --format nuon # for nushell: sizes, durations, dates keep their types3132 ```3233 34+JSON values keep their types: `3.5`, `"00123"` (text), `{"currency": 3.5}`,35+`{"percent": 0.12}`, `{"date": "2026-09-29"}`, `{"duration": "90min"}`,36+`{"size": 1500}`, with `decimals` or `format` when the cell's format37+differs from its type's own.38+3339 References are written as in formulas: `B7`, `A1:C9`, `A:A`, `Q3!B7`,3440 `'Q3 plan'!A1:C9`, a named range, a table (`Sales`, `Sales[Amount]`) or a3541 sheet name for the whole sheet. Without a sheet, the sheet shown when the@@ -56,6 +62,17 @@ Preview first, then write:
5662 table or notebook output. Read the message; it names the cell.5763 - An input starting with `--` goes after `--`: `012 set book.012 -- A1 --`.5864 65+Keep what values mean. Never write money, percentages, dates, sizes or66+durations as bare numbers (`3.5` shows as `3.5`, not `$3.50`): type them67+as a person would (`$3.50`, `12%`, `2026-09-29`), or with `--value` as68+JSON values with their types, and write tables with `--table`, which69+formats each column from its values:70+71+```sh72+012 set book.012 --value B2 '{"currency": 3.5}' C2 '{"percent": 0.12}' D2 '{"size": "1.5kb"}' E2 '"00123"'73+echo '[{"item": "Tea", "price": {"currency": 3.5}, "bought": {"date": "2026-09-29"}}]' | 012 set book.012 --table A174+```75+5976 ## Check6077 6178 ```sh@@ -107,7 +124,12 @@ host afterwards.
107124 108125 When the server is connected, its tools do what the commands do, with109126 the same checks: describe without a path lists the workbooks, then110-describe, read_range, write_cells with dry_run, evaluate, list_errors111-and create_workbook, each given the workbook's path.127+describe, read_range, write_cells with dry_run, write_table, evaluate,128+list_errors and create_workbook, each given the workbook's path.129+Values go both ways with their types, as above: write_cells takes an130+entry's `input` or its `value` (`{"ref": "B2", "value": {"currency": 3.5}}`)131+and a `format` code; write_table takes `columns` (their order) and132+`rows` of records or lists; read_range returns typed values and each133+cell's shown text, so check what you wrote reads as intended (`$3.50`).112134 113135 The full reference: https://github.com/FelineStateMachine/012/blob/main/docs/agents/README.md@@ -0,0 +1,51 @@
1+package main2+3+import (4+ "os/exec"5+ "path/filepath"6+ "strings"7+ "testing"8+)9+10+// 012 set --table writes a NUON table from standard input with its11+// types, --value types values, and 012 get writes them back: as typed12+// JSON, and as NUON nushell reads with its types.13+func TestTypedSetAndGet(t *testing.T) {14+ e, out, _ := scriptEnv(t)15+ path := filepath.Join(t.TempDir(), "book.012")16+ e.stdin = strings.NewReader(`[[name, size, took, when]; [a.txt, 1.5kb, 90sec, 2026-09-29T14:30:00+00:00], [b.txt, 2mb, 2hr, 2026-09-28T09:00:00+00:00]]`)17+ if code, err := status(e, "set", path, "--table", "A1", "--value", "E1", `"price"`, "E2", `{"currency": 3.5}`, "E3", `{currency: 12, decimals: 0}`); code != 0 {18+ t.Fatal(err)19+ }20+ for _, c := range []struct {21+ args []string22+ want string23+ }{24+ {[]string{"E2"}, "$3.50\n"},25+ {[]string{"E3", "--format", "json"}, `{"currency":12,"decimals":0}` + "\n"},26+ {[]string{"B2", "--format", "json"}, `{"size":1500}` + "\n"},27+ {[]string{"C3", "--format", "json"}, `{"duration":"2hr"}` + "\n"},28+ {[]string{"A1:C2", "--format", "json"}, "[\n" + `{"name":"a.txt","size":{"size":1500},"took":{"duration":"90sec"}}` + "\n]\n"},29+ {[]string{"A1:C3", "--format", "nuon"}, "[[name, size, took]; [a.txt, 1500b, 90000000000ns],\n[b.txt, 2000000b, 7200000000000ns]]\n"},30+ } {31+ out.Reset()32+ if code, err := status(e, append([]string{"get", path}, c.args...)...); code != 0 || out.String() != c.want {33+ t.Errorf("get %v: %d %v %q, want %q", c.args, code, err, out, c.want)34+ }35+ }36+ nu, err := exec.LookPath("nu")37+ if err != nil {38+ t.Skip("nu isn't installed")39+ }40+ out.Reset()41+ status(e, "get", path, "A1:E3", "--format", "nuon")42+ cmd := exec.Command(nu, "--no-config-file", "--stdin", "-c", `from nuon | each {|r| [($r.size | describe), ($r.took | describe), ($r.when | describe), ($r.price | describe)] | str join " "} | str join "\n"`)43+ cmd.Stdin = strings.NewReader(out.String())44+ got, err := cmd.Output()45+ if err != nil {46+ t.Fatalf("nu: %v %s", err, got)47+ }48+ if want := "filesize duration datetime float\nfilesize duration datetime int"; strings.TrimSpace(string(got)) != want {49+ t.Errorf("nushell reads %q, want %q", got, want)50+ }51+}@@ -95,19 +95,25 @@ Every tool but `describe` takes `path`, the workbook, required unless
9595 the server was started with a file. References are written as in9696 formulas (`B7`, `Q3!A1:C9`, `'Q3 plan'!A:A`, a named range, a table,9797 `Sales[Amount]`, a sheet's name), and inputs as a person types them, in98-en-US form. Every write takes `dry_run`, which returns the change99-without making it.98+en-US form. Values keep their types both ways: writes take money,99+percentages, dates, times, durations and sizes as100+[values with their types](../reference/json.md#values)101+(`{"currency": 3.5}`, `{"date": "2026-09-29"}`) as well as typed102+entries (`$3.50`), and reads return them the same way beside the text103+each cell shows, so a model can check what it wrote. Every write takes104+`dry_run`, which returns the change without making it.100105 101106 | Tool | Does |102107 |---|---|103108 | `describe` | Without a path, the workbooks open to the server; with one, its sheets, used ranges, guessed header rows and column names, tables, outputs, charts, pivots, notebook cells and names, as [`012 describe`](../reference/json.md#describe) |104-| `read_range` | A range's values as rows, its formulas by cell, and with `text` each cell as shown |109+| `read_range` | A range's values as rows, typed, each cell as shown, and its formulas by cell |105110 | `evaluate` | A formula's value, computed in the workbook (at `at`, or below the data) without writing it: its text, why it's an error, an array's spill |106111 | `find` | Cells by what they show or, with `in_formulas`, their formulas' text, as Edit > Find |107112 | `list_errors` | The formulas showing errors after recalculating, as `012 recalc` lists them, without saving |108-| `create_workbook` | A new `.012` workbook at `path`, empty or made `from` a file 012 imports or another workbook; it never replaces a file |109-| `write_cells` | Entries typed into cells as one change, as `012 set` |110-| `apply_operations` | Several operations as one change: `set`, `clear`, `insert_rows`, `delete_rows`, `insert_columns`, `delete_columns`, `add_sheet`, `rename_sheet`, `delete_sheet`, `define_name`, `sort` |113+| `create_workbook` | A new `.012` workbook at `path`, empty, made `from` a file 012 imports or another workbook, or holding `data`, a table as `write_table` writes it; it never replaces a file |114+| `write_cells` | Entries typed into cells, or values with their types, and number formats, as one change, as `012 set` |115+| `write_table` | Rows of records under a header, each column formatted as its values' type, as one change, as importing a NUON table |116+| `apply_operations` | Several operations as one change: `set`, `write_table`, `clear`, `insert_rows`, `delete_rows`, `insert_columns`, `delete_columns`, `add_sheet`, `rename_sheet`, `delete_sheet`, `define_name`, `sort` |111117 | `sort` | A range's rows sorted by columns, named by letter or header |112118 | `filter` | A filter on a range by conditions (`gt`, `contains` and the rest) or values to hide, or the sheet's filter removed |113119 | `create_chart` | A chart of a range, its type, title and place given or guessed as Insert > Chart guesses them |@@ -144,8 +150,8 @@ where the range or chart is, and its HTML, at most 200 rows of what
144150 `read_range` returned. Both kinds of hosts hand a result's `_meta` to145151 the page (the Apps SDK as `window.openai.toolResponseMetadata`) and146152 keep it from the model, which reads the result itself,147-`structuredContent` and the same JSON as text, with no HTML: a few148-kilobytes for 60 rows. The page asks for nothing from the network and153+`structuredContent` and the same JSON as text, with no HTML: about ten154+kilobytes for 60 rows of currency, typed values and text shown. The page asks for nothing from the network and149155 calls no tools.150156 151157 ## Resources@@ -66,7 +66,10 @@ the race detector, with the time an edit takes to reach another
6666 session's frame (`sharelive_test.go`).6767 6868 The MCP server is tested through the SDK's client over an in-memory69-transport. `TestViewInBrowser` (`internal/mcp`) draws its69+transport. `TestTypedValuesThroughNotebook` writes values of every type70+through it, reads them in a notebook cell run by the real `nu` (skipped71+without it) and checks the output sent back to a sheet keeps their72+formats. `TestViewInBrowser` (`internal/mcp`) draws its7073 [view](../agents/mcp.md#views-in-the-chat) in Chromium with Playwright7174 (`internal/mcp/testdata/view.mjs`), once with `window.openai` set as the7275 OpenAI Apps SDK sets it and once behind a page speaking MCP Apps'@@ -48,10 +48,11 @@ form:
4848 |---|---|---|4949 | `text`, the default | As the cell shows it: `$1,200.00`, `9/29/2026`, `#DIV/0!` | Aligned columns, numbers to the right |5050 | `csv`, `tsv` | One line | Rows as shown, in the workbook's [locale](../sheets/locale.md), as a CSV download |51-| `json` | The value: a number, a string, `true`, `null` for a blank cell | A list of records named by the range's first row |51+| `json` | The [value with its type](../reference/json.md#values): `3.5`, `"00123"`, `true`, `null` for a blank cell, `{"currency": 3.5}`, `{"date": "2026-09-29"}` | A list of records named by the range's first row |5252 | `nuon` | The value with its nushell type: a date, a file size, a duration | A nushell table named by the range's first row |5353 54-JSON and NUON type values by their cells' formats as a54+JSON and NUON type values by their cells' formats, JSON as the55+[values](../reference/json.md#values) agents read and NUON as a5556 [NUON download](../nushell/types.md) does, and take the columns' names5657 from the range's first row, as a table in 012 has its header there.5758 `--no-header` names the columns by their letters instead (`A`, `B`) and@@ -83,6 +84,18 @@ and `=ROUND(A1, 2)` in every workbook, so a script means the same in a
8384 de-DE workbook as in an en-US one. Entries that imply a format (`$1,200`,8485 `12%`, `2026-09-29`) set it, as typing them does.8586 87+`--value` takes each input as a [value with its type](../reference/json.md#values),88+in JSON or NUON, for what typing has no entry for: sizes, durations,89+other currencies, text that looks like a number. `--table ref` writes90+the table on standard input (NUON or JSON, a list of records) with its91+header at `ref`, each column formatted as its values' type, as92+importing it would, before the entries:93+94+```sh95+012 set budget.012 --value B7 '{"currency": 12.5, "symbol": "€"}' C7 1.5kb D7 '"00123"'96+ls | select name size modified | to nuon | 012 set files.012 --table A197+```98+8699 `set` stops, and leaves the file as it was, on the first input a cell87100 can't take, naming the cell:88101 @@ -440,7 +440,9 @@ A cell reads the sheets two ways, each a table:
440440 441441 They reach nu as NUON in a file it reads, never as text spliced into442442 the pipeline, so types survive ([Types](types.md)): sizes stay sizes,443-dates stay dates.443+dates stay dates, and currency and percentages, numbers in nu, take444+their columns' formats again when the output is sent to a sheet445+([Currency and percentages](types.md#currency-and-percentages)).444446 445447 ### Stale outputs and reactive notebooks446448 @@ -38,6 +38,27 @@ decides what goes back: numbers given the Size format go back as file
3838 sizes, and a date column formatted as plain numbers goes back as3939 numbers.4040 41+## Currency and percentages42+43+Nushell has no type for money or percentages, so `$3.50` goes to44+nushell as the number `3.5` and `12%` as `0.12`, which `math sum` and45+`where price > 3` work on. The format comes back with the column: when46+a [notebook cell](notebooks.md)'s output is sent to a sheet, each of47+its columns named as a column of the ranges the cell read48+(`$sheet.A1:C9`, `$selection`) takes that column's format again, when49+its values are still of the type the format shows. A price column read50+as currency and sent back is currency, a date column shows its dates51+in its own pattern (`2026-09-29` rather than the Date time format), and52+a size column keeps its decimals:53+54+```nu55+$sheet.A1:D9 | where price > 3 | sort-by bought # price is currency again on the sheet56+```57+58+A column named anew (`insert total {|r| $r.price * $r.qty}`) has no59+format to take, and shows plain numbers. Outside notebooks, [`012 get --format json`](../reference/json.md#values)60+keeps currency and percentages as typed values.61+4162 ## Tables, records and single values4263 4364 A value that isn't in a table comes in as a table too: a@@ -13,13 +13,42 @@ may add fields, but never renames or removes one, or changes its type.
1313 Errors are not JSON: they go to standard error as text, with the exit1414 statuses in [Command line](command-line.md#exit-status).1515 16+## Values17+18+A cell's value in JSON keeps what its format means: money stays money19+and a date a date, so what reads it can write it back as it was. The20+same form is what [`012 get`](#get) and the MCP server's reads return21+and what `012 set --value`, `write_cells`, `write_table` and22+`create_workbook` take:23+24+| Value | Is | Shown |25+|---|---|---|26+| `3.5`, `"00123"`, `true`, `null` | A number in the Automatic format, text (always text, however it looks), a boolean, a blank cell | `3.5`, `00123`, `TRUE` |27+| `{"currency": 3.5}` | An amount; `"symbol": "€"` for another currency than dollars | `$3.50`, `€3.50` |28+| `{"percent": 0.12}` | A fraction shown as a percentage | `12%` |29+| `{"date": "2026-09-29"}` | A date, or with a time `"2026-09-29T14:30:00"` on the sheet's clock (an offset, `Z` or `-06:00`, converts it to the local time zone) | `9/29/2026` |30+| `{"time": "14:30:00"}` | A time of day | `2:30:00 PM` |31+| `{"duration": "90min"}` | Elapsed time, in [nushell's units](https://www.nushell.sh/book/types_of_data.html#durations) (`ns` to `wk`), or written as a number of seconds | `1:30:00` |32+| `{"size": 1500}` | A number of bytes, or written as nushell's `"1.5kb"` | `1.5 kB` |33+| `{"number": 1234.5, "decimals": 2}` | A number in another format than Automatic | `1,234.50` |34+| `{"text": "00123"}` | Text, written this way or as a string | `00123` |35+36+Any of the objects may add `"decimals"` (0 to 15) or `"format"`, a37+[number format](../sheets/formatting.md#number-formats) code shown in38+the type's place (`{"date": "2026-09-29", "format": "yyyy-mm-dd"}`).39+A value read back carries them when its cell's format isn't the one its40+type gives by itself, so writing it again gives the same value and41+format. An error reads as its text (`"#DIV/0!"`). NUON has its own42+[types](../nushell/types.md) for sizes, durations and dates, and none43+for currency or percentages.44+1645 ## get1746 18-`012 get file ref --format json` writes a cell's value alone (a number,19-a string, `true`, `null` for a blank cell), or a range as a list of20-records named by its first row (`--no-header`: by column letters), as21-[Scripts](../files/scripts.md#get) describes. Dates are strings in ISO22-8601, and in NUON dates, file sizes and durations keep their types.47+`012 get file ref --format json` writes a cell's [value](#values) alone,48+or a range as a list of records named by its first row49+(`--no-header`: by column letters), each field a value, as50+[Scripts](../files/scripts.md#get) describes. In NUON dates, file sizes51+and durations are nushell's, and currency and percentages numbers.2352 2453 ## set2554 @@ -152,21 +181,48 @@ more than it lists:
152181 ```json153182 {154183 "range": "Sales!A1:C3", "rows": 3, "cols": 3,155- "values": [["Region", "Units", "Price"], ["North", 3, 12.5], ["South", 5, null]],184+ "values": [["Region", "Units", "Price"], ["North", 3, {"currency": 12.5}], ["South", 5, null]],156185 "text": [["Region", "Units", "Price"], ["North", "3", "$12.50"], ["South", "5", ""]],157186 "formulas": {"C3": "=IF(B3>4, \"\", 9.75)"},158187 "truncated": false159188 }160189 ```161190 162-`values` are typed as [`get`](#get) types one cell (blank is `null`, an163-error its text); `text` comes only when asked for; `truncated` is set164-when the range held more cells than `max_cells`, and `rows` says how165-many came back. The result's `_meta["o12/view"]` holds what191+`values` are [values](#values) (blank is `null`, an error its text);192+`text` is each cell as shown, left out with `"text": false`;193+`truncated` is set when the range held more cells than `max_cells`, and194+`rows` says how many came back. The result's `_meta["o12/view"]` holds what166195 [the view](../agents/mcp.md#views-in-the-chat) draws: `title` (the167196 workbook's name), `where` and `html`.168197 169-Writes (`write_cells`, `apply_operations`, `sort`, `filter`) return198+`write_cells` takes each entry's `input` as a person types it, or its199+`value` as a [value](#values), and `format`, a number format code for200+the cell or, alone, for every cell of a range:201+202+```json203+{"entries": [204+ {"ref": "B2", "value": {"currency": 3.5}},205+ {"ref": "C2", "input": "12%"},206+ {"ref": "D2", "value": {"date": "2026-09-29"}, "format": "yyyy-mm-dd"},207+ {"ref": "E2:E9", "format": "#,##0.00"}208+]}209+```210+211+`write_table` (and `apply_operations`' `write_table`, and212+`create_workbook`'s `data`) writes rows under a header naming their213+columns, in `columns`' order (a JSON object's keys can reach the tool214+in any order), each row a record of values or a list of them in that215+order, and formats each column as its first typed value, or as216+`formats` says:217+218+```json219+{"at": "A1", "columns": ["Item", "Price", "Bought"],220+ "rows": [{"Item": "Tea", "Price": {"currency": 3.5}, "Bought": {"date": "2026-09-29"}},221+ ["Cake", 2, {"date": "2026-09-28"}]],222+ "formats": {"Price": "$#,##0.00"}}223+```224+225+Writes (`write_cells`, `write_table`, `apply_operations`, `sort`, `filter`) return170226 `{"saved", "changes", "warnings"}` as [`set`](#set) does, `saved` false171227 for a dry run or a change that changed nothing; `create_chart` adds172228 `"chart": {"sheet", "number", "title"}`, its view in `_meta` as@@ -0,0 +1,122 @@
1+package fileio2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/notebook"7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// NUON has no currency or percentage, so a cell showing $3.50 reaches11+// nushell as the number 3.5, and a date as a datetime, whatever its12+// pattern. When a notebook cell's output comes back to a sheet, a13+// column takes again the format its namesake had in the ranges the cell14+// read ($sheet.A1:C9, $selection), when its values are still of that15+// type: a price column read as currency and sent back is currency16+// again, a date column shows its dates as it did (see17+// docs/nushell/types.md).18+19+// TextInput is s as an entry that stays text, whatever it looks like:20+// '00123 for 00123, as importing stores text.21+func TextInput(s string) string { return textInput(s) }22+23+// NumInput is v as an entry: 3.5, 1E+21.24+func NumInput(v float64) string { return numInput(v) }25+26+// CodeKind says whether a number format code shows a date, a time,27+// both or a duration; FmtCustom for anything else.28+func CodeKind(code string) sheet.FormatKind { return dateKind(code) }29+30+// SnapColumns are the names of snap's columns, from its range's first31+// row, as a table written as NUON or JSON names them.32+func SnapColumns(snap *Snapshot) []string { return snapColumns(snap) }33+34+// ColumnFormats are the formats of snap's columns, by the columns'35+// names: the format of each column's first number under the header,36+// when it isn't Automatic.37+func ColumnFormats(snap *Snapshot) map[string]sheet.Format {38+ r := snap.Range39+ out := map[string]sheet.Format{}40+ for i, name := range snapColumns(snap) {41+ col := r.From.Col + i42+ for row := r.From.Row + 1; row <= r.To.Row; row++ {43+ c, ok := snap.Cells[sheet.Addr{Col: col, Row: row}]44+ if !ok || c.Value.Kind != sheet.Number {45+ continue46+ }47+ if !c.Format.IsZero() {48+ out[name] = c.Format49+ }50+ break51+ }52+ }53+ return out54+}55+56+// nuonKind is the NUON type a number shown in f is written as: a size,57+// a duration (a time of day among them), a date, or a plain number.58+func nuonKind(f sheet.Format) sheet.FormatKind {59+ switch k := f.Kind; k {60+ case sheet.FmtSize, sheet.FmtDuration:61+ return k62+ case sheet.FmtTime:63+ return sheet.FmtDuration64+ case sheet.FmtDate, sheet.FmtDateTime:65+ return sheet.FmtDate66+ case sheet.FmtCustom:67+ return nuonKind(sheet.Format{Kind: dateKind(f.Pattern)})68+ }69+ return sheet.FmtNumber70+}71+72+// KeepFormats gives each number under a header named in formats that73+// format, when NUON writes both as the same type: a plain number takes74+// its column's currency, a date its column's pattern, a size its75+// column's decimals.76+func KeepFormats(rows *TailRows, formats map[string]sheet.Format) {77+ if len(formats) == 0 {78+ return79+ }80+ for i, h := range rows.Header {81+ f, ok := formats[strings.TrimSpace(h.V.String())]82+ if !ok {83+ continue84+ }85+ for _, row := range rows.Rows {86+ if i < len(row) && row[i].V.Kind == sheet.Number && nuonKind(row[i].F) == nuonKind(f) {87+ row[i].F = f88+ }89+ }90+ }91+}92+93+// RangeResolver finds the sheet and range a $sheet reference names.94+type RangeResolver func(ref string) (*sheet.Sheet, sheet.Rect, error)95+96+// OutputFormats are the formats KeepFormats keeps for output o of w:97+// those of the columns of the ranges its run read, found by resolve,98+// and of its selection.99+func OutputFormats(w *sheet.Workbook, o *notebook.Output, resolve RangeResolver) map[string]sheet.Format {100+ out := map[string]sheet.Format{}101+ add := func(t *sheet.Sheet, r sheet.Rect) {102+ for name, f := range ColumnFormats(Snap(t, r, t.Name())) {103+ if _, ok := out[name]; !ok {104+ out[name] = f105+ }106+ }107+ }108+ _, _, refs := notebook.Parse(o.Source).Command()109+ for _, ref := range refs {110+ if t, r, err := resolve(ref.Ref); err == nil {111+ add(t, r)112+ }113+ }114+ if o.Selection != "" {115+ name, cells := sheet.SplitSheet(o.Selection)116+ r, ok := sheet.ParseRange(cells)117+ if t := w.Lookup(name); ok && t != nil {118+ add(t, r)119+ }120+ }121+ return out122+}@@ -0,0 +1,29 @@
1+package fileio2+3+import (4+ "context"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// A notebook's output takes the formats of the columns it read, where11+// its values are still of their types.12+func TestKeepFormats(t *testing.T) {13+ w := sheet.NewBook()14+ s := w.Sheet(0)15+ for a, in := range map[string]string{"A1": "price", "B1": "bought", "C1": "n", "A2": "$3.50", "B2": "2026-09-29", "C2": "12%"} {16+ at, _ := sheet.ParseAddr(a)17+ s.Set(at, in)18+ }19+ formats := ColumnFormats(Snap(s, sheet.NewRect(sheet.Addr{}, sheet.Addr{Col: 2, Row: 1}), "Sheet1"))20+ rows, _, err := NUONRows(context.Background(), []byte(`[[price, bought, n, other]; [4.25, 2026-10-01T00:00:00+00:00, "text", 7]]`), 0)21+ if err != nil {22+ t.Fatal(err)23+ }24+ KeepFormats(&rows, formats)25+ row := rows.Rows[0]26+ if row[0].F.Kind != sheet.FmtCurrency || row[1].F.Pattern != "yyyy-mm-dd" || row[2].V.Kind != sheet.Text || !row[3].F.IsZero() {27+ t.Errorf("kept: %+v", row)28+ }29+}@@ -58,10 +58,45 @@ func Get(out io.Writer, t Target, o GetOptions) error {
5858 if o.NoHeader && (k == fileio.JSON || k == fileio.NUON) {5959 snap = lettered(snap)6060 }61+ if k == fileio.JSON {62+ return writeJSONTable(out, snap)63+ }6164 _, err := fileio.Encode(out, k, snap)6265 return err6366 }6467 68+// writeJSONTable writes snap as a JSON list of records named by its69+// first row, one to a line, each value a Value.70+func writeJSONTable(out io.Writer, snap *fileio.Snapshot) error {71+ r := snap.Range72+ cols := fileio.SnapColumns(snap)73+ b := []byte("[")74+ for row := r.From.Row + 1; row <= r.To.Row; row++ {75+ if row > r.From.Row+1 {76+ b = append(b, ',')77+ }78+ b = append(b, "\n{"...)79+ for i, name := range cols {80+ if i > 0 {81+ b = append(b, ',')82+ }83+ b = append(nuon.AppendJSON(b, nuon.StringValue(name)), ':')84+ c, ok := snap.Cells[sheet.Addr{Col: r.From.Col + i, Row: row}]85+ if !ok {86+ b = append(b, "null"...)87+ continue88+ }89+ b = append(b, valueOf(c.Value, c.Format)...)90+ }91+ b = append(b, '}')92+ }93+ if r.To.Row > r.From.Row {94+ b = append(b, '\n')95+ }96+ _, err := out.Write(append(b, "]\n"...))97+ return err98+}99+65100 // getCell writes one cell's value or input.66101 func getCell(out io.Writer, s *sheet.Sheet, a sheet.Addr, o GetOptions) error {67102 c := fileio.SnapCell{Value: s.Value(a), Format: s.DisplayFormat(a)}@@ -83,7 +118,7 @@ func getCell(out io.Writer, s *sheet.Sheet, a sheet.Addr, o GetOptions) error {
83118 case "csv", "tsv":84119 return getTable(out, s, a, o)85120 case "json":86- line = nuon.AppendJSON(nil, fileio.CellValue(c))121+ line = valueOf(c.Value, c.Format)87122 case "nuon":88123 line = nuon.Append(nil, fileio.CellValue(c))89124 default:@@ -103,7 +103,7 @@ func TestTables(t *testing.T) {
103103 if got := get(t, w, "Fruit", GetOptions{Format: "nuon"}); got != "[[Item, Price]; [Apple, 1.5],\n[Pear, 2]]\n" {104104 t.Errorf("get Fruit %q", got)105105 }106- if _, err := Set(w, []Entry{{"Fruit[[#Headers],[Price]]", "Cost"}}, SetOptions{}); err != nil {106+ if _, err := Set(w, []Entry{{Ref: "Fruit[[#Headers],[Price]]", Input: "Cost"}}, SetOptions{}); err != nil {107107 t.Fatal(err)108108 }109109 if got := get(t, w, "Fruit[[#Headers],[Cost]]", GetOptions{}); got != "Cost\n" {@@ -134,7 +134,7 @@ func TestGet(t *testing.T) {
134134 {"B4", GetOptions{}, "3.5\n"},135135 {"B3", GetOptions{}, "$2\n"},136136 {"B3", GetOptions{Format: "nuon"}, "2\n"},137- {"B3", GetOptions{Format: "json"}, "2\n"},137+ {"B3", GetOptions{Format: "json"}, "{\"currency\":2,\"decimals\":0}\n"},138138 {"B4", GetOptions{Input: true}, "=SUM(B2:B3)\n"},139139 {"B4", GetOptions{Input: true, Format: "json"}, "\"=SUM(B2:B3)\"\n"},140140 {"A1", GetOptions{Format: "nuon"}, "Item\n"},@@ -145,7 +145,7 @@ func TestGet(t *testing.T) {
145145 {"A1:B3", GetOptions{}, "Item Price\nApple 1.5\nPear $2\n"},146146 {"A1:B3", GetOptions{Format: "csv"}, "Item,Price\nApple,1.5\nPear,$2\n"},147147 {"A1:B3", GetOptions{Format: "nuon"}, "[[Item, Price]; [Apple, 1.5],\n[Pear, 2]]\n"},148- {"A2:B3", GetOptions{Format: "json", NoHeader: true}, "[\n{\"A\":\"Apple\",\"B\":1.5},\n{\"A\":\"Pear\",\"B\":2}\n]\n"},148+ {"A2:B3", GetOptions{Format: "json", NoHeader: true}, "[\n{\"A\":\"Apple\",\"B\":1.5},\n{\"A\":\"Pear\",\"B\":{\"currency\":2,\"decimals\":0}}\n]\n"},149149 {"B1:B4", GetOptions{Format: "json", Input: true}, "[\n{\"Price\":\"1.5\"},\n{\"Price\":\"$2\"},\n{\"Price\":\"=SUM(B2:B3)\"}\n]\n"},150150 } {151151 if got := get(t, w, c.ref, c.o); got != c.want {@@ -163,7 +163,7 @@ func TestGet(t *testing.T) {
163163 164164 func TestSet(t *testing.T) {165165 w := book(t)166- warn, err := Set(w, []Entry{{"B2", "10"}, {"'Q3 plan'!C1", "=B2+1"}, {"A2", ""}}, SetOptions{})166+ warn, err := Set(w, []Entry{{Ref: "B2", Input: "10"}, {Ref: "'Q3 plan'!C1", Input: "=B2+1"}, {Ref: "A2", Input: ""}}, SetOptions{})167167 if err != nil || len(warn) > 0 {168168 t.Fatal(warn, err)169169 }@@ -181,9 +181,9 @@ func TestSet(t *testing.T) {
181181 e Entry182182 want string183183 }{184- {Entry{"B5", "=SUM(B2:B3"}, "Sheet1!B5: Expected , or ) in SUM, at character 11 of =SUM(B2:B3"},185- {Entry{"A1:A2", "x"}, "A1:A2 is the range A1:A2: name one cell"},186- {Entry{"Q9!A1", "x"}, `no sheet named "Q9"`},184+ {Entry{Ref: "B5", Input: "=SUM(B2:B3"}, "Sheet1!B5: Expected , or ) in SUM, at character 11 of =SUM(B2:B3"},185+ {Entry{Ref: "A1:A2", Input: "x"}, "A1:A2 is the range A1:A2: name one cell"},186+ {Entry{Ref: "Q9!A1", Input: "x"}, `no sheet named "Q9"`},187187 } {188188 if _, err := Set(w, []Entry{c.e}, SetOptions{}); err == nil || !strings.Contains(err.Error(), c.want) {189189 t.Errorf("set %v: %v, want %q", c.e, err, c.want)@@ -195,18 +195,18 @@ func TestSetRules(t *testing.T) {
195195 w := book(t)196196 s := w.Sheet(0)197197 s.Protect(sheet.Protection{Range: sheet.NewRect(addr("D1"), addr("D9"))})198- if _, err := Set(w, []Entry{{"D2", "1"}}, SetOptions{}); err == nil || !strings.Contains(err.Error(), "Sheet1!D2 is protected (D1:D9): --force") {198+ if _, err := Set(w, []Entry{{Ref: "D2", Input: "1"}}, SetOptions{}); err == nil || !strings.Contains(err.Error(), "Sheet1!D2 is protected (D1:D9): --force") {199199 t.Errorf("a protected cell: %v", err)200200 }201- if _, err := Set(w, []Entry{{"D2", "1"}}, SetOptions{Force: true}); err != nil {201+ if _, err := Set(w, []Entry{{Ref: "D2", Input: "1"}}, SetOptions{Force: true}); err != nil {202202 t.Errorf("--force: %v", err)203203 }204204 s.AddValidation(sheet.Validation{Ranges: []sheet.Rect{sheet.NewRect(addr("E1"), addr("E9"))}, Kind: sheet.ValidNumber, Op: sheet.RuleBetween, Args: [2]string{"1", "9"}, Reject: true})205205 s.AddValidation(sheet.Validation{Ranges: []sheet.Rect{sheet.NewRect(addr("F1"), addr("F9"))}, Kind: sheet.ValidNumber, Op: sheet.RuleBetween, Args: [2]string{"1", "9"}})206- if _, err := Set(w, []Entry{{"E1", "5"}, {"E2", "50"}}, SetOptions{}); err == nil || !strings.HasPrefix(err.Error(), "Sheet1!E2: ") {206+ if _, err := Set(w, []Entry{{Ref: "E1", Input: "5"}, {Ref: "E2", Input: "50"}}, SetOptions{}); err == nil || !strings.HasPrefix(err.Error(), "Sheet1!E2: ") {207207 t.Errorf("a rejected entry: %v", err)208208 }209- warn, err := Set(w, []Entry{{"F1", "50"}}, SetOptions{})209+ warn, err := Set(w, []Entry{{Ref: "F1", Input: "50"}}, SetOptions{})210210 if err != nil || len(warn) != 1 || !strings.HasPrefix(warn[0], "Sheet1!F1: ") {211211 t.Errorf("an entry marked invalid: %v %v", warn, err)212212 }@@ -222,7 +222,7 @@ func TestOpenSave(t *testing.T) {
222222 if err != nil || !f.New {223223 t.Fatal(f, err)224224 }225- if _, err := Set(f.Book, []Entry{{"A1", "5"}, {"A2", "=A1*2"}}, SetOptions{}); err != nil {225+ if _, err := Set(f.Book, []Entry{{Ref: "A1", Input: "5"}, {Ref: "A2", Input: "=A1*2"}}, SetOptions{}); err != nil {226226 t.Fatal(err)227227 }228228 if err := f.Save(); err != nil {@@ -244,7 +244,7 @@ func TestOpenSave(t *testing.T) {
244244 if st, _ := os.Stat(path); !st.ModTime().Equal(old) {245245 t.Error("saving an unchanged workbook rewrote it")246246 }247- Set(f.Book, []Entry{{"A1", "6"}}, SetOptions{})247+ Set(f.Book, []Entry{{Ref: "A1", Input: "6"}}, SetOptions{})248248 if err := f.Save(); err != nil {249249 t.Fatal(err)250250 }@@ -202,6 +202,8 @@ func feedOutput(w *sheet.Workbook, name string) {
202202 op = sheet.LiveOp{Region: r.Name, Err: err.Error()}203203 break204204 }205+ resolve := func(ref string) (*sheet.Sheet, sheet.Rect, error) { return rangeRef(w, ref) }206+ fileio.KeepFormats(&rows, fileio.OutputFormats(w, o, resolve))205207 op.Header, op.Rows, op.Note = rows.Header, rows.Rows, note206208 }207209 w.ApplyLive(op)@@ -11,7 +11,8 @@ import (
1111 // tool takes, each the same engine call a menu command makes. Op says1212 // which, and the other fields are its arguments:1313 //14-// set Ref (one cell) and Input, as 012 set types it14+// set Ref (one cell) and Input or Value, and Format, as Set takes an Entry15+// write_table Rows under a header at Ref, formatted as WriteTable formats them1516 // clear Ref: the range's contents, keeping formats and notes1617 // insert_rows before Ref's first row, Count rows (Ref's rows when 0)1718 // delete_rows Ref's rows@@ -23,13 +24,18 @@ import (
2324 // define_name Name for the range Ref2425 // sort Ref's rows by Keys, the first row left in place with Header2526 type Operation struct {26- Op string `json:"op" jsonschema:"set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name or sort"`27- Ref string `json:"ref,omitempty" jsonschema:"the cell, range or sheet the operation acts on, as formulas write it: B7, Q3!A1:C9, 'Q3 plan'!A:A, Q3"`28- Input string `json:"input,omitempty" jsonschema:"for set: what to type, in en-US form (1.5, =SUM(A1:A6), 2026-09-29); empty clears the cell"`29- Count int `json:"count,omitempty" jsonschema:"for insert_rows and insert_columns: how many; 0 inserts as many as ref spans"`30- Name string `json:"name,omitempty" jsonschema:"for add_sheet and rename_sheet the sheet's name, for define_name the range's"`31- Keys []SortKey `json:"keys,omitempty" jsonschema:"for sort: the columns to sort by, first first"`32- Header bool `json:"header,omitempty" jsonschema:"for sort: the range's first row is a header and stays in place"`27+ Op string `json:"op" jsonschema:"set, write_table, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name or sort"`28+ Ref string `json:"ref,omitempty" jsonschema:"the cell, range or sheet the operation acts on, as formulas write it: B7, Q3!A1:C9, 'Q3 plan'!A:A, Q3; for write_table its top-left cell"`29+ Input string `json:"input,omitempty" jsonschema:"for set: what to type, as a person types it in en-US form (=SUM(A1:A6), $3.50, 12%, 2026-09-29); empty clears the cell"`30+ Value Value `json:"value,omitempty" jsonschema:"for set, instead of input: the value with its type, as write_cells takes it: {\"currency\": 3.5}, {\"date\": \"2026-09-29\"}, \"00123\""`31+ Format string `json:"format,omitempty" jsonschema:"for set: a number format code for the cell, or alone for every cell of ref: $#,##0.00, 0.0%, yyyy-mm-dd"`32+ Columns []string `json:"columns,omitempty" jsonschema:"for write_table: the columns' names in order"`33+ Rows []Row `json:"rows,omitempty" jsonschema:"for write_table: the rows, each a record of typed values or a list of them in columns' order, as write_table takes them"`34+ Formats map[string]string `json:"formats,omitempty" jsonschema:"for write_table: number format codes by column name"`35+ Count int `json:"count,omitempty" jsonschema:"for insert_rows and insert_columns: how many; 0 inserts as many as ref spans"`36+ Name string `json:"name,omitempty" jsonschema:"for add_sheet and rename_sheet the sheet's name, for define_name the range's"`37+ Keys []SortKey `json:"keys,omitempty" jsonschema:"for sort: the columns to sort by, first first"`38+ Header bool `json:"header,omitempty" jsonschema:"for sort: the range's first row is a header and stays in place"`3339 }3440 3541 // SortKey is a column to sort by: a letter (B) or, with a header, the@@ -61,7 +67,10 @@ func Apply(w *sheet.Workbook, ops []Operation, o SetOptions) (warnings []string,
6167 func applyOne(w *sheet.Workbook, op Operation, o SetOptions) (string, error) {6268 switch op.Op {6369 case "set":64- return setOne(w, Entry{Ref: op.Ref, Input: op.Input}, o)70+ return setOne(w, Entry{Ref: op.Ref, Input: op.Input, Value: op.Value, Format: op.Format}, o)71+ case "write_table":72+ warnings, err := writeTable(w, TableSpec{At: op.Ref, Columns: op.Columns, Rows: op.Rows, Formats: op.Formats}, o)73+ return strings.Join(warnings, "; "), err6574 case "add_sheet":6675 _, err := w.AddSheet(op.Name, w.Len())6776 return "", err@@ -116,7 +125,7 @@ func rangeOp(w *sheet.Workbook, op Operation, o SetOptions) error {
116125 s.DeleteCols(r.From.Col, r.To.Col-r.From.Col+1)117126 return nil118127 }119- return fmt.Errorf("no operation %q: set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name or sort", op.Op)128+ return fmt.Errorf("no operation %q: set, write_table, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name or sort", op.Op)120129 }121130 122131 // targetRect is the target's range: a whole sheet's is A1 to its last@@ -1,10 +1,6 @@
11 package headless22 33 import (4- "encoding/json"5-6- "github.com/FelineStateMachine/012/internal/fileio"7- "github.com/FelineStateMachine/012/internal/nuon"84 "github.com/FelineStateMachine/012/internal/sheet"95 )106 @@ -17,11 +13,11 @@ type Range struct {
1713 Range string `json:"range"`1814 Rows int `json:"rows"`1915 Cols int `json:"cols"`20- // Values are the cells row by row, typed as 012 get --format json21- // types one cell: numbers, strings, true or false, null for blank,22- // dates as ISO 8601 strings, errors as their text (#DIV/0!).23- Values [][]json.RawMessage `json:"values"`24- // Text is each cell as the sheet shows it, when asked for.16+ // Values are the cells row by row, each a Value typed by its format:17+ // numbers, strings, true or false, null for blank, {"currency": 3.5},18+ // {"date": "2026-09-29"} and the rest, errors as their text (#DIV/0!).19+ Values [][]Value `json:"values"`20+ // Text is each cell as the sheet shows it, unless left out.2521 Text [][]string `json:"text,omitempty"`2622 // Formulas are the formulas in the range by cell (B7: =SUM(B1:B6)).2723 Formulas map[string]string `json:"formulas"`@@ -33,7 +29,7 @@ type Range struct {
3329 // ReadOptions tune ReadRange.3430 type ReadOptions struct {3531 MaxCells int // the most cells to return, whole rows at a time; 0 is 10,00036- Text bool // also return each cell as shown32+ NoText bool // leave out each cell as shown3733 }3834 3935 // ReadRange reads t's cells as a grid.@@ -56,30 +52,28 @@ func ReadRange(t Target, o ReadOptions) Range {
5652 rows, out.Truncated = max(o.MaxCells/cols, 1), true5753 }5854 out.Rows = rows59- null := json.RawMessage("null")6055 for row := r.From.Row; row < r.From.Row+rows; row++ {61- vals := make([]json.RawMessage, cols)56+ vals := make([]Value, cols)6257 var text []string63- if o.Text {58+ if !o.NoText {6459 text = make([]string, cols)6560 }6661 for i := range cols {6762 a := sheet.Addr{Col: r.From.Col + i, Row: row}68- vals[i] = null63+ vals[i] = Value("null")6964 if c := s.Cell(a); c != nil && c.IsFormula() {7065 out.Formulas[a.String()] = c.Input7166 }72- v := s.Value(a)73- if v.Kind == sheet.Empty {67+ if s.Value(a).Kind == sheet.Empty {7468 continue7569 }76- vals[i] = nuon.AppendJSON(nil, fileio.CellValue(fileio.SnapCell{Value: v, Format: s.DisplayFormat(a)}))77- if o.Text {70+ vals[i] = CellValue(s, a)71+ if text != nil {7872 text[i] = s.LocalText(a)7973 }8074 }8175 out.Values = append(out.Values, vals)82- if o.Text {76+ if text != nil {8377 out.Text = append(out.Text, text)8478 }8579 }@@ -9,10 +9,14 @@ import (
99 1010 // Entry is one cell to set: a reference to one cell, and what to type1111 // in it, as the file stores entries (numbers and dates in en-US's form,12-// formulas with commas, whatever the workbook's locale); "" clears it.12+// formulas with commas, whatever the workbook's locale), or a typed13+// Value; "" clears it. Format, a number format code, formats the cell,14+// or with neither input nor value every cell of a range.1315 type Entry struct {14- Ref string `json:"ref" jsonschema:"one cell, as formulas write it: B7, Q3!B7, 'Q3 plan'!B7"`15- Input string `json:"input" jsonschema:"what to type, in en-US form: 1.5, =SUM(A1:A6), $1,200, 12%, 2026-09-29; empty clears the cell"`16+ Ref string `json:"ref" jsonschema:"one cell, as formulas write it: B7, Q3!B7, 'Q3 plan'!B7; a range when only format is given"`17+ Input string `json:"input,omitempty" jsonschema:"what to type, as a person types it in en-US form: =SUM(A1:A6), $1,200.50, 12%, 2026-09-29, '00123 for text; empty clears the cell"`18+ Value Value `json:"value,omitempty" jsonschema:"instead of input, the value with its type: 3.5, \"00123\" (text), true, null (clears), {\"currency\": 3.5}, {\"percent\": 0.12}, {\"date\": \"2026-09-29\"}, {\"time\": \"14:30\"}, {\"duration\": \"90min\"}, {\"size\": \"1.5kb\"}, {\"number\": 1234.5, \"decimals\": 2}"`19+ Format string `json:"format,omitempty" jsonschema:"a number format code for the cell, or alone for every cell of ref: $#,##0.00, 0.0%, yyyy-mm-dd, #,##0"`1620 }1721 1822 // SetOptions tune Set.@@ -45,28 +49,80 @@ func Set(w *sheet.Workbook, entries []Entry, o SetOptions) (warnings []string, e
4549 return warnings, err4650 }4751 52+// typedEntry is what e types: its input, or its value's, and the53+// format it sets.54+func typedEntry(e Entry) (typed, error) {55+ t := typed{input: e.Input}56+ if e.Value != nil {57+ if e.Input != "" {58+ return t, errors.New("give input or value, not both")59+ }60+ var err error61+ if t, err = parseValue(e.Value); err != nil {62+ return t, err63+ }64+ }65+ if e.Format != "" {66+ t.format, t.formatted = FormatOfCode(e.Format), true67+ }68+ return t, nil69+}70+4871 func setOne(w *sheet.Workbook, e Entry, o SetOptions) (string, error) {72+ if e.Format != "" && e.Input == "" && e.Value == nil {73+ return "", formatRange(w, e.Ref, FormatOfCode(e.Format), o)74+ }4975 s, a, err := ResolveCell(w, e.Ref)5076 if err != nil {5177 return "", err5278 }53- at := sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a})54- if p, ok := s.Protecting(sheet.Rect{From: a, To: a}); ok && !o.Force {79+ t, err := typedEntry(e)80+ if err != nil {81+ return "", fmt.Errorf("%s: %w", sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a}), err)82+ }83+ return putTyped(s, a, t, o)84+}85+86+// putTyped types t into the cell at a, checked as typing it is.87+func putTyped(s *sheet.Sheet, a sheet.Addr, t typed, o SetOptions) (string, error) {88+ cell := sheet.Rect{From: a, To: a}89+ at := sheet.Qualified(s.Name(), cell)90+ if p, ok := s.Protecting(cell); ok && !o.Force {5591 return "", fmt.Errorf("%s is protected (%s): --force sets it anyway", at, p.Label())5692 }5793 warn := ""58- if bad := s.CheckEntry(a, e.Input); bad != nil {94+ if bad := s.CheckEntry(a, t.input); bad != nil {5995 if bad.Reject {6096 return "", fmt.Errorf("%s: %s", at, bad.Help)6197 }6298 warn = at + ": " + bad.Help6399 }64- if err := s.Set(a, e.Input); err != nil {100+ if t.formatted && s.DisplayFormat(a).Kind == sheet.FmtText {101+ s.SetFormat(cell, t.format) // a number typed into Plain text would stay text102+ }103+ if err := s.Set(a, t.input); err != nil {65104 var pe *sheet.ParseError66105 if errors.As(err, &pe) {67- return "", fmt.Errorf("%s: %s, at character %d of %s", at, pe.Msg, pe.Pos+1, e.Input)106+ return "", fmt.Errorf("%s: %s, at character %d of %s", at, pe.Msg, pe.Pos+1, t.input)68107 }69108 return "", fmt.Errorf("%s: %w", at, err)70109 }110+ if t.formatted {111+ s.SetFormat(cell, t.format)112+ }71113 return warn, nil72114 }115+116+// formatRange gives every cell of the range ref names format f.117+func formatRange(w *sheet.Workbook, ref string, f sheet.Format, o SetOptions) error {118+ t, err := Resolve(w, ref)119+ if err != nil {120+ return err121+ }122+ r := targetRect(t)123+ if p, ok := t.Sheet.Protecting(r); ok && !o.Force {124+ return fmt.Errorf("%s is protected (%s): --force formats it anyway", t, p.Label())125+ }126+ t.Sheet.SetFormat(r, f)127+ return nil128+}@@ -0,0 +1,171 @@
1+package headless2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/012/internal/nuon"8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// TableSpec is a table to write: rows of records, each field a Value,12+// under a header row naming their fields, as importing NUON stores a13+// table. Columns orders them: a JSON object's keys may arrive in14+// another order than written.15+type TableSpec struct {16+ At string `json:"at" jsonschema:"the table's top-left cell, where its header goes: A1, Q3!B2"`17+ Columns []string `json:"columns,omitempty" jsonschema:"the columns' names in order, left to right; rows' other fields follow them. Give it: a record's fields may arrive in any order"`18+ Rows []Row `json:"rows" jsonschema:"the rows, each a record of the columns' values or a list of them in columns' order, typed as a cell's value is: {\"Item\": \"Tea\", \"Price\": {\"currency\": 3.5}, \"Bought\": {\"date\": \"2026-09-29\"}} or [\"Tea\", {\"currency\": 3.5}, {\"date\": \"2026-09-29\"}]"`19+ Formats map[string]string `json:"formats,omitempty" jsonschema:"number format codes by column name, for every cell of the column under the header: {\"Price\": \"$#,##0.00\"}"`20+}21+22+// Row is a table's row, in JSON or NUON: a record of its columns'23+// Values by name, or a list of them in the columns' order.24+type Row json.RawMessage25+26+// MarshalJSON writes r as it is.27+func (r Row) MarshalJSON() ([]byte, error) { return Value(r).MarshalJSON() }28+29+// UnmarshalJSON keeps the JSON as it is.30+func (r *Row) UnmarshalJSON(b []byte) error { return (*Value)(r).UnmarshalJSON(b) }31+32+// WriteTable writes spec's rows under a header naming their columns,33+// in the order they're first seen. Each column takes the format of its34+// first value that has one (currency, a date, a size), or the one35+// Formats gives it, so its plain numbers and blanks show as the rest36+// do; a cell whose own value has another type keeps its own. Each cell37+// is checked as Set checks it.38+func WriteTable(w *sheet.Workbook, spec TableSpec, o SetOptions) (warnings []string, err error) {39+ err = w.Batch(sheet.Change{Label: "write table"}, func() (err error) {40+ warnings, err = writeTable(w, spec, o)41+ return err42+ })43+ return warnings, err44+}45+46+func writeTable(w *sheet.Workbook, spec TableSpec, o SetOptions) (warnings []string, err error) {47+ s, at, err := ResolveCell(w, spec.At)48+ if err != nil {49+ return nil, err50+ }51+ cols, rows, err := tableRows(spec.Rows, spec.Columns)52+ if err != nil {53+ return nil, err54+ }55+ if at.Col+len(cols) > sheet.MaxCols || at.Row+len(rows) >= sheet.MaxRows {56+ return nil, fmt.Errorf("a table of %d rows and %d columns doesn't fit at %s", len(rows), len(cols), spec.At)57+ }58+ formats := columnFormats(cols, rows, spec.Formats)59+ for i, name := range cols {60+ t := typed{input: textInputOf(name)}61+ if warn, err := putTyped(s, sheet.Addr{Col: at.Col + i, Row: at.Row}, t, o); err != nil {62+ return warnings, err63+ } else if warn != "" {64+ warnings = append(warnings, warn)65+ }66+ }67+ for r, row := range rows {68+ for i, t := range row {69+ if f, ok := formats[i]; ok && (!t.formatted || spec.Formats[cols[i]] != "") {70+ t.format, t.formatted = f, true71+ }72+ warn, err := putTyped(s, sheet.Addr{Col: at.Col + i, Row: at.Row + 1 + r}, t, o)73+ if err != nil {74+ return warnings, err75+ }76+ if warn != "" {77+ warnings = append(warnings, warn)78+ }79+ }80+ }81+ return warnings, nil82+}83+84+// tableRows reads rows as the columns they name, columns first and the85+// others in the order first seen, and each row's cells in those86+// columns. A row is a record, or a list of values in columns' order.87+func tableRows(values []Row, columns []string) ([]string, [][]typed, error) {88+ t := tableReader{index: map[string]int{}}89+ for _, c := range columns {90+ t.col(c)91+ }92+ for n, v := range values {93+ val, err := nuon.Parse(v)94+ if err == nil && val.Kind == nuon.List {95+ val, err = t.record(val.List, len(columns))96+ }97+ if err != nil || val.Kind != nuon.Record {98+ return nil, nil, fmt.Errorf("row %d isn't a record of the columns' values, or a list of them in columns' order: %s", n+1, v)99+ }100+ row := make([]typed, len(t.cols))101+ for _, f := range val.Fields {102+ i := t.col(f.Key)103+ c, err := typedOf(f.Value)104+ if err != nil {105+ return nil, nil, fmt.Errorf("row %d, %s: %w", n+1, f.Key, err)106+ }107+ for len(row) <= i {108+ row = append(row, typed{})109+ }110+ row[i] = c111+ }112+ t.rows = append(t.rows, row)113+ }114+ return t.cols, t.rows, nil115+}116+117+// tableReader collects a table's columns and rows.118+type tableReader struct {119+ cols []string120+ index map[string]int121+ rows [][]typed122+}123+124+// col is the column named name, added after the others when new.125+func (t *tableReader) col(name string) int {126+ i, ok := t.index[name]127+ if !ok {128+ i = len(t.cols)129+ t.index[name] = i130+ t.cols = append(t.cols, name)131+ }132+ return i133+}134+135+// record is a list of values as a record of the first n columns.136+func (t *tableReader) record(vs []nuon.Value, n int) (nuon.Value, error) {137+ if len(vs) > n {138+ return nuon.Value{}, fmt.Errorf("%d values for %d columns", len(vs), n)139+ }140+ fs := make([]nuon.Field, len(vs))141+ for i, v := range vs {142+ fs[i] = nuon.Field{Key: t.cols[i], Value: v}143+ }144+ return nuon.RecordValue(fs...), nil145+}146+147+// columnFormats are the formats of the table's columns, by index: the148+// one formats names, or the first a value sets.149+func columnFormats(cols []string, rows [][]typed, formats map[string]string) map[int]sheet.Format {150+ out := map[int]sheet.Format{}151+ for i, name := range cols {152+ if code := formats[name]; code != "" {153+ out[i] = FormatOfCode(code)154+ continue155+ }156+ for _, row := range rows {157+ if i < len(row) && row[i].formatted {158+ out[i] = row[i].format159+ break160+ }161+ }162+ }163+ return out164+}165+166+// textInputOf is a column's name as an entry: text, whatever it looks167+// like.168+func textInputOf(name string) string {169+ t, _ := typedOf(nuon.StringValue(name))170+ return t.input171+}@@ -1,7 +1,6 @@
11 package headless22 33 import (4- "encoding/json"54 "fmt"65 "strings"76 @@ -122,11 +121,11 @@ func Find(w *sheet.Workbook, query string, o FindOptions) (Found, error) {
122121 123122 // Evaluated is a formula's result, computed without keeping it.124123 type Evaluated struct {125- Cell string `json:"cell"` // where it was computed126- Value json.RawMessage `json:"value"` // typed as ReadRange types values127- Text string `json:"text"` // as the cell would show it128- Error string `json:"error"` // why, when Value is an error129- Spill *Range `json:"spill,omitempty"`124+ Cell string `json:"cell"` // where it was computed125+ Value Value `json:"value"` // typed as ReadRange types values126+ Text string `json:"text"` // as the cell would show it127+ Error string `json:"error"` // why, when Value is an error128+ Spill *Range `json:"spill,omitempty"`130129 }131130 132131 // Evaluate computes formula as if typed in the cell at names (a cell@@ -0,0 +1,352 @@
1+package headless2+3+import (4+ "encoding/json"5+ "fmt"6+ "math"7+ "strings"8+ "sync"9+ "time"10+11+ "github.com/FelineStateMachine/012/internal/fileio"12+ "github.com/FelineStateMachine/012/internal/numfmt"13+ "github.com/FelineStateMachine/012/internal/nuon"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// 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.35+type Value json.RawMessage36+37+// MarshalJSON writes v as it is, null when empty.38+func (v Value) MarshalJSON() ([]byte, error) {39+ if len(v) == 0 {40+ return []byte("null"), nil41+ }42+ return v, nil43+}44+45+// UnmarshalJSON keeps the JSON as it is: null is a Value too, one that46+// clears a cell.47+func (v *Value) UnmarshalJSON(b []byte) error {48+ *v = append((*v)[:0], b...)49+ return nil50+}51+52+// typedTypes are the types a Value's object may name.53+var typedTypes = []string{"currency", "percent", "date", "time", "duration", "size", "number", "text"}54+55+// typed is what writing a Value types into a cell: the entry, and the56+// format it sets when formatted.57+type typed struct {58+ input string59+ format sheet.Format60+ formatted bool61+}62+63+// parseValue reads a Value, as JSON or NUON.64+func 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+}71+72+// typedOf is what writing n types.73+func 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+}92+93+// number is n as a float64, if it's a number.94+func 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+}103+104+// record is a Value's object: its type, the value under it, and the105+// other fields.106+type record struct {107+ kind string108+ val nuon.Value109+ symbol string110+ dec *int111+ code string112+}113+114+func 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+}136+137+func oneOf(s string, list []string) bool {138+ for _, x := range list {139+ if s == x {140+ return true141+ }142+ }143+ return false144+}145+146+// typedRecord is what writing a Value's object types.147+func 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+}164+165+func (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+}178+179+// serial is the number r stands for and the format it's shown in.180+func (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+}206+207+// currencyFormat shows an amount in symbol ($ when ""), with dec208+// decimals: 012's Currency for dollars, a pattern for the others.209+func 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+}215+216+func 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+}223+224+// percentDecimals are the decimals typing n as a percentage shows: none225+// for a whole percentage (12%), two otherwise (12.50%).226+func percentDecimals(n float64) int {227+ p := n * 100228+ if math.Abs(p-math.Round(p)) < 1e-9 {229+ return 0230+ }231+ return 2232+}233+234+// clock reads a date, a date and time, or a time of day.235+func (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+}262+263+// isoLayouts are the dates a Value's date takes, without an offset: the264+// time on the sheet's clock.265+var isoLayouts = []string{"2006-01-02T15:04:05.999999999", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999", "2006-01-02 15:04"}266+267+// 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).270+func 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+}285+286+// unit reads a duration or a size: a number (seconds, bytes) or287+// nushell's literal (90min, 1.5kb).288+func (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+}310+311+func 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+}317+318+// 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.321+func 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+}336+337+// presetCodes are 012's formats by their codes.338+var 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+})@@ -0,0 +1,151 @@
1+package headless2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// Each type written as a Value is the cell typing it makes, shown as a11+// person would type it, and reads back as the same Value.12+func TestValuesBothWays(t *testing.T) {13+ for _, c := range []struct {14+ value, shown string15+ format sheet.Format16+ back string // what reads back, when not value17+ }{18+ {value: `3.5`, shown: "3.5"},19+ {value: `"00123"`, shown: "00123"},20+ {value: `"=1+1"`, shown: "=1+1"},21+ {value: `true`, shown: "TRUE"},22+ {value: `{"currency":3.5}`, shown: "$3.50", format: sheet.Format{Kind: sheet.FmtCurrency, Decimals: 2}},23+ {value: `{"currency":-1200,"decimals":0}`, shown: "-$1,200", format: sheet.Format{Kind: sheet.FmtCurrency}},24+ {value: `{"currency":3.5,"symbol":"€"}`, shown: "€3.50", format: sheet.Format{Kind: sheet.FmtCustom, Pattern: `"€"#,##0.00`}},25+ {value: `{"percent":0.12}`, shown: "12%", format: sheet.Format{Kind: sheet.FmtPercent}},26+ {value: `{"percent":0.125}`, shown: "12.50%", format: sheet.Format{Kind: sheet.FmtPercent, Decimals: 2}},27+ {value: `{"date":"2026-09-29"}`, shown: "9/29/2026", format: sheet.Format{Kind: sheet.FmtDate}},28+ {value: `{"date":"2026-09-29T14:30:00"}`, shown: "9/29/2026 14:30:00", format: sheet.Format{Kind: sheet.FmtDateTime}},29+ {value: `{"date":"2026-09-29","format":"yyyy-mm-dd"}`, shown: "2026-09-29", format: sheet.Format{Kind: sheet.FmtDate, Pattern: "yyyy-mm-dd"}},30+ {value: `{"time":"14:30"}`, shown: "2:30:00 PM", format: sheet.Format{Kind: sheet.FmtTime}, back: `{"time":"14:30:00"}`},31+ {value: `{"duration":"90min"}`, shown: "1:30:00", format: sheet.Format{Kind: sheet.FmtDuration}},32+ {value: `{"duration":5400}`, shown: "1:30:00", format: sheet.Format{Kind: sheet.FmtDuration}, back: `{"duration":"90min"}`},33+ {value: `{"size":1500}`, shown: "1.5 kB", format: sheet.Format{Kind: sheet.FmtSize, Decimals: 1}},34+ {value: `{"size":"1.5kb"}`, shown: "1.5 kB", format: sheet.Format{Kind: sheet.FmtSize, Decimals: 1}, back: `{"size":1500}`},35+ {value: `{"number":1234.5,"decimals":2}`, shown: "1,234.50", format: sheet.Format{Kind: sheet.FmtNumber, Decimals: 2}},36+ {value: `{"number":0.5,"format":"0.000"}`, shown: "0.500", format: sheet.Format{Kind: sheet.FmtCustom, Pattern: "0.000"}},37+ {value: `{"text":"12%"}`, shown: "12%", back: `"12%"`},38+ } {39+ w := sheet.NewBook()40+ s := w.Sheet(0)41+ if _, err := Set(w, []Entry{{Ref: "B2", Value: Value(c.value)}}, SetOptions{}); err != nil {42+ t.Errorf("%s: %v", c.value, err)43+ continue44+ }45+ a := addr("B2")46+ if got := s.LocalText(a); got != c.shown {47+ t.Errorf("%s shows %q, want %q", c.value, got, c.shown)48+ }49+ if got := s.DisplayFormat(a); got != c.format {50+ t.Errorf("%s is formatted %+v, want %+v", c.value, got, c.format)51+ }52+ back := c.back53+ if back == "" {54+ back = c.value55+ }56+ if got := string(CellValue(s, a)); got != back {57+ t.Errorf("%s reads back as %s, want %s", c.value, got, back)58+ }59+ }60+}61+62+// Cells typed in the grid read as Values of their types.63+func TestTypedEntriesRead(t *testing.T) {64+ w := sheet.NewBook()65+ s := w.Sheet(0)66+ for in, want := range map[string]string{67+ "$3.50": `{"currency":3.5}`,68+ "12%": `{"percent":0.12}`,69+ "2026-09-29": `{"date":"2026-09-29","format":"yyyy-mm-dd"}`,70+ "1,234": `{"number":1234,"decimals":0}`,71+ "'00123": `"00123"`,72+ "25:30:00": `{"duration":"1530min"}`,73+ } {74+ if _, err := Set(w, []Entry{{Ref: "A1", Input: in}}, SetOptions{}); err != nil {75+ t.Fatal(err)76+ }77+ if got := string(CellValue(s, addr("A1"))); got != want {78+ t.Errorf("%s reads as %s, want %s", in, got, want)79+ }80+ }81+}82+83+func TestValueErrors(t *testing.T) {84+ for value, want := range map[string]string{85+ `{"price":3}`: `"price" isn't a field of a value`,86+ `{"currency":"3"}`: `currency: "3" is a number`,87+ `{"date":"29/9/2026"}`: `date: "29/9/2026" isn't a date`,88+ `{"size":"3 apples"}`: `size: "3 apples" isn't nushell's size`,89+ `{"duration":"1kb"}`: `duration: 1000b isn't a duration`,90+ `[1, 2]`: `isn't a cell's value`,91+ `{"decimals":2}`: `names its type`,92+ `{"currency":1,"percent":0.5}`: `"percent" isn't a field`,93+ } {94+ _, err := Set(sheet.NewBook(), []Entry{{Ref: "A1", Value: Value(value)}}, SetOptions{})95+ if err == nil || !strings.Contains(err.Error(), want) {96+ t.Errorf("%s: %v, want %q", value, err, want)97+ }98+ }99+ if _, err := Set(sheet.NewBook(), []Entry{{Ref: "A1", Input: "1", Value: Value("1")}}, SetOptions{}); err == nil {100+ t.Error("input and value both taken")101+ }102+}103+104+// A format alone formats a range; with an input it formats the cell.105+func TestEntryFormats(t *testing.T) {106+ w := sheet.NewBook()107+ s := w.Sheet(0)108+ _, err := Set(w, []Entry{{Ref: "A1", Input: "3.5"}, {Ref: "A2", Input: "4"}, {Ref: "A1:A2", Format: `"$"#,##0.00`}, {Ref: "B1", Input: "0.25", Format: "0.0%"}}, SetOptions{})109+ if err != nil {110+ t.Fatal(err)111+ }112+ for a, want := range map[string]string{"A1": "$3.50", "A2": "$4.00", "B1": "25.0%"} {113+ if got := s.LocalText(addr(a)); got != want {114+ t.Errorf("%s shows %q, want %q", a, got, want)115+ }116+ }117+}118+119+// A table written as records gets a header, typed cells and column120+// formats, blanks and plain numbers taking their column's.121+func TestWriteTable(t *testing.T) {122+ w := sheet.NewBook()123+ s := w.Sheet(0)124+ rows := []Row{125+ Row(`{"Item": "Tea", "Price": {"currency": 3.5}, "Bought": {"date": "2026-09-29"}, "Code": "00123"}`),126+ Row(`{"Item": "Cake", "Price": 3, "Code": "7", "Share": 0.5}`),127+ Row(`{Item: Jam, Price: null, Bought: 2026-09-28T10:00:00Z, Size: 1.5kb}`),128+ }129+ warn, err := WriteTable(w, TableSpec{At: "B2", Rows: rows, Formats: map[string]string{"Share": "0%"}}, SetOptions{})130+ if err != nil || len(warn) != 0 {131+ t.Fatal(err, warn)132+ }133+ for a, want := range map[string]string{"B2": "Item", "C2": "Price", "G2": "Size", "C3": "$3.50", "C4": "$3.00", "D3": "9/29/2026",134+ "E3": "00123", "E4": "7", "F4": "50%", "G5": "1.5 kB"} {135+ if got := s.LocalText(addr(a)); got != want {136+ t.Errorf("%s shows %q, want %q", a, got, want)137+ }138+ }139+ if f := s.DisplayFormat(addr("C5")); f.Kind != sheet.FmtCurrency {140+ t.Errorf("the blank price is formatted %+v", f)141+ }142+ if f := s.DisplayFormat(addr("D5")); f.Kind != sheet.FmtDateTime {143+ t.Errorf("a date and time in a column of dates is formatted %+v", f)144+ }145+ if v := s.Value(addr("E4")); v.Kind != sheet.Text {146+ t.Errorf("text that looks like a number is %+v", v)147+ }148+ if _, err := WriteTable(w, TableSpec{At: "A1", Rows: []Row{Row(`[1]`)}}, SetOptions{}); err == nil || !strings.Contains(err.Error(), "row 1 isn't a record") {149+ t.Errorf("a row that isn't a record: %v", err)150+ }151+}@@ -0,0 +1,177 @@
1+package headless2+3+import (4+ "math"5+ "regexp"6+ "strconv"7+ "time"8+9+ "github.com/FelineStateMachine/012/internal/fileio"10+ "github.com/FelineStateMachine/012/internal/nuon"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Reading a cell as a Value: its type from its format, so what reads15+// it back writes the same value and format. A number in a format its16+// type doesn't show by itself carries decimals or the format's code.17+18+// CellValue is the value of the cell at a, typed by the format it's19+// shown in.20+func CellValue(s *sheet.Sheet, a sheet.Addr) Value {21+ return valueOf(s.Value(a), s.DisplayFormat(a))22+}23+24+// valueOf is v, shown in f, as a Value.25+func valueOf(v sheet.Value, f sheet.Format) Value {26+ switch v.Kind {27+ case sheet.Empty:28+ return Value("null")29+ case sheet.Bool:30+ return Value(strconv.FormatBool(v.Num != 0))31+ case sheet.Text:32+ return Value(nuon.AppendJSON(nil, nuon.StringValue(v.Str)))33+ case sheet.Number:34+ return numberOf(v.Num, f)35+ }36+ return Value(nuon.AppendJSON(nil, nuon.StringValue(v.String()))) // an error, as it shows37+}38+39+// plainJSON is n as a JSON number: an int when whole.40+func plainJSON(n float64) nuon.Value {41+ if n == math.Trunc(n) && math.Abs(n) < 1<<53 {42+ return nuon.IntValue(int64(n))43+ }44+ return nuon.FloatValue(n)45+}46+47+// numberOf is n shown in f: a plain number in the Automatic format, an48+// object naming its type otherwise.49+func numberOf(n float64, f sheet.Format) Value {50+ kind, val := typeOf(n, f)51+ if kind == "" {52+ return Value(nuon.AppendJSON(nil, plainJSON(n)))53+ }54+ fs := []nuon.Field{{Key: kind, Value: val}}55+ if kind == "currency" {56+ if sym := currencySymbol(f); sym != "$" {57+ fs = append(fs, nuon.Field{Key: "symbol", Value: nuon.StringValue(sym)})58+ }59+ }60+ fs = append(fs, formatFields(fs, f)...)61+ return Value(nuon.AppendJSON(nil, nuon.RecordValue(fs...)))62+}63+64+// formatFields are the fields a Value of fields needs to be shown in f:65+// none when its type shows it so, decimals when they're all it takes,66+// and the format's code otherwise.67+func formatFields(fs []nuon.Field, f sheet.Format) []nuon.Field {68+ tries := [][]nuon.Field{nil}69+ if f.Kind.HasDecimals() || currencySymbol(f) != "" {70+ tries = append(tries, []nuon.Field{{Key: "decimals", Value: nuon.IntValue(int64(decimalsOf(f)))}})71+ }72+ for _, extra := range tries {73+ if t, err := typedRecord(append(fs[:len(fs):len(fs)], extra...)); err == nil && t.format == f {74+ return extra75+ }76+ }77+ return []nuon.Field{{Key: "format", Value: nuon.StringValue(f.Code())}}78+}79+80+// decimalsOf are the decimals f shows: its own, or a currency81+// pattern's.82+func decimalsOf(f sheet.Format) int {83+ if m := currencyPatternRE.FindStringSubmatch(f.Pattern); f.Kind == sheet.FmtCustom && m != nil {84+ return max(len(m[3])-1, 0)85+ }86+ return f.Decimals87+}88+89+// typeOf is the type a number shown in f is written as, and its value:90+// "" for a plain number.91+func typeOf(n float64, f sheet.Format) (string, nuon.Value) {92+ kind := f.Kind93+ if kind == sheet.FmtCustom {94+ if currencySymbol(f) != "" {95+ return "currency", plainJSON(n)96+ }97+ kind = fileio.CodeKind(f.Pattern)98+ }99+ switch kind {100+ case sheet.FmtAuto, sheet.FmtText:101+ return "", nuon.Value{}102+ case sheet.FmtCurrency, sheet.FmtAccounting:103+ return "currency", plainJSON(n)104+ case sheet.FmtPercent:105+ return "percent", plainJSON(n)106+ case sheet.FmtDate, sheet.FmtDateTime:107+ return "date", nuon.StringValue(isoDate(n))108+ case sheet.FmtTime:109+ if n >= 0 && n < 1 {110+ return "time", nuon.StringValue(timeOfDay(n))111+ }112+ return "date", nuon.StringValue(isoDate(n))113+ case sheet.FmtDuration:114+ return "duration", nuon.StringValue(durationLiteral(time.Duration(math.Round(n * 86400e9))))115+ case sheet.FmtSize:116+ return "size", plainJSON(n)117+ }118+ return "number", plainJSON(n)119+}120+121+// currencyPatternRE is a custom format showing a currency: a symbol,122+// quoted as currencyPattern writes it or one of the common ones bare,123+// before a grouped amount.124+var currencyPatternRE = regexp.MustCompile(`^(?:"([^"]+)"|([€£¥₹]))#,##0(\.0+)?$`)125+126+// currencySymbol is the symbol a currency format shows: $ for 012's127+// Currency and Accounting, the quoted symbol of a pattern such as128+// "€"#,##0.00, "" for other formats.129+func currencySymbol(f sheet.Format) string {130+ switch f.Kind {131+ case sheet.FmtCurrency, sheet.FmtAccounting:132+ return "$"133+ case sheet.FmtCustom:134+ if m := currencyPatternRE.FindStringSubmatch(f.Pattern); m != nil {135+ return m[1] + m[2]136+ }137+ }138+ return ""139+}140+141+// isoDate is a date serial in ISO 8601, on the sheet's clock: the day142+// alone when it's whole, else with the time, to the microsecond.143+func isoDate(n float64) string {144+ days := math.Floor(n)145+ us := math.Round((n - days) * 86400e6)146+ if math.Abs(days) > 1e7 {147+ days = math.Copysign(1e7, days)148+ }149+ t := time.Date(1899, 12, 30+int(days), 0, 0, 0, int(us)*1e3, time.UTC)150+ if us == 0 && n == days {151+ return t.Format("2006-01-02")152+ }153+ return t.Format("2006-01-02T15:04:05.999999")154+}155+156+// timeOfDay is a fraction of a day as a time on the clock: 14:30:00.157+func timeOfDay(n float64) string {158+ us := math.Round(n * 86400e6)159+ return time.Date(2000, 1, 1, 0, 0, 0, int(us)*1e3, time.UTC).Format("15:04:05.999999")160+}161+162+// durationUnits are nushell's units of time, largest first.163+var durationUnits = []struct {164+ name string165+ d time.Duration166+}{{"day", 24 * time.Hour}, {"hr", time.Hour}, {"min", time.Minute}, {"sec", time.Second}, {"ms", time.Millisecond}, {"us", time.Microsecond}}167+168+// durationLiteral is d as nushell writes a duration literal, in the169+// largest unit that holds it whole: 90min, 1day, 1500ms.170+func durationLiteral(d time.Duration) string {171+ for _, u := range durationUnits {172+ if d%u.d == 0 {173+ return strconv.FormatInt(int64(d/u.d), 10) + u.name174+ }175+ }176+ return strconv.FormatInt(int64(d), 10) + "ns"177+}@@ -2,6 +2,7 @@ package mcp
22 33 import (44 "context"5+ "errors"56 "fmt"67 "os"78 @@ -13,17 +14,20 @@ import (
1314 )1415 1516 // create_workbook makes a new .012 file in a folder open to the server:16-// empty, or from a file 012 imports (which the other tools only read),17-// or a copy of another workbook. It never replaces a file.17+// empty, from a file 012 imports (which the other tools only read), a18+// copy of another workbook, or holding a table of typed values. It19+// never replaces a file.1820 1921 type createIn struct {20- Path string `json:"path" jsonschema:"the new workbook's file, ending in .012; relative to the folder open to the server, or absolute inside it"`21- From string `json:"from,omitempty" jsonschema:"a file to make it from: one 012 imports (CSV, XLSX, JSON, SQLite...) or another workbook; empty for an empty workbook"`22+ Path string `json:"path" jsonschema:"the new workbook's file, ending in .012; relative to the folder open to the server, or absolute inside it"`23+ From string `json:"from,omitempty" jsonschema:"a file to make it from: one 012 imports (CSV, XLSX, JSON, SQLite...) or another workbook; empty for an empty workbook"`24+ Data []headless.Row `json:"data,omitempty" jsonschema:"instead of from: a table for its first sheet, rows written at A1 with their types and column formats, as write_table writes them"`25+ Columns []string `json:"columns,omitempty" jsonschema:"with data: the columns' names in order, as write_table takes them"`2226 }2327 2428 func (s *Server) addCreateTool() {2529 tool(s, &sdk.Tool{Name: "create_workbook", Annotations: writes,26- Description: "Make a new .012 workbook, empty or from a file 012 imports (CSV, XLSX, JSON, SQLite...), which can then be changed; it describes the workbook made. It won't replace a file that exists."},30+ Description: "Make a new .012 workbook, empty, from a file 012 imports (CSV, XLSX, JSON, SQLite...), or with data, a table of typed values as write_table writes it; it can then be changed, and it describes the workbook made. It won't replace a file that exists."},2731 s.createWorkbook)2832 }2933 @@ -43,10 +47,18 @@ func (s *Server) createWorkbook(ctx context.Context, req *sdk.CallToolRequest, i
4347 if err != nil {4448 return nil, describeOut{}, err4549 }46- if in.From != "" {50+ switch {51+ case in.From != "" && in.Data != nil:52+ return nil, describeOut{}, errors.New("give from or data, not both: write_table adds data to a workbook made from a file")53+ case in.From != "":4754 if f.Book, err = s.source(ctx, roots, in.From); err != nil {4855 return nil, describeOut{}, err4956 }57+ case in.Data != nil:58+ spec := headless.TableSpec{At: sheet.Qualified(f.Book.Sheet(0).Name(), sheet.Rect{}), Columns: in.Columns, Rows: in.Data}59+ if _, err := headless.WriteTable(f.Book, spec, headless.SetOptions{}); err != nil {60+ return nil, describeOut{}, err61+ }5062 }5163 save := f.Save5264 if s.opts.Save != nil {@@ -29,7 +29,7 @@ type describeOut struct {
2929 type readIn struct {3030 bookIn3131 Ref string `json:"ref" jsonschema:"a cell, range, named range, table or sheet, as formulas write it: Q3!A1:D40, Sales, Sales[Amount], Q3"`32- Text bool `json:"text,omitempty" jsonschema:"also return each cell as the sheet shows it ($1,200.00, 9/29/2026)"`32+ Text *bool `json:"text,omitempty" jsonschema:"false leaves out each cell as the sheet shows it ($1,200.00, 9/29/2026), which comes back beside the values otherwise"`3333 Max int `json:"max_cells,omitempty" jsonschema:"the most cells to return, whole rows at a time; 10000 when 0"`3434 }3535 @@ -66,7 +66,7 @@ func (s *Server) addReadTools() {
6666 Description: "Without a path: the workbooks (.012 files, and files 012 imports) in the folders open to the server. With one: what the workbook holds, each sheet's used range, guessed header row and column names, tables, notebook outputs and linked files, charts, pivot tables, notebook cells, and the named ranges. Call it first."},6767 s.describe)6868 tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly, Meta: viewMeta("Reading the range", "Read the range"),69- Description: "The values of a range as rows (numbers, strings, booleans, null for blank, dates as ISO 8601, errors as #DIV/0!), with the formulas in it by cell. Hosts that draw views show it as 012's grid."},69+ Description: "The values of a range as rows, typed by their formats (numbers, strings, booleans, null for blank, {\"currency\": 3.5}, {\"percent\": 0.12}, {\"date\": \"2026-09-29\"}, {\"duration\": \"90min\"}, {\"size\": 1500}, errors as #DIV/0!), each cell as the sheet shows it ($3.50, 12%, 9/29/2026), and the formulas in it by cell. Hosts that draw views show it as 012's grid."},7070 s.readRange)7171 tool(s, &sdk.Tool{Name: "evaluate", Annotations: readOnly,7272 Description: "Compute a formula in the workbook without writing it: its value, as the cell would show it, why it's an error, and an array's spilled values."},@@ -135,7 +135,7 @@ func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in rea
135135 if err != nil {136136 return err137137 }138- out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})138+ out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, NoText: in.Text != nil && !*in.Text})139139 view = rangeView(b, t, out.Rows)140140 return nil141141 })@@ -9,8 +9,6 @@ package mcp
99 1010 import (1111 "context"12- "encoding/json"13- "reflect"1412 "slices"1513 "sync"1614 @@ -101,24 +99,19 @@ func orDevel(v string) string {
10199 102100 // instructions tell the model how to use the server.103101 const instructions = `This server works on 012 spreadsheet workbooks (.012 files) in the folders open to it, and reads the files 012 imports (CSV, XLSX, JSON, SQLite and more).104-Every tool takes the workbook's path. Call describe without one to list the workbooks; describe with a path lists its sheets, their used ranges, guessed header rows and column names, tables, named ranges, charts and notebooks. create_workbook makes a new workbook, empty or from a file 012 imports.102+Every tool takes the workbook's path. Call describe without one to list the workbooks; describe with a path lists its sheets, their used ranges, guessed header rows and column names, tables, named ranges, charts and notebooks. create_workbook makes a new workbook, empty, from a file 012 imports, or holding a table of data.105103 References are written as in formulas: B7, A1:C9, Q3!B7, 'Q3 plan'!A1:C9, a named range, a table (Sales, Sales[Amount]) or a sheet name for the whole sheet; without a sheet name, the sheet shown when the file was saved.106104 Inputs are what a person types, in en-US form: 1.5, =SUM(A1:A6), $1,200, 12%, 2026-09-29. Formulas are Google Sheets'.105+Keep values' meaning: write money, percentages, dates, times, durations and sizes with their types, never as bare numbers, either typed as a person types them ($3.50, 12%) or as values with their types ({"currency": 3.5}, {"percent": 0.12}, {"date": "2026-09-29"}, {"duration": "90min"}, {"size": "1.5kb"}); write rows of records with write_table, which formats each column from its values. Reads return values the same way, with each cell as shown.107106 Writes are checked as typing is (formulas must parse, validation rules, protected ranges) and each call is one change, saved at once; pass dry_run to see the change without making it. Call evaluate to try a formula without writing it.`108107 109-// rawSchemas infer json.RawMessage, a value already in JSON, as any110-// value.111-var rawSchemas = &jsonschema.ForOptions{TypeSchemas: map[reflect.Type]*jsonschema.Schema{112- reflect.TypeFor[json.RawMessage](): {},113-}}114-115108 // tool adds a tool whose handler takes In and returns Out, with the116-// output schema inferred as rawSchemas says. Its path is required109+// schemas inferred as rawSchemas says. Its path is required117110 // unless the server has a default workbook, or the tool is describe,118111 // which lists the workbooks without one.119112 func tool[In, Out any](s *Server, t *sdk.Tool, h func(context.Context, *sdk.CallToolRequest, In) (*sdk.CallToolResult, Out, error)) {120113 if t.InputSchema == nil {121- schema, err := jsonschema.For[In](nil)114+ schema, err := jsonschema.For[In](rawSchemas)122115 if err != nil {123116 panic(err)124117 }@@ -0,0 +1,51 @@
1+package mcp2+3+import (4+ "encoding/json"5+ "reflect"6+7+ "github.com/google/jsonschema-go/jsonschema"8+9+ "github.com/FelineStateMachine/012/internal/headless"10+)11+12+// The schema of a cell's value (headless.Value), which the tools that13+// write take and the tools that read return, so a model sees in the14+// tools' schemas how to write money, percentages, dates, times,15+// durations and sizes, and what it reads back.16+17+// valueDescription says what a value is.18+const valueDescription = `A cell's value with its type: a number, a string (always text, even "00123"), true, false, null (blank), ` +19+ `or an object naming its type: {"currency": 3.5} ($3.50; "symbol": "€" for another currency), {"percent": 0.12} (12%), ` +20+ `{"date": "2026-09-29"} or {"date": "2026-09-29T14:30:00"}, {"time": "14:30"}, {"duration": "90min"} (nushell's units, or seconds), ` +21+ `{"size": 1500} (bytes, or "1.5kb"), {"number": 1234.5}, {"text": "00123"}. ` +22+ `Any object may add "decimals" (2) or "format", a number format code ("#,##0.00", "yyyy-mm-dd").`23+24+// valueSchema is headless.Value's schema.25+func valueSchema() *jsonschema.Schema {26+ str := &jsonschema.Schema{Type: "string"}27+ num := &jsonschema.Schema{Type: "number"}28+ numOrStr := &jsonschema.Schema{Types: []string{"number", "string"}}29+ typed := func(key string, v *jsonschema.Schema) *jsonschema.Schema {30+ return &jsonschema.Schema{Type: "object", Required: []string{key}, Properties: map[string]*jsonschema.Schema{31+ key: v, "decimals": {Type: "integer", Minimum: ptr(0.0), Maximum: ptr(15.0)}, "format": str, "symbol": str,32+ }}33+ }34+ return &jsonschema.Schema{Description: valueDescription, AnyOf: []*jsonschema.Schema{35+ {Types: []string{"number", "string", "boolean", "null"}},36+ typed("currency", num), typed("percent", num), typed("date", str), typed("time", str),37+ typed("duration", numOrStr), typed("size", numOrStr), typed("number", num), typed("text", str),38+ }}39+}40+41+// schemaTypes infer json.RawMessage, a value already in JSON, as any42+// value, and a cell's value as valueSchema says.43+var schemaTypes = map[reflect.Type]*jsonschema.Schema{44+ reflect.TypeFor[json.RawMessage](): {},45+ reflect.TypeFor[headless.Value](): valueSchema(),46+ reflect.TypeFor[headless.Row](): {Description: "A row: its columns' values by name, or a list of them in columns' order, each a cell's value with its type",47+ AnyOf: []*jsonschema.Schema{{Type: "object", AdditionalProperties: valueSchema()}, {Type: "array", Items: valueSchema()}}},48+}49+50+// rawSchemas are the options inferring the tools' schemas.51+var rawSchemas = &jsonschema.ForOptions{TypeSchemas: schemaTypes}@@ -0,0 +1,113 @@
1+package mcp2+3+import (4+ "encoding/json"5+ "os/exec"6+ "path/filepath"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/012/internal/headless"12+ "github.com/FelineStateMachine/012/internal/notebook"13+ "github.com/FelineStateMachine/012/internal/nushell"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// groceries are rows of every type, as a model writes them, in records18+// and as a list in columns' order.19+var groceries = []json.RawMessage{20+ json.RawMessage(`{"Item": "Tea", "Price": {"currency": 3.5}, "Share": {"percent": 0.12}, "Bought": {"date": "2026-09-29"}, "Steep": {"duration": "4min"}, "Size": {"size": 1500}, "Code": "00123"}`),21+ json.RawMessage(`["Cake", {"currency": 2}, {"percent": 0.5}, {"date": "2026-09-28"}, {"duration": "90sec"}, {"size": "250kb"}, "7"]`),22+}23+24+// columns order the table: the SDK hands a tool a record's fields in25+// another order than written.26+var columns = []string{"Item", "Price", "Share", "Bought", "Steep", "Size", "Code"}27+28+// wantValues are groceries as read_range reads them back, header first.29+const wantValues = `[["Item","Price","Share","Bought","Steep","Size","Code"],` +30+ `["Tea",{"currency":3.5},{"percent":0.12},{"date":"2026-09-29"},{"duration":"4min"},{"size":1500},"00123"],` +31+ `["Cake",{"currency":2},{"percent":0.5},{"date":"2026-09-28"},{"duration":"90sec"},{"size":250000},"7"]]`32+33+// Values written with their types read back as the same values, shown34+// as their formats show them, and keep their formats in the file.35+func TestTypedValuesRoundTrip(t *testing.T) {36+ dir := t.TempDir()37+ path := filepath.Join(dir, "groceries.012")38+ cs := connect(t, Options{Roots: rootsFor(t, dir)})39+ if res := call(t, cs, "create_workbook", map[string]any{"path": path, "columns": columns, "data": groceries}, nil); res.IsError {40+ t.Fatal(errorText(res))41+ }42+ var r headless.Range43+ call(t, cs, "read_range", map[string]any{"path": path, "ref": "A1:G3"}, &r)44+ if got, _ := json.Marshal(r.Values); string(got) != wantValues {45+ t.Errorf("values:\n%s\nwant\n%s", got, wantValues)46+ }47+ if got := strings.Join(r.Text[1], "|"); got != "Tea|$3.50|12%|9/29/2026|0:04:00|1.5 kB|00123" {48+ t.Errorf("text: %s", got)49+ }50+ var changed Changed51+ call(t, cs, "write_cells", map[string]any{"path": path, "entries": []map[string]any{52+ {"ref": "B4", "value": map[string]any{"currency": 4.25, "symbol": "€"}},53+ {"ref": "D4", "input": "2026-10-01"},54+ {"ref": "A4", "value": "0042"},55+ {"ref": "B2:B3", "format": "$#,##0"},56+ }}, &changed)57+ if !changed.Saved {58+ t.Fatalf("write_cells: %+v", changed)59+ }60+ call(t, cs, "read_range", map[string]any{"path": path, "ref": "A2:D4"}, &r)61+ want := `[["Tea",{"currency":3.5,"decimals":0},{"percent":0.12},{"date":"2026-09-29"}],` +62+ `["Cake",{"currency":2,"decimals":0},{"percent":0.5},{"date":"2026-09-28"}],` +63+ `["0042",{"currency":4.25,"symbol":"€"},null,{"date":"2026-10-01","format":"yyyy-mm-dd"}]]`64+ if got, _ := json.Marshal(r.Values); string(got) != want {65+ t.Errorf("after write_cells:\n%s\nwant\n%s", got, want)66+ }67+ f, err := headless.Open(path, false)68+ if err != nil {69+ t.Fatal(err)70+ }71+ s := f.Book.Sheet(0)72+ for a, k := range map[string]sheet.FormatKind{"B3": sheet.FmtCurrency, "C2": sheet.FmtPercent, "D2": sheet.FmtDate, "E2": sheet.FmtDuration, "F2": sheet.FmtSize} {73+ at, _ := sheet.ParseAddr(a)74+ if got := s.DisplayFormat(at).Kind; got != k {75+ t.Errorf("%s is formatted %v in the file, want %v", a, got, k)76+ }77+ }78+}79+80+// Values written through MCP go to nushell with their types as a81+// notebook cell reads them ($sheet), and its output sent back to a82+// sheet keeps the formats: currency, percentages, dates, durations and83+// sizes.84+func TestTypedValuesThroughNotebook(t *testing.T) {85+ if _, err := exec.LookPath("nu"); err != nil {86+ t.Skip("nu isn't installed")87+ }88+ w := sheet.NewBook()89+ out, _ := w.AddSheet("Out", 1)90+ nb, _ := w.AddNotebook("Notes", out)91+ nb.SetNotebookCells("add cell", []notebook.Cell{{Kind: notebook.Code,92+ Source: "dear = $sheet.Sheet1!A1:G3 | where Price > 3 | insert Kind {|r| $r.Size | describe} | insert Took {|r| $r.Steep | describe}"}})93+ if err := out.AddRegion(sheet.Region{Name: "dear", At: sheet.Addr{}, Output: true}); err != nil {94+ t.Fatal(err)95+ }96+ w.SetActive(w.Sheet(0))97+ path := filepath.Join(t.TempDir(), "nb.012")98+ writeBook(t, w, path)99+ cs := connect(t, Options{Default: path, Notebooks: &headless.NotebookOptions{Runner: nushell.Nu{}, Timeout: 30 * time.Second}})100+ call(t, cs, "write_table", map[string]any{"at": "Sheet1!A1", "columns": columns, "rows": groceries}, nil)101+ var run headless.CellRun102+ call(t, cs, "run_notebook_cell", map[string]any{"notebook": "Notes", "cell": 1}, &run)103+ if run.State != "ran" {104+ t.Fatalf("run: %+v", run)105+ }106+ var r headless.Range107+ call(t, cs, "read_range", map[string]any{"ref": "Out!A1:I2"}, &r)108+ want := `[["Item","Price","Share","Bought","Steep","Size","Code","Kind","Took"],` +109+ `["Tea",{"currency":3.5},{"percent":0.12},{"date":"2026-09-29"},{"duration":"4min"},{"size":1500},"00123","filesize","duration"]]`110+ if got, _ := json.Marshal(r.Values); string(got) != want {111+ t.Errorf("the output sent to Out:\n%s\nwant\n%s\n%s", got, want, run.Output)112+ }113+}@@ -104,7 +104,8 @@ func TestViewsForEveryClient(t *testing.T) {
104104 }105105 106106 // TestViewLeavesTheModelSmall checks what the model reads of a 60-row107-// read stays a few kilobytes while the view gets the grid.107+// read, typed values and the text shown, stays about ten kilobytes108+// while the view gets the grid.108109 func TestViewLeavesTheModelSmall(t *testing.T) {109110 w := sheet.NewBook()110111 s := w.Sheet(0)@@ -118,7 +119,7 @@ func TestViewLeavesTheModelSmall(t *testing.T) {
118119 writeBook(t, w, path)119120 got := call(t, connect(t, Options{Default: path}), "read_range", map[string]any{"ref": "A1:D60"}, nil)120121 structured, _ := json.Marshal(got.StructuredContent)121- if len(structured) > 5000 || strings.Contains(string(structured), "<") {122+ if len(structured) > 12000 || strings.Contains(string(structured), "<") {122123 t.Errorf("structuredContent of 60 rows: %d bytes", len(structured))123124 }124125 if v := resultView(t, got); strings.Count(v.HTML, "<tr") != 61 {@@ -21,10 +21,17 @@ type whyIn struct {
2121 type writeIn struct {2222 bookIn2323 whyIn24- Entries []headless.Entry `json:"entries" jsonschema:"the cells to set, in order: each a ref (one cell) and its input, typed as a person types it in en-US form; an empty input clears the cell"`24+ Entries []headless.Entry `json:"entries" jsonschema:"the cells to set, in order: each a ref (one cell) and its input as a person types it, or its value with its type, and a format; an empty input or a null value clears the cell"`2525 DryRun bool `json:"dry_run,omitempty" jsonschema:"check the entries and return the change without making it"`2626 }2727 28+type tableIn struct {29+ bookIn30+ whyIn31+ headless.TableSpec32+ DryRun bool `json:"dry_run,omitempty" jsonschema:"check the rows and return the change without making it"`33+}34+2835 type opsIn struct {2936 bookIn3037 whyIn@@ -97,7 +104,9 @@ var writes = &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: pt
97104 func (s *Server) addWriteTools() {98105 set := headless.SetOptions{Force: s.opts.Force}99106 tool(s, &sdk.Tool{Name: "write_cells", Annotations: writes,100- Description: "Type entries into cells as one change, as 012 set does: each checked as typing it is (a formula must parse, validation rules, protected ranges), stopping at the first a cell can't take."},107+ Description: "Set cells as one change, as 012 set does: each checked as typing it is (a formula must parse, validation rules, protected ranges), stopping at the first a cell can't take. " +108+ "Write money, percentages, dates, times, durations and sizes with their types, never as bare numbers: an input as a person types it ($3.50, 12%, 2026-09-29), or a value ({\"currency\": 3.5}, {\"percent\": 0.12}, {\"date\": \"2026-09-29\"}, {\"duration\": \"90min\"}, {\"size\": \"1.5kb\"}); \"00123\" as a value stays text. " +109+ "format sets a number format code on the cell, or alone on a range. For rows of records, write_table."},101110 func(ctx context.Context, req *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {102111 var warnings []string103112 return s.change(ctx, req, in.Path, "set", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {@@ -105,8 +114,19 @@ func (s *Server) addWriteTools() {
105114 return err106115 })107116 })117+ tool(s, &sdk.Tool{Name: "write_table", Annotations: writes,118+ Description: "Write a table as one change: a header row at `at` naming the records' fields, then a row for each record, its values typed as write_cells takes them, " +119+ "each column formatted as its first typed value is (or as formats says), as importing a NUON table does. " +120+ "Example rows: [{\"Item\": \"Tea\", \"Price\": {\"currency\": 3.5}, \"Share\": {\"percent\": 0.12}, \"Bought\": {\"date\": \"2026-09-29\"}, \"Code\": \"00123\"}]."},121+ func(ctx context.Context, req *sdk.CallToolRequest, in tableIn) (*sdk.CallToolResult, Changed, error) {122+ var warnings []string123+ return s.change(ctx, req, in.Path, "write table", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {124+ warnings, err = headless.WriteTable(w, in.TableSpec, set)125+ return err126+ })127+ })108128 tool(s, &sdk.Tool{Name: "apply_operations", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},109- Description: "Make several operations as one change: set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name, sort."},129+ Description: "Make several operations as one change: set (input or typed value, and format, as write_cells), write_table (rows at ref, as write_table), clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name, sort."},110130 func(ctx context.Context, req *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {111131 var warnings []string112132 return s.change(ctx, req, in.Path, "apply operations", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {@@ -131,6 +131,7 @@ func (m *Model) feedOutput(name string) {
131131 op = sheet.LiveOp{Region: r.Name, Err: err.Error()}132132 break133133 }134+ fileio.KeepFormats(&rows, fileio.OutputFormats(w, o, m.rangeRef))134135 op.Header, op.Rows, op.Note = rows.Header, rows.Rows, note135136 }136137 if err := w.ApplyLive(op); err != nil {